Skip to content

Commit e2cecb3

Browse files
committed
🤖 Hover AIView preview in sidebar: show recent messages on workspace hover without altering active view\n\n- Add AIViewPreview (read-only, last N messages, ChatProvider for parity)\n- Integrate preview as Tooltip content around WorkspaceItem\n- Keep non-interactive/pointer-events:none to avoid stealing focus\n- Uses existing MessageRenderer for full parity\n\nGenerated with
1 parent df4c863 commit e2cecb3

File tree

2 files changed

+260
-79
lines changed

2 files changed

+260
-79
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import React, { useMemo } from "react";
2+
import styled from "@emotion/styled";
3+
import type { WorkspaceState } from "@/hooks/useWorkspaceAggregators";
4+
import { MessageRenderer } from "./Messages/MessageRenderer";
5+
import { ChatProvider } from "@/contexts/ChatContext";
6+
import { mergeConsecutiveStreamErrors } from "@/utils/messages/messageUtils";
7+
8+
interface AIViewPreviewProps {
9+
workspaceId: string;
10+
projectName: string;
11+
branch: string;
12+
workspacePath: string;
13+
workspaceState: WorkspaceState;
14+
maxMessages?: number;
15+
className?: string;
16+
}
17+
18+
const PreviewContainer = styled.div`
19+
width: 300px; /* match Tooltip width=\"wide\" max-width */
20+
max-width: min(300px, 80vw);
21+
max-height: 340px;
22+
display: flex;
23+
flex-direction: column;
24+
background: #1f1f1f;
25+
border: 1px solid #3a3a3a;
26+
border-radius: 8px;
27+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
28+
color: #d4d4d4;
29+
overflow: hidden;
30+
pointer-events: none; /* Keep non-interactive to avoid stealing hover */
31+
`;
32+
33+
const PreviewHeader = styled.div`
34+
padding: 6px 10px;
35+
background: #252526;
36+
border-bottom: 1px solid #3e3e42;
37+
font-size: 12px;
38+
font-weight: 600;
39+
color: #cccccc;
40+
display: flex;
41+
justify-content: space-between;
42+
align-items: center;
43+
`;
44+
45+
const HeaderPath = styled.span`
46+
font-family: var(--font-monospace);
47+
color: #888;
48+
font-weight: 400;
49+
font-size: 11px;
50+
`;
51+
52+
const PreviewContent = styled.div`
53+
padding: 10px;
54+
overflow: hidden;
55+
`;
56+
57+
const MessagesScroll = styled.div`
58+
overflow: hidden; /* non-interactive */
59+
`;
60+
61+
const FadeBottom = styled.div`
62+
position: absolute;
63+
bottom: 0;
64+
left: 0;
65+
right: 0;
66+
height: 36px;
67+
background: linear-gradient(to bottom, rgba(31, 31, 31, 0), rgba(31, 31, 31, 1));
68+
pointer-events: none;
69+
`;
70+
71+
const ContentWrapper = styled.div`
72+
position: relative;
73+
`;
74+
75+
/**
76+
* Lightweight read-only view of recent messages for hover previews.
77+
* Uses the same MessageRenderer components to ensure visual parity with AIView.
78+
*/
79+
export const AIViewPreview: React.FC<AIViewPreviewProps> = ({
80+
workspaceId,
81+
projectName,
82+
branch,
83+
workspacePath,
84+
workspaceState,
85+
maxMessages = 6,
86+
className,
87+
}) => {
88+
const merged = useMemo(() => mergeConsecutiveStreamErrors(workspaceState.messages), [
89+
workspaceState.messages,
90+
]);
91+
92+
// Select only the last N messages for brevity
93+
const messages = useMemo(() => merged.slice(Math.max(0, merged.length - maxMessages)), [
94+
merged,
95+
maxMessages,
96+
]);
97+
98+
return (
99+
<ChatProvider
100+
messages={messages}
101+
cmuxMessages={workspaceState.cmuxMessages}
102+
model={workspaceState.currentModel}
103+
>
104+
<PreviewContainer className={className} role="dialog" aria-label="Workspace preview">
105+
<PreviewHeader>
106+
<span>
107+
{projectName} / {branch}
108+
</span>
109+
<HeaderPath>{workspacePath}</HeaderPath>
110+
</PreviewHeader>
111+
<PreviewContent>
112+
<ContentWrapper>
113+
<MessagesScroll>
114+
{messages.length === 0 ? (
115+
<div style={{ color: "#6b6b6b", textAlign: "center", padding: "12px 0" }}>
116+
No messages yet
117+
</div>
118+
) : (
119+
messages.map((msg) => (
120+
<div key={msg.id} style={{ marginBottom: 8 }}>
121+
<MessageRenderer
122+
message={msg}
123+
workspaceId={workspaceId}
124+
model={workspaceState.currentModel}
125+
/>
126+
</div>
127+
))
128+
)}
129+
</MessagesScroll>
130+
<FadeBottom />
131+
</ContentWrapper>
132+
</PreviewContent>
133+
</PreviewContainer>
134+
</ChatProvider>
135+
);
136+
};
137+

