Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/components/AIViewPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React, { useMemo } from "react";
import styled from "@emotion/styled";
import type { WorkspaceState } from "@/hooks/useWorkspaceAggregators";
import { MessageRenderer } from "./Messages/MessageRenderer";
import { ChatProvider } from "@/contexts/ChatContext";
import { mergeConsecutiveStreamErrors } from "@/utils/messages/messageUtils";

interface AIViewPreviewProps {
workspaceId: string;
projectName: string;
branch: string;
workspacePath: string;
workspaceState: WorkspaceState;
maxMessages?: number;
className?: string;
}

const PreviewContainer = styled.div`
width: 300px; /* match Tooltip width=\"wide\" max-width */
max-width: min(300px, 80vw);
max-height: 340px;
display: flex;
flex-direction: column;
background: #1f1f1f;
border: 1px solid #3a3a3a;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
color: #d4d4d4;
overflow: hidden;
pointer-events: none; /* Keep non-interactive to avoid stealing hover */
`;

const PreviewHeader = styled.div`
padding: 6px 10px;
background: #252526;
border-bottom: 1px solid #3e3e42;
font-size: 12px;
font-weight: 600;
color: #cccccc;
display: flex;
justify-content: space-between;
align-items: center;
`;

const HeaderPath = styled.span`
font-family: var(--font-monospace);
color: #888;
font-weight: 400;
font-size: 11px;
`;

const PreviewContent = styled.div`
padding: 10px;
overflow: hidden;
`;

const MessagesScroll = styled.div`
overflow: hidden; /* non-interactive */
`;

const FadeBottom = styled.div`
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 36px;
background: linear-gradient(to bottom, rgba(31, 31, 31, 0), rgba(31, 31, 31, 1));
pointer-events: none;
`;

const ContentWrapper = styled.div`
position: relative;
`;

/**
* Lightweight read-only view of recent messages for hover previews.
* Uses the same MessageRenderer components to ensure visual parity with AIView.
*/
export const AIViewPreview: React.FC<AIViewPreviewProps> = ({
workspaceId,
projectName,
branch,
workspacePath,
workspaceState,
maxMessages = 6,
className,
}) => {
const merged = useMemo(
() => mergeConsecutiveStreamErrors(workspaceState.messages),
[workspaceState.messages]
);

// Select only the last N messages for brevity
const messages = useMemo(
() => merged.slice(Math.max(0, merged.length - maxMessages)),
[merged, maxMessages]
);

return (
<ChatProvider
messages={messages}
cmuxMessages={workspaceState.cmuxMessages}
model={workspaceState.currentModel}
>
<PreviewContainer className={className} role="dialog" aria-label="Workspace preview">
<PreviewHeader>
<span>
{projectName} / {branch}
</span>
<HeaderPath>{workspacePath}</HeaderPath>
</PreviewHeader>
<PreviewContent>
<ContentWrapper>
<MessagesScroll>
{messages.length === 0 ? (
<div style={{ color: "#6b6b6b", textAlign: "center", padding: "12px 0" }}>
No messages yet
</div>
) : (
messages.map((msg) => (
<div key={msg.id} style={{ marginBottom: 8 }}>
<MessageRenderer
message={msg}
workspaceId={workspaceId}
model={workspaceState.currentModel}
/>
</div>
))
)}
</MessagesScroll>
<FadeBottom />
</ContentWrapper>
</PreviewContent>
</PreviewContainer>
</ChatProvider>
);
};
211 changes: 127 additions & 84 deletions src/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@ import type { WorkspaceState } from "@/hooks/useWorkspaceAggregators";
import SecretsModal from "./SecretsModal";
import type { Secret } from "@/types/secrets";
import { ForceDeleteModal } from "./ForceDeleteModal";
import { AIViewPreview } from "./AIViewPreview";

// HoverPreviewRenderer isolates preview mount logic to avoid re-render storms and keeps
// the ProjectSidebar lean. It renders a Tooltip portal whose contents are the AIViewPreview.
const HoverPreviewRenderer: React.FC<{
workspaceId: string;
projectName: string;
branch: string;
workspacePath: string;
workspaceState: WorkspaceState;
}> = ({ workspaceId, projectName, branch, workspacePath, workspaceState }) => {
return (
<Tooltip className="tooltip" align="right" width="wide">
<AIViewPreview
workspaceId={workspaceId}
projectName={projectName}
branch={branch}
workspacePath={workspacePath}
workspaceState={workspaceState}
/>
</Tooltip>
);
};

