diff --git a/apps/debug-frontend/src/daemon/events/hub-client.ts b/apps/debug-frontend/src/daemon/events/hub-client.ts index 71bf964e3..d5580ff4d 100644 --- a/apps/debug-frontend/src/daemon/events/hub-client.ts +++ b/apps/debug-frontend/src/daemon/events/hub-client.ts @@ -221,6 +221,7 @@ export class DaemonHubClient { this.socket = undefined; this.daemonSubscribed = false; socket?.close(); + this.scheduleReconnect(); return; } if ( diff --git a/apps/frontend/index.html b/apps/frontend/index.html index a975c0f7a..670f0034e 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -1,9 +1,20 @@ - + Plannotator +
diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 07e5eec54..e1d9c3e2f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -17,6 +17,7 @@ "dependencies": { "@fontsource-variable/geist-mono": "^5.2.7", "@fontsource-variable/inter": "^5.2.8", + "@plannotator/code-review": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "@radix-ui/react-collapsible": "^1.1.12", diff --git a/apps/frontend/src/app/Layout.tsx b/apps/frontend/src/app/Layout.tsx index 6a6876c19..9e45dd090 100644 --- a/apps/frontend/src/app/Layout.tsx +++ b/apps/frontend/src/app/Layout.tsx @@ -1,16 +1,22 @@ import { useCallback, useEffect } from "react"; -import { Outlet } from "@tanstack/react-router"; +import { Outlet, useMatchRoute } from "@tanstack/react-router"; import { Toaster } from "sonner"; -import { SidebarProvider } from "@/components/ui/sidebar"; +import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { AppSidebar } from "../components/sidebar/AppSidebar"; import { AddProjectDialog } from "../components/landing/AddProjectDialog"; +import { SessionSurface } from "../components/sessions/SessionSurface"; import { useDaemonEvents } from "../daemon/events/use-daemon-events"; import { projectStore } from "../stores/project-store"; import { useAppStore } from "../stores/app-store"; -export function Layout() { +function LayoutContent() { const addProjectOpen = useAppStore((s) => s.addProjectOpen); const setAddProjectOpen = useAppStore((s) => s.setAddProjectOpen); + const activeSessionId = useAppStore((s) => s.activeSessionId); + const visitedSessions = useAppStore((s) => s.visitedSessions); + const matchRoute = useMatchRoute(); + const { open: sidebarOpen } = useSidebar(); useDaemonEvents(); @@ -18,19 +24,62 @@ export function Layout() { void projectStore.getState().fetchProjects(); }, []); + const isOnSession = !!matchRoute({ to: "/s/$sessionId", fuzzy: true }); + const showLanding = !isOnSession; + const openAddProject = useCallback(() => setAddProjectOpen(true), [setAddProjectOpen]); return ( - + <> -
- +
+
+ +
+ + {Object.values(visitedSessions).map(({ sessionId, bootstrap }) => ( +
+ +
+ ))}
- - + + + ); +} + +export function Layout() { + return ( + + + + + ); } diff --git a/apps/frontend/src/app/router.tsx b/apps/frontend/src/app/router.tsx index 63b8e47d0..3693e34c7 100644 --- a/apps/frontend/src/app/router.tsx +++ b/apps/frontend/src/app/router.tsx @@ -13,6 +13,8 @@ export function createAppRouter( routeTree, context, defaultPreload: "intent", + defaultPendingMs: 0, + defaultPendingMinMs: 0, }); } diff --git a/apps/frontend/src/components/landing/LandingPage.tsx b/apps/frontend/src/components/landing/LandingPage.tsx index 89ffcbed6..26d6c87e0 100644 --- a/apps/frontend/src/components/landing/LandingPage.tsx +++ b/apps/frontend/src/components/landing/LandingPage.tsx @@ -152,7 +152,7 @@ function ProjectTable({ onSelect: (name: string) => void; }) { return ( -
+
{projects.map((project, i) => ( +
+ + )} + {prMetadata ? ( +
+ + + {displayRepo} + + + +
+ + + +
+
+ ) : repoInfo ? ( +
+ {repoInfo.branch && ( + + {repoInfo.branch} + + )} + + + {repoInfo.display} + +
+ ) : ( + Review + )} +
+ +
+ {/* Diff style toggle */} +
+ + +
+ + {origin ? ( + <> + {/* Destination dropdown (PR mode only) */} + {prMetadata && ( +
+ + {showDestinationMenu && ( + <> +
setShowDestinationMenu(false)} /> +
+ + +
+ + {altKey} + {altKey} + to toggle + +
+
+ + )} +
+ )} + + {/* GitHub error message */} + {platformActionError && ( +
+ {platformActionError} +
+ )} + + {/* Agent mode: Close/SendFeedback flip + Approve */} + {!platformMode ? ( + totalAnnotationCount > 0 ? setShowApproveWarning(true) : handleApprove()} + onExit={() => totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + /> + ) : ( + <> + {/* Platform mode: Close + Post Comments + Approve */} + totalAnnotationCount > 0 ? setShowExitWarning(true) : handleExit()} + disabled={isSendingFeedback || isApproving || isExiting || isPlatformActioning} + isLoading={isExiting} + /> + openPlatformDialog('comment')} + disabled={isSendingFeedback || isApproving || isPlatformActioning} + isLoading={isSendingFeedback || isPlatformActioning} + label="Post Comments" + shortLabel="Post" + loadingLabel="Posting..." + shortLoadingLabel="Posting..." + title="Post review to platform" + /> +
+ { + if (platformUser && prMetadata?.author === platformUser) return; + openPlatformDialog('approve'); + }} + disabled={ + isSendingFeedback || isApproving || isPlatformActioning || + (!!platformUser && prMetadata?.author === platformUser) + } + isLoading={isApproving} + muted={!!platformUser && prMetadata?.author === platformUser && !isSendingFeedback && !isApproving && !isPlatformActioning} + title={ + platformUser && prMetadata?.author === platformUser + ? `You can't approve your own ${mrLabel}` + : "Approve - no changes needed" + } + /> + {platformUser && prMetadata?.author === platformUser && ( +
+
+
+ You can't approve your own {mrLabel === 'MR' ? 'merge request' : 'pull request'} on {platformLabel}. +
+ )} +
+ + )} + + ) : ( + + )} + +
+ + setOpenSettingsMenu(true)} + onOpenExport={() => setShowExportModal(true)} + onToggleFileTree={() => setIsFileTreeOpen(prev => !prev)} + onToggleSidebar={() => reviewSidebar.isOpen ? reviewSidebar.close() : reviewSidebar.open()} + isFileTreeOpen={isFileTreeOpen} + isSidebarOpen={reviewSidebar.isOpen} + appVersion={appVersion} + /> + +
+ + {/* Sidebar tab toggles */} + + {aiAvailable && ( + + )} + {agentJobs.capabilities?.available && ( + + )} +
+ + + {/* Main content */} +
+ {shouldShowFileTree && isFileTreeOpen && ( + <> + f.path === allFilesVisibleFile) : undefined} + onSelectFile={handleFilePreview} + onDoubleClickFile={handleFilePinned} + annotations={allAnnotations} + viewedFiles={viewedFiles} + onToggleViewed={handleToggleViewed} + hideViewedFiles={hideViewedFiles} + onToggleHideViewed={() => setHideViewedFiles(prev => !prev)} + enableKeyboardNav={!showExportModal && hasSearchableFiles} + diffOptions={gitContext?.diffOptions} + activeDiffType={activeDiffBase} + onSelectDiff={handleDiffSwitch} + isLoadingDiff={isLoadingDiff} + width={fileTreeResize.width} + worktrees={gitContext?.worktrees} + activeWorktreePath={activeWorktreePath} + onSelectWorktree={handleWorktreeSwitch} + currentBranch={gitContext?.currentBranch} + availableBranches={prMetadata ? undefined : gitContext?.availableBranches} + selectedBase={prMetadata ? undefined : selectedBase ?? undefined} + detectedBase={prMetadata ? undefined : gitContext?.defaultBranch || gitContext?.compareTarget?.fallback} + onSelectBase={prMetadata ? undefined : handleBaseSelect} + compareTarget={gitContext?.compareTarget} + recentCommits={prMetadata ? undefined : gitContext?.recentCommits} + jjEvologs={prMetadata ? undefined : gitContext?.jjEvologs} + detectedEvoBase={prMetadata ? undefined : gitContext?.jjEvologs?.[1]?.commitId} + stagedFiles={stagedFiles} + onCopyRawDiff={handleCopyDiff} + canCopyRawDiff={!!diffData?.rawPatch} + copyRawDiffStatus={copyRawDiffStatus} + searchQuery={hasSearchableFiles ? searchQuery : ''} + isSearchOpen={hasSearchableFiles ? isSearchOpen : false} + isSearchPending={isSearchPending} + searchInputRef={hasSearchableFiles ? searchInputRef : undefined} + onOpenSearch={hasSearchableFiles ? openSearch : undefined} + onSearchChange={hasSearchableFiles ? handleSearchInputChange : undefined} + onSearchClear={hasSearchableFiles ? clearSearch : undefined} + onSearchClose={hasSearchableFiles ? closeSearch : undefined} + searchGroups={hasSearchableFiles ? searchGroups : []} + searchMatches={hasSearchableFiles ? searchMatches : []} + activeSearchMatchId={hasSearchableFiles ? activeSearchMatchId : null} + onSelectSearchMatch={hasSearchableFiles ? handleSelectSearchMatch : undefined} + onStepSearchMatch={hasSearchableFiles ? stepSearchMatch : undefined} + repoRoot={prMetadata ? null : (activeWorktreePath ?? agentCwd ?? gitContext?.cwd ?? null)} + /> + + + )} + + {/* Center dock area */} +
+ {files.length > 0 ? ( + + ) : ( +
+
+
+ {diffError ? ( + + + + ) : ( + + + + )} +
+
+ {diffError ? ( + <> +

Failed to load diff

+

{diffError}

+ + ) : ( + <> +

No changes

+

+ {activeDiffBase === 'uncommitted' && `No uncommitted changes${activeWorktreePath ? ' in this worktree' : ' to review'}.`} + {activeDiffBase === 'staged' && "No staged changes. Stage some files with git add."} + {activeDiffBase === 'unstaged' && "No unstaged changes. All changes are staged."} + {activeDiffBase === 'last-commit' && `No changes in the last commit${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'jj-current' && "No changes in the current jj change."} + {activeDiffBase === 'jj-last' && "No changes in the last jj change."} + {activeDiffBase === 'jj-line' && `No changes in your line of work vs ${selectedBase || gitContext?.defaultBranch || '@-'}.`} + {activeDiffBase === 'jj-evolog' && `No changes since evolution ${selectedBase ? selectedBase.slice(0, 8) : 'previous'} — the change looks the same as before.`} + {activeDiffBase === 'jj-all' && "No files at the current jj change."} + {activeDiffBase === 'branch' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'merge-base' && `No changes vs ${selectedBase || gitContext?.defaultBranch || 'main'}${activeWorktreePath ? ' in this worktree' : ''}.`} + {activeDiffBase === 'all' && `No tracked files${activeWorktreePath ? ' in this worktree' : ' in this repository'}.`} +

+ + )} +
+ {gitContext?.diffOptions && gitContext.diffOptions.length > 1 && ( +

+ Try selecting a different view from the dropdown. +

+ )} +
+
+ )} +
+ + {/* Resize Handle + Sidebar */} + {reviewSidebar.isOpen && ( + <> + + + + )} +
+ + {/* Export Modal */} + {showExportModal && ( +
+
+
+

Export Review Feedback

+ +
+
+
+ {allAnnotations.length} annotation{allAnnotations.length !== 1 ? 's' : ''} +
+
+                  {feedbackMarkdown}
+                
+
+
+ +
+
+
+ )} + + + + {/* Worktree info dialog */} + {(gitContext?.cwd || agentCwd) && prMetadata && ( + setShowWorktreeDialog(false)} + title="Local Worktree" + wide + message={ +
+

This PR is checked out locally so review agents have full file access.

+
+ Path + +
+

Automatically removed when this review session ends.

+
+ } + variant="info" + /> + )} + + {/* No annotations dialog */} + setShowNoAnnotationsDialog(false)} + title="No Annotations" + message="You haven't made any annotations yet. There's nothing to copy." + variant="info" + /> + + {/* Approve with annotations warning */} + setShowApproveWarning(false)} + onConfirm={() => { + setShowApproveWarning(false); + handleApprove(); + }} + title="Annotations Won't Be Sent" + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you approve.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Approve Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + + setShowExitWarning(false)} + onConfirm={() => { + setShowExitWarning(false); + handleExit(); + }} + title="Annotations Won't Be Sent" + message={<>You have {totalAnnotationCount} annotation{totalAnnotationCount !== 1 ? 's' : ''} that will be lost if you close.} + subMessage="To send your feedback, use Send Feedback instead." + confirmText="Close Anyway" + cancelText="Cancel" + variant="warning" + showCancel + /> + + {/* AI setup dialog — first-run only */} + { + setShowAISetup(false); + handleAIConfigChange({ providerId }); + }} + /> + + {/* Diff type setup dialog — first-run only */} + {showDiffTypeSetup && ( + { + setShowDiffTypeSetup(false); + if (selected !== diffType) handleDiffSwitch(selected); + }} + /> + )} + + {/* Completion overlay - shown after approve/feedback/exit */} + + + {/* Update notification */} + + + {/* GitHub general comment dialog */} + { + setPlatformOpenPR(checked); + storage.setItem('plannotator-platform-open-pr', String(checked)); + }} + onConfirm={() => { + if (!platformCommentDialog) return; + handlePlatformAction(platformCommentDialog.action, platformCommentDialog.plan, platformGeneralComment); + }} + onCancel={() => setPlatformCommentDialog(null)} + isSubmitting={isPlatformActioning} + mrLabel={mrLabel} + platformLabel={platformLabel} + /> +
+ + {/* Tour dialog overlay */} + setTourDialogJobId(null)} /> + + {/* Dev-only: open a fully-formed demo tour without running the agent. + Stripped from production builds via import.meta.env.DEV. */} + {import.meta.env.DEV && ( + + )} + + {!__embedded && ( + + )} + + + ); + + if (__embedded) return innerContent; + + return ( + + + {innerContent} + + + ); +}; + +export default ReviewApp; + +export function ReviewAppEmbedded({ headerLeft }: { headerLeft?: React.ReactNode }) { + return ; +} + diff --git a/packages/plannotator-code-review/components/AIConfigBar.tsx b/packages/plannotator-code-review/components/AIConfigBar.tsx new file mode 100644 index 000000000..d3b2986d7 --- /dev/null +++ b/packages/plannotator-code-review/components/AIConfigBar.tsx @@ -0,0 +1,275 @@ +import type React from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { getProviderMeta } from '@plannotator/ui/components/ProviderIcons'; + +interface AIProviderModel { + id: string; + label: string; + default?: boolean; +} + +interface AIProviderInfo { + id: string; + name: string; + models?: AIProviderModel[]; +} + +const REASONING_EFFORTS = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Max' }, +] as const; + +interface AIConfigBarProps { + providers: AIProviderInfo[]; + selectedProviderId: string | null; + selectedModel: string | null; + selectedReasoningEffort: string | null; + onProviderChange: (providerId: string) => void; + onModelChange: (model: string) => void; + onReasoningEffortChange: (effort: string | null) => void; + hasSession: boolean; +} + +export const AIConfigBar: React.FC = ({ + providers, + selectedProviderId, + selectedModel, + selectedReasoningEffort, + onProviderChange, + onModelChange, + onReasoningEffortChange, + hasSession, +}) => { + const [showSessionNote, setShowSessionNote] = useState(false); + const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null); + const [modelSearch, setModelSearch] = useState(''); + const barRef = useRef(null); + const searchInputRef = useRef(null); + + // Flash "New chat session" briefly when config changes while a session exists + useEffect(() => { + if (showSessionNote) { + const t = setTimeout(() => setShowSessionNote(false), 2000); + return () => clearTimeout(t); + } + }, [showSessionNote]); + + // Close menu on click outside + useEffect(() => { + if (!openMenu) return; + const handler = (e: MouseEvent) => { + if (barRef.current && !barRef.current.contains(e.target as Node)) { + setOpenMenu(null); + setModelSearch(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [openMenu]); + + if (providers.length === 0) { + return ( +
+ No AI providers available +
+ ); + } + + const effectiveProviderId = selectedProviderId ?? providers[0]?.id; + const currentProvider = providers.find(p => p.id === effectiveProviderId) ?? providers[0]; + if (!currentProvider) return null; + + const meta = getProviderMeta(currentProvider.name); + const Icon = meta.icon; + const models = currentProvider.models ?? []; + const defaultModel = models.find(m => m.default) ?? models[0]; + const effectiveModel = selectedModel ?? defaultModel?.id; + const currentModelLabel = models.find(m => m.id === effectiveModel)?.label ?? defaultModel?.label; + + const handleProviderSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onProviderChange(id); + setOpenMenu(null); + }; + + const handleModelSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onModelChange(id); + setOpenMenu(null); + setModelSearch(''); + }; + + const handleEffortSelect = (id: string) => { + if (hasSession) setShowSessionNote(true); + onReasoningEffortChange(id); + setOpenMenu(null); + }; + + const chevron = ( + + + + ); + + return ( +
+ {/* Provider selector */} + {providers.length > 1 ? ( +
+ + + {openMenu === 'provider' && ( +
+ {providers.map(p => { + const m = getProviderMeta(p.name); + const ProvIcon = m.icon; + const isActive = p.id === effectiveProviderId; + return ( + + ); + })} +
+ )} +
+ ) : ( + + + {meta.label} + + )} + + {/* Model selector */} + {models.length > 1 ? ( + <> + · +
+ + + {openMenu === 'model' && ( +
+ {models.length > 8 && ( +
+ setModelSearch(e.target.value)} + autoFocus + /> +
+ )} +
8 ? 'ai-config-menu-scroll' : ''}> + {models + .filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase())) + .map(m => { + const isActive = m.id === effectiveModel; + return ( + + ); + })} +
+
+ )} +
+ + ) : currentModelLabel ? ( + <> + · + {currentModelLabel} + + ) : null} + + {/* Reasoning effort — Codex only */} + {currentProvider.name === 'codex-sdk' && ( + <> + · +
+ + + {openMenu === 'effort' && ( +
+ {REASONING_EFFORTS.map(e => { + const isActive = e.id === (selectedReasoningEffort ?? 'high'); + return ( + + ); + })} +
+ )} +
+ + )} + + {/* Spacer */} +
+ + {/* Session reset note */} + {showSessionNote && ( + New chat session + )} +
+ ); +}; diff --git a/packages/plannotator-code-review/components/AITab.tsx b/packages/plannotator-code-review/components/AITab.tsx new file mode 100644 index 000000000..4d395e9cd --- /dev/null +++ b/packages/plannotator-code-review/components/AITab.tsx @@ -0,0 +1,398 @@ +import React, { useRef, useEffect, useState, useMemo, useCallback, memo } from 'react'; +import type { AIChatEntry, PendingPermission } from '../hooks/useAIChat'; +import { renderChatMarkdown } from '../utils/renderChatMarkdown'; +import { formatLineRange } from '../utils/formatLineRange'; +import { formatRelativeTime } from '../utils/formatRelativeTime'; +import { SparklesIcon } from './SparklesIcon'; +import { CountBadge } from './CountBadge'; +import { CopyButton } from './CopyButton'; +import { PermissionCard } from './PermissionCard'; +import { AIConfigBar } from './AIConfigBar'; +import { submitHint } from '@plannotator/ui/utils/platform'; +import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'; + +interface AIProviderInfo { + id: string; + name: string; + models?: Array<{ id: string; label: string; default?: boolean }>; +} + +interface AITabProps { + messages: AIChatEntry[]; + isCreatingSession: boolean; + isStreaming: boolean; + activeFilePath?: string; + scrollToQuestionId?: string | null; + onScrollToLines: (filePath: string, lineStart: number, lineEnd: number, side: 'old' | 'new') => void; + onAskGeneral?: (question: string) => void; + permissionRequests?: PendingPermission[]; + onRespondToPermission?: (requestId: string, allow: boolean) => void; + aiProviders?: AIProviderInfo[]; + aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; + onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; + hasAISession?: boolean; +} + +interface FileGroup { + filePath: string; + messages: AIChatEntry[]; +} + +function getQuestionScope(q: AIChatEntry['question']): 'general' | 'file' | 'line' { + if (!q.filePath) return 'general'; + if (q.lineStart == null) return 'file'; + return 'line'; +} + +export const AITab: React.FC = ({ + messages, + isCreatingSession, + isStreaming, + activeFilePath, + scrollToQuestionId, + onScrollToLines, + onAskGeneral, + permissionRequests = [], + onRespondToPermission, + aiProviders = [], + aiConfig, + onAIConfigChange, + hasAISession = false, +}) => { + const scrollRef = useRef(null); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const [generalInput, setGeneralInput] = useState(''); + const [highlightFilePath, setHighlightFilePath] = useState(null); + + // Group messages by file + const { fileGroups, generalMessages } = useMemo(() => { + const grouped = new Map(); + const general: AIChatEntry[] = []; + + for (const msg of messages) { + if (!msg.question.filePath) { + general.push(msg); + } else { + const existing = grouped.get(msg.question.filePath) || []; + existing.push(msg); + grouped.set(msg.question.filePath, existing); + } + } + + const fileGroups: FileGroup[] = []; + for (const [filePath, msgs] of grouped) { + msgs.sort((a, b) => { + const aScope = getQuestionScope(a.question); + const bScope = getQuestionScope(b.question); + if (aScope !== bScope) return aScope === 'file' ? -1 : 1; + return (a.question.lineStart ?? 0) - (b.question.lineStart ?? 0); + }); + fileGroups.push({ filePath, messages: msgs }); + } + + return { fileGroups, generalMessages: general }; + }, [messages]); + + // Auto-expand active file's group + useEffect(() => { + if (activeFilePath) { + setExpandedFiles(prev => { + if (prev.has(activeFilePath)) return prev; + const next = new Set(prev); + next.add(activeFilePath); + return next; + }); + } + }, [activeFilePath]); + + // Scroll to specific question and flash-highlight its file group header + useEffect(() => { + if (!scrollToQuestionId || !scrollRef.current) return; + + const msg = messages.find(m => m.question.id === scrollToQuestionId); + const filePath = msg?.question.filePath; + + if (filePath) { + const header = scrollRef.current.querySelector(`[data-file-group="${CSS.escape(filePath)}"]`); + if (header) { + header.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + setHighlightFilePath(filePath); + setTimeout(() => setHighlightFilePath(null), 1200); + } + + if (filePath && expandedFiles.has(filePath)) { + setTimeout(() => { + const el = scrollRef.current?.querySelector(`[data-question-id="${scrollToQuestionId}"]`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 300); + } + }, [scrollToQuestionId]); + + // Auto-scroll when new messages arrive (not on every streaming token) + const prevMsgCount = useRef(messages.length); + useEffect(() => { + if (!scrollRef.current) return; + const isNewMessage = messages.length > prevMsgCount.current; + prevMsgCount.current = messages.length; + + if (isNewMessage) { + const allQAs = scrollRef.current.querySelectorAll('[data-question-id]'); + const lastQA = allQAs[allQAs.length - 1]; + if (lastQA) { + lastQA.scrollIntoView({ behavior: 'smooth', block: 'end' }); + } + } + }, [messages.length]); + + const toggleFile = (filePath: string) => { + setExpandedFiles(prev => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }; + + const handleGeneralSubmit = () => { + if (!generalInput.trim() || !onAskGeneral) return; + onAskGeneral(generalInput.trim()); + setGeneralInput(''); + }; + + // Empty state + if (messages.length === 0 && !isCreatingSession) { + return ( +
+
+
+ +
+

+ Select lines and click Ask AI, or ask a general question below. +

+
+ onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + {onAskGeneral && } +
+ ); + } + + return ( +
+ +
+ {isCreatingSession && messages.length === 0 && ( +
+ Starting AI session... +
+ )} + + {/* File-grouped questions */} + {fileGroups.map(({ filePath, messages: fileMessages }) => { + const isExpanded = expandedFiles.has(filePath); + const basename = filePath.split('/').pop() || filePath; + + return ( +
+ + + {isExpanded && ( +
+ {fileMessages.map(({ question, response }) => ( + + ))} +
+ )} +
+ ); + })} + + {/* Pending permission requests */} + {permissionRequests.filter(p => !p.decided).map(perm => ( +
+ {})} + /> +
+ ))} + + {/* General questions */} + {generalMessages.length > 0 && ( +
+ {fileGroups.length > 0 && ( +
+
+ General +
+
+ )} +
+ {generalMessages.map(({ question, response }) => ( + + ))} +
+
+ )} +
+ + + {/* Config bar */} + onAIConfigChange?.({ providerId })} + onModelChange={(model) => onAIConfigChange?.({ model })} + selectedReasoningEffort={aiConfig?.reasoningEffort ?? null} + onReasoningEffortChange={(effort) => onAIConfigChange?.({ reasoningEffort: effort })} + hasSession={hasAISession} + /> + + {/* General question input */} + {onAskGeneral && } +
+ ); +}; + +/** General question input pinned at bottom — textarea grows upward on multi-line */ +const GeneralInput: React.FC<{ + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + disabled?: boolean; +}> = ({ value, onChange, onSubmit, disabled }) => { + const textareaRef = useRef(null); + + const autoResize = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + // Cap at ~6 lines (6 * 16px line-height + padding) + el.style.height = `${Math.min(el.scrollHeight, 120)}px`; + }, []); + + useEffect(() => { autoResize(); }, [value, autoResize]); + + return ( +
+
+