‎src/components/ProjectSidebar.tsx‎

Lines changed: 123 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,29 @@ import type { WorkspaceState } from "@/hooks/useWorkspaceAggregators";
2020
import SecretsModal from "./SecretsModal";
2121
import type { Secret } from "@/types/secrets";
2222
import { ForceDeleteModal } from "./ForceDeleteModal";
23+
import { AIViewPreview } from "./AIViewPreview";
24+
25+
// HoverPreviewRenderer isolates preview mount logic to avoid re-render storms and keeps
26+
// the ProjectSidebar lean. It renders a Tooltip portal whose contents are the AIViewPreview.
27+
const HoverPreviewRenderer: React.FC<{
28+
workspaceId: string;
29+
projectName: string;
30+
branch: string;
31+
workspacePath: string;
32+
workspaceState: WorkspaceState;
33+
}> = ({ workspaceId, projectName, branch, workspacePath, workspaceState }) => {
34+
return (
35+
<Tooltip className="tooltip" align="right" width="wide">
36+
<AIViewPreview
37+
workspaceId={workspaceId}
38+
projectName={projectName}
39+
branch={branch}
40+
workspacePath={workspacePath}
41+
workspaceState={workspaceState}
42+
/>
43+
</Tooltip>
44+
);
45+
};
2346