// Styled Components
const SidebarContent = styled.div`
Expand Down Expand Up @@ -982,100 +1005,120 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
const isSelected =
selectedWorkspace?.workspacePath === workspace.path;

// Compute preview props early to avoid re-computation in hover handlers
const previewProps = {
workspaceId,
projectName,
branch: displayName,
workspacePath: workspace.path,
workspaceState,
} as const;

return (
<React.Fragment key={workspace.path}>
<WorkspaceItem
selected={isSelected}
onClick={() =>
onSelectWorkspace({
projectPath,
projectName,
workspacePath: workspace.path,
workspaceId,
})
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<TooltipWrapper inline>
<WorkspaceItem
selected={isSelected}
onClick={() =>
onSelectWorkspace({
projectPath,
projectName,
workspacePath: workspace.path,
workspaceId,
});
})
}
}}
role="button"
tabIndex={0}
aria-current={isSelected ? "true" : undefined}
data-workspace-path={workspace.path}
data-workspace-id={workspaceId}
>
<TooltipWrapper inline>
<WorkspaceRemoveBtn
onClick={(e) => {
e.stopPropagation();
void handleRemoveWorkspace(workspaceId, e.currentTarget);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</WorkspaceRemoveBtn>
<Tooltip className="tooltip" align="right">
Remove workspace
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectWorkspace({
projectPath,
projectName,
workspacePath: workspace.path,
workspaceId,
});
}
}}
role="button"
tabIndex={0}
aria-current={isSelected ? "true" : undefined}
data-workspace-path={workspace.path}
data-workspace-id={workspaceId}
>
{/* Hover preview tooltip (does not steal hover) */}
<Tooltip className="tooltip" align="right" width="wide">
<AIViewPreview {...previewProps} />
</Tooltip>
</TooltipWrapper>
<GitStatusIndicator
gitStatus={gitStatus.get(metadata.id) ?? null}
workspaceId={workspaceId}
tooltipPosition="right"
/>
{isEditing ? (
<WorkspaceNameInput
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
onBlur={() => void confirmRename(workspaceId)}
autoFocus
onClick={(e) => e.stopPropagation()}
aria-label={`Rename workspace ${displayName}`}
data-workspace-id={workspaceId}
<TooltipWrapper inline>
<WorkspaceRemoveBtn
onClick={(e) => {
e.stopPropagation();
void handleRemoveWorkspace(
workspaceId,
e.currentTarget
);
}}
aria-label={`Remove workspace ${displayName}`}
data-workspace-id={workspaceId}
>
×
</WorkspaceRemoveBtn>
<Tooltip className="tooltip" align="right">
Remove workspace
</Tooltip>
</TooltipWrapper>
<GitStatusIndicator
gitStatus={gitStatus.get(metadata.id) ?? null}
workspaceId={workspaceId}
tooltipPosition="right"
/>
) : (
<WorkspaceName
onDoubleClick={(e) => {
e.stopPropagation();
startRenaming(workspaceId, displayName);
}}
title="Double-click to rename"
>
{displayName}
</WorkspaceName>
)}
<WorkspaceStatusIndicator
streaming={isStreaming}
unread={isUnread}
onClick={() => _onToggleUnread(workspaceId)}
title={
isStreaming && streamingModel ? (
<span>
<ModelDisplay
modelString={streamingModel}
showTooltip={false}
/>{" "}
is responding
</span>
) : isStreaming ? (
"Assistant is responding"
) : isUnread ? (
"Unread messages"
) : (
"Idle"
)
}
/>
</WorkspaceItem>
{isEditing ? (
<WorkspaceNameInput
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
onBlur={() => void confirmRename(workspaceId)}
autoFocus
onClick={(e) => e.stopPropagation()}
aria-label={`Rename workspace ${displayName}`}
data-workspace-id={workspaceId}
/>
) : (
<WorkspaceName
onDoubleClick={(e) => {
e.stopPropagation();
startRenaming(workspaceId, displayName);
}}
title="Double-click to rename"
>
{displayName}
</WorkspaceName>
)}
<WorkspaceStatusIndicator
streaming={isStreaming}
unread={isUnread}
onClick={() => _onToggleUnread(workspaceId)}
title={
isStreaming && streamingModel ? (
<span>
<ModelDisplay
modelString={streamingModel}
showTooltip={false}
/>{" "}
is responding
</span>
) : isStreaming ? (
"Assistant is responding"
) : isUnread ? (
"Unread messages"
) : (
"Idle"
)
}
/>
</WorkspaceItem>
{/* Hover preview portal */}
<HoverPreviewRenderer {...previewProps} />
</TooltipWrapper>
{renameError && editingWorkspaceId === workspaceId && (
<WorkspaceErrorContainer>{renameError}</WorkspaceErrorContainer>
)}
Expand Down
Loading