diff --git a/src/components/AIViewPreview.tsx b/src/components/AIViewPreview.tsx new file mode 100644 index 000000000..c1454e1c3 --- /dev/null +++ b/src/components/AIViewPreview.tsx @@ -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 = ({ + 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 ( + + + + + {projectName} / {branch} + + {workspacePath} + + + + + {messages.length === 0 ? ( +
+ No messages yet +
+ ) : ( + messages.map((msg) => ( +
+ +
+ )) + )} +
+ +
+
+
+
+ ); +}; diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 3c59a0a21..5400d63f2 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -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 ( + + + + ); +}; // Styled Components const SidebarContent = styled.div` @@ -982,100 +1005,120 @@ const ProjectSidebar: React.FC = ({ 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 ( - - onSelectWorkspace({ - projectPath, - projectName, - workspacePath: workspace.path, - workspaceId, - }) - } - 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} - > - - { - e.stopPropagation(); - void handleRemoveWorkspace(workspaceId, e.currentTarget); - }} - aria-label={`Remove workspace ${displayName}`} - data-workspace-id={workspaceId} - > - × - - - 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) */} + + - - - {isEditing ? ( - 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} + + { + e.stopPropagation(); + void handleRemoveWorkspace( + workspaceId, + e.currentTarget + ); + }} + aria-label={`Remove workspace ${displayName}`} + data-workspace-id={workspaceId} + > + × + + + Remove workspace + + + - ) : ( - { - e.stopPropagation(); - startRenaming(workspaceId, displayName); - }} - title="Double-click to rename" - > - {displayName} - - )} - _onToggleUnread(workspaceId)} - title={ - isStreaming && streamingModel ? ( - - {" "} - is responding - - ) : isStreaming ? ( - "Assistant is responding" - ) : isUnread ? ( - "Unread messages" - ) : ( - "Idle" - ) - } - /> - + {isEditing ? ( + 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} + /> + ) : ( + { + e.stopPropagation(); + startRenaming(workspaceId, displayName); + }} + title="Double-click to rename" + > + {displayName} + + )} + _onToggleUnread(workspaceId)} + title={ + isStreaming && streamingModel ? ( + + {" "} + is responding + + ) : isStreaming ? ( + "Assistant is responding" + ) : isUnread ? ( + "Unread messages" + ) : ( + "Idle" + ) + } + /> + + {/* Hover preview portal */} + + {renameError && editingWorkspaceId === workspaceId && ( {renameError} )}