2447
// Styled Components
2548
const SidebarContent = styled.div`
@@ -981,102 +1004,123 @@ const ProjectSidebar: React.FC<ProjectSidebarProps> = ({
9811004
const isSelected =
9821005
selectedWorkspace?.workspacePath === workspace.path;
9831006

1007+
// Compute preview props early to avoid re-computation in hover handlers
1008+
const previewProps = {
1009+
workspaceId,
1010+
projectName,
1011+
branch: displayName,
1012+
workspacePath: workspace.path,
1013+
workspaceState,
1014+
} as const;
1015+
9841016
return (
9851017
<React.Fragment key={workspace.path}>
986-
<WorkspaceItem
987-
selected={isSelected}
988-
onClick={() =>
989-
onSelectWorkspace({
990-
projectPath,
991-
projectName,
992-
workspacePath: workspace.path,
993-
workspaceId,
994-
})
995-
}
996-
onKeyDown={(e) => {
997-
if (e.key === "Enter" || e.key === " ") {
998-
e.preventDefault();
1018+
<TooltipWrapper inline>
1019+
<WorkspaceItem
1020+
selected={isSelected}
1021+
onClick={() =>
9991022
onSelectWorkspace({
10001023
projectPath,
10011024
projectName,
10021025
workspacePath: workspace.path,
10031026
workspaceId,
1004-
});
1027+
})
10051028
}
1006-
}}
1007-
role="button"
1008-
tabIndex={0}
1009-
aria-current={isSelected ? "true" : undefined}
1010-
data-workspace-path={workspace.path}
1011-
data-workspace-id={workspaceId}
1012-
>
1013-
<TooltipWrapper inline>
1014-
<WorkspaceRemoveBtn
1015-
onClick={(e) => {
1016-
e.stopPropagation();
1017-
void handleRemoveWorkspace(workspaceId, e.currentTarget);
1018-
}}
1019-
aria-label={`Remove workspace ${displayName}`}
1020-
data-workspace-id={workspaceId}
1021-
>
1022-
×
1023-
</WorkspaceRemoveBtn>
1024-
<Tooltip className="tooltip" align="right">
1025-
Remove workspace
1029+
onKeyDown={(e) => {
1030+
if (e.key === "Enter" || e.key === " ") {
1031+
e.preventDefault();
1032+
onSelectWorkspace({
1033+
projectPath,
1034+
projectName,
1035+
workspacePath: workspace.path,
1036+
workspaceId,
1037+
});
1038+
}
1039+
}}
1040+
role="button"
1041+
tabIndex={0}
1042+
aria-current={isSelected ? "true" : undefined}
1043+
data-workspace-path={workspace.path}
1044+
data-workspace-id={workspaceId}
1045+
>
1046+
<Tooltip className="tooltip" align="right" width="wide">
1047+
{/* Lazy import to avoid bundle growth in main path */}
1048+
{/* We avoid dynamic imports per repo rules; component is small */}
1049+
{/* Render compact AIViewPreview inside tooltip for hover glance */}
1050+
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
1051+
{/* @ts-ignore - imported at top-level */}
1052+
{(() => {
1053+
// Inline require to avoid circulars; still static import at file top not allowed per size?
1054+
// We already created component at src/components/AIViewPreview.tsx
1055+
// Importing statically:
1056+
return null;
1057+
})()}
10261058
</Tooltip>
1027-
</TooltipWrapper>
1028-
<GitStatusIndicator
1029-
gitStatus={gitStatus.get(metadata.id) ?? null}
1030-
workspaceId={workspaceId}
1031-
tooltipPosition="right"
1032-
/>
1033-
{isEditing ? (
1034-
<WorkspaceNameInput
1035-
value={editingName}
1036-
onChange={(e) => setEditingName(e.target.value)}
1037-
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
1038-
onBlur={() => void confirmRename(workspaceId)}
1039-
autoFocus
1040-
onClick={(e) => e.stopPropagation()}
1041-
aria-label={`Rename workspace ${displayName}`}
1042-
data-workspace-id={workspaceId}
1059+
<TooltipWrapper inline>
1060+
<WorkspaceRemoveBtn
1061+
onClick={(e) => {
1062+
e.stopPropagation();
1063+
void handleRemoveWorkspace(workspaceId, e.currentTarget);
1064+
}}
1065+
aria-label={`Remove workspace ${displayName}`}
1066+
data-workspace-id={workspaceId}
1067+
>
1068+
×
1069+
</WorkspaceRemoveBtn>
1070+
<Tooltip className="tooltip" align="right">
1071+
Remove workspace
1072+
</Tooltip>
1073+
</TooltipWrapper>
1074+
<GitStatusIndicator
1075+
gitStatus={gitStatus.get(metadata.id) ?? null}
1076+
workspaceId={workspaceId}
1077+
tooltipPosition="right"
10431078
/>
1044-
) : (
1045-
<WorkspaceName
1046-
onDoubleClick={(e) => {
1047-
e.stopPropagation();
1048-
startRenaming(workspaceId, displayName);
1049-
}}
1050-
title="Double-click to rename"
1051-
>
1052-
{displayName}
1053-
</WorkspaceName>
1054-
)}
1055-
<WorkspaceStatusIndicator
1056-
streaming={isStreaming}
1057-
unread={isUnread}
1058-
onClick={() => _onToggleUnread(workspaceId)}
1059-
title={
1060-
isStreaming
1061-
? "Assistant is responding"
1062-
: isUnread
1063-
? "Unread messages"
1064-
: "Idle"
1065-
}
1066-
/>
1067-
</WorkspaceItem>
1079+
{isEditing ? (
1080+
<WorkspaceNameInput
1081+
value={editingName}
1082+
onChange={(e) => setEditingName(e.target.value)}
1083+
onKeyDown={(e) => handleRenameKeyDown(e, workspaceId)}
1084+
onBlur={() => void confirmRename(workspaceId)}
1085+
autoFocus
1086+
onClick={(e) => e.stopPropagation()}
1087+
aria-label={`Rename workspace ${displayName}`}
1088+
data-workspace-id={workspaceId}
1089+
/>
1090+
) : (
1091+
<WorkspaceName
1092+
onDoubleClick={(e) => {
1093+
e.stopPropagation();
1094+
startRenaming(workspaceId, displayName);
1095+
}}
1096+
title="Double-click to rename"
1097+
>
1098+
{displayName}
1099+
</WorkspaceName>
1100+
)}
1101+
<WorkspaceStatusIndicator
1102+
streaming={isStreaming}
1103+
unread={isUnread}
1104+
onClick={() => _onToggleUnread(workspaceId)}
1105+
title={
1106+
isStreaming
1107+
? "Assistant is responding"
1108+
: isUnread
1109+
? "Unread messages"
1110+
: "Idle"
1111+
}
1112+
/>
1113+
</WorkspaceItem>
1114+
{/* Hover preview portal */}
1115+
<HoverPreviewRenderer {...previewProps} />
1116+
</TooltipWrapper>
10681117
{renameError && editingWorkspaceId === workspaceId && (
10691118
<WorkspaceErrorContainer>{renameError}</WorkspaceErrorContainer>
10701119
)}
10711120
</React.Fragment>
10721121
);
10731122
}
10741123
)}
1075-
</WorkspacesContainer>
1076-
)}
1077-
</ProjectGroup>
1078-
);
1079-
})
10801124
)}
10811125
</ProjectsList>
10821126
</>

0 commit comments

Comments
 (0)