diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 91efbde4..8ad7a789 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -38,6 +38,7 @@ import { ModelControls } from './ModelControls'; import { UnifiedControlsDrawer } from './UnifiedControlsDrawer'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; +import { PendingChangesBar } from './PendingChangesBar'; import { MobileAgentButton } from './MobileAgentButton'; import { MobileModelButton } from './MobileModelButton'; import { MobileSessionStatusBar } from './MobileSessionStatusBar'; @@ -3397,6 +3398,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo showAbortStatus={showAbortStatus} showAssistantStatus={false} showTodos + leftAccessory={newSessionDraftOpen ? null : } /> {showDraftTargetSelectors && selectedDraftProject ? (
diff --git a/packages/ui/src/components/chat/PendingChangesBar.tsx b/packages/ui/src/components/chat/PendingChangesBar.tsx new file mode 100644 index 00000000..085733cf --- /dev/null +++ b/packages/ui/src/components/chat/PendingChangesBar.tsx @@ -0,0 +1,409 @@ +import React from 'react'; +import { RiFileEditLine, RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'; +import type { ToolPart } from '@opencode-ai/sdk/v2'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionMessageRecords } from '@/sync/sync-context'; +import { useStreamingStore, selectIsStreaming } from '@/sync/streaming'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useGitStore, useIsGitRepo } from '@/stores/useGitStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +// ---- Types ---- + +/** File changed by an AI tool (non-Git mode) */ +interface ChangedFile { + path: string; + tool: string; + partId: string; + messageID: string; + additions?: number; + deletions?: number; + patch?: string; +} + +/** File changed in workspace (Git mode) */ +interface GitChangedFile { + path: string; + relativePath: string; + insertions: number; + deletions: number; + status: string; +} + +type ChangedFileEntry = ChangedFile | GitChangedFile; + +// ---- Helpers ---- + +const FILE_EDIT_TOOLS = new Set(['edit', 'multiedit', 'write', 'apply_patch', 'create', 'file_write']); + +const parseCount = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.trunc(value)); + return undefined; +}; + +const parsePatchStats = (patch: string): { added: number; removed: number } => { + let added = 0; + let removed = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) added++; + if (line.startsWith('-') && !line.startsWith('---')) removed++; + } + return { added, removed }; +}; + +/** Extract changed files from tool parts of a single assistant message */ +const extractChangedFiles = (parts: ToolPart[]): ChangedFile[] => { + const files: ChangedFile[] = []; + const seen = new Set(); + + for (const part of parts) { + if (part.type !== 'tool') continue; + if (!FILE_EDIT_TOOLS.has(part.tool)) continue; + + const state = part.state as { metadata?: Record; input?: Record; status?: string }; + if (state.status && state.status !== 'completed') continue; + + const sizeBeforeThisPart = files.length; + + const metadata = state.metadata; + + // Extract from metadata.files[] (apply_patch) + const metaFiles = Array.isArray(metadata?.files) ? metadata.files : []; + for (const file of metaFiles) { + if (!file || typeof file !== 'object') continue; + const record = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown; patch?: unknown }; + const rawPath = record.relativePath || record.filePath || ''; + if (!rawPath || seen.has(rawPath)) continue; + seen.add(rawPath); + files.push({ + path: rawPath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parseCount(record.additions) ?? undefined, + deletions: parseCount(record.deletions) ?? undefined, + patch: typeof record.patch === 'string' ? record.patch : undefined, + }); + } + + // Fallback 1: extract from metadata.filediff (edit tool) + if (metaFiles.length === 0 && metadata?.filediff && typeof metadata.filediff === 'object') { + const fd = metadata.filediff as { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown }; + const rawPath = typeof fd.file === 'string' ? fd.file : ''; + if (rawPath && !seen.has(rawPath)) { + seen.add(rawPath); + files.push({ + path: rawPath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parseCount(fd.additions) ?? undefined, + deletions: parseCount(fd.deletions) ?? undefined, + patch: typeof fd.patch === 'string' ? fd.patch : undefined, + }); + } + } + + // Fallback 2: extract from metadata.results[].filediff (multiedit tool) + if (metaFiles.length === 0 && Array.isArray(metadata?.results)) { + for (const result of metadata.results) { + if (!result || typeof result !== 'object') continue; + const fd = (result as { filediff?: { file?: string; additions?: unknown; deletions?: unknown; patch?: unknown } }).filediff; + if (!fd || typeof fd !== 'object') continue; + const rawPath = typeof fd.file === 'string' ? fd.file : ''; + if (!rawPath || seen.has(rawPath)) continue; + seen.add(rawPath); + files.push({ + path: rawPath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parseCount(fd.additions) ?? undefined, + deletions: parseCount(fd.deletions) ?? undefined, + patch: typeof fd.patch === 'string' ? fd.patch : undefined, + }); + } + } + + // Fallback 3: extract from input.filePath for write-like tools + if (files.length === sizeBeforeThisPart) { + const input = state.input; + const filePath = typeof input?.filePath === 'string' ? input.filePath + : typeof input?.file_path === 'string' ? input.file_path + : typeof input?.path === 'string' ? input.path + : undefined; + if (filePath && !seen.has(filePath)) { + seen.add(filePath); + files.push({ + path: filePath, + tool: part.tool, + partId: part.id, + messageID: part.messageID, + }); + } + } + + // Fallback 4: parse top-level patch/diff for stats + if (files.length === sizeBeforeThisPart) { + const patchText = typeof metadata?.patch === 'string' ? metadata.patch.trim() + : typeof metadata?.diff === 'string' ? metadata.diff.trim() : ''; + if (patchText && !seen.has('Diff')) { + seen.add('Diff'); + const parsed = parsePatchStats(patchText); + files.push({ + path: 'Diff', + tool: part.tool, + partId: part.id, + messageID: part.messageID, + additions: parsed.added, + deletions: parsed.removed, + }); + } + } + } + + return files; +}; + +/** Convert absolute path to relative path based on current directory */ +const toRelativePath = (absolutePath: string, baseDirectory: string): string => { + const norm = (p: string) => p.split('\\').join('/').replace(/\/+$/, ''); + const base = norm(baseDirectory); + const absPath = norm(absolutePath); + if (absPath.startsWith(base + '/')) { + return absPath.slice(base.length + 1); + } + if (absPath.startsWith(base)) { + return absPath.slice(base.length) || absPath; + } + return absPath; +}; + +/** Extract changed files from GitStatus */ +const extractGitChangedFiles = ( + files: Array<{ path: string; index: string; working_dir: string }>, + diffStats: Record | undefined, + directory: string, +): GitChangedFile[] => { + const result: GitChangedFile[] = []; + for (const file of files) { + const code = file.working_dir !== ' ' ? file.working_dir : file.index; + if (code === '!' || code === ' ') continue; + const stats = diffStats?.[file.path]; + result.push({ + path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path, + relativePath: file.path, + insertions: stats?.insertions ?? 0, + deletions: stats?.deletions ?? 0, + status: code, + }); + } + return result; +}; + +/** Type guard for GitChangedFile */ +const isGitFile = (file: ChangedFileEntry): file is GitChangedFile => { + return 'insertions' in file; +}; + +// ---- Component ---- + +export const PendingChangesBar: React.FC = React.memo(() => { + const [isExpanded, setIsExpanded] = React.useState(false); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const isGitRepo = useIsGitRepo(currentDirectory); + const gitStatus = useGitStore((s) => + currentDirectory ? s.directories.get(currentDirectory)?.status ?? null : null, + ); + const isStreaming = useStreamingStore(selectIsStreaming(currentSessionId ?? '')); + const popoverRef = React.useRef(null); + + // ---- Mode selection ---- + const mode: 'git' | 'non-git' = isGitRepo === true ? 'git' : 'non-git'; + + // ---- Git mode data ---- + const gitChangedFiles = React.useMemo(() => { + if (isGitRepo !== true || mode !== 'git' || !gitStatus || gitStatus.isClean) return []; + return extractGitChangedFiles(gitStatus.files, gitStatus.diffStats, currentDirectory); + }, [isGitRepo, mode, gitStatus, currentDirectory]); + + // ---- Non-Git mode data (latest assistant turn only) ---- + const nonGitChangedFiles = React.useMemo(() => { + if (isGitRepo !== false || mode !== 'non-git' || !currentSessionId || isStreaming) return []; + + for (let i = sessionMessageRecords.length - 1; i >= 0; i--) { + const record = sessionMessageRecords[i]; + if (record.info.role !== 'assistant') continue; + + const toolParts = record.parts.filter( + (p): p is ToolPart => p.type === 'tool' && FILE_EDIT_TOOLS.has(p.tool), + ); + if (toolParts.length === 0) continue; + + return extractChangedFiles(toolParts); + } + return []; + }, [isGitRepo, mode, sessionMessageRecords, currentSessionId, isStreaming]); + + // ---- Merged view ---- + const changedFiles: ChangedFileEntry[] = mode === 'git' ? gitChangedFiles : nonGitChangedFiles; + + // ---- Aggregate stats ---- + const { totalAdded, totalRemoved } = React.useMemo(() => { + let added = 0; + let removed = 0; + for (const file of changedFiles) { + if (isGitFile(file)) { + added += file.insertions; + removed += file.deletions; + } else { + if (file.additions != null) added += file.additions; + if (file.deletions != null) removed += file.deletions; + } + } + return { totalAdded: added, totalRemoved: removed }; + }, [changedFiles]); + + React.useEffect(() => { + if (!isExpanded) return; + + const handleClickOutside = (event: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + setIsExpanded(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isExpanded]); + + // Don't render while git status is still loading + if (isGitRepo === null) return null; + + // ---- Visibility ---- + if (changedFiles.length === 0) return null; + + // ---- Handlers ---- + const handleOpenFile = (file: ChangedFileEntry) => { + if (!currentDirectory) return; + + const targetPath = isGitFile(file) + ? file.relativePath + : toRelativePath(file.path, currentDirectory); + + const store = useUIStore.getState(); + if (!store.isMobile) { + store.openContextDiff(currentDirectory, targetPath); + return; + } + store.navigateToDiff(targetPath); + store.setRightSidebarOpen(false); + }; + + // ---- Label ---- + const fileCount = changedFiles.length; + const labelHead = `${fileCount} file${fileCount !== 1 ? 's' : ''}`; + const labelTail = mode === 'git' ? 'changed in workspace' : 'changed in the last reply'; + + // ---- Display helpers ---- + const getDisplayPath = (file: ChangedFileEntry): { fileName: string; dirPart: string } => { + const relativePath = isGitFile(file) && file.relativePath + ? file.relativePath + : toRelativePath(file.path, currentDirectory); + const fileName = relativePath.split('/').pop() ?? relativePath; + const dirPart = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/')) : ''; + return { fileName, dirPart }; + }; + + const getFileStats = (file: ChangedFileEntry): { additions: number; deletions: number } => { + if (isGitFile(file)) return { additions: file.insertions, deletions: file.deletions }; + return { additions: file.additions ?? 0, deletions: file.deletions ?? 0 }; + }; + + // ---- Render ---- + return ( +
+ + + {isExpanded ? ( +
+
+ Changed files + {fileCount} +
+ +
+ {changedFiles.map((file, index) => { + const { fileName, dirPart } = getDisplayPath(file); + const stats = getFileStats(file); + + return ( + + ); + })} +
+
+ ) : null} +
+ ); +}); + +PendingChangesBar.displayName = 'PendingChangesBar'; diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 668c49cd..36666fd3 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -18,6 +18,7 @@ type TodoItem = Todo & { id?: string }; type TodoStatus = string; type TodoPriority = string; import { useUIStore } from "@/stores/useUIStore"; +import { useTodosPersistStore } from "@/stores/useTodosPersistStore"; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; import { isVSCodeRuntime } from "@/lib/desktop"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -134,6 +135,7 @@ interface StatusRowProps { showAssistantStatus?: boolean; showTodos?: boolean; agentName?: string; + leftAccessory?: React.ReactNode; } export const StatusRow: React.FC = ({ @@ -150,14 +152,23 @@ export const StatusRow: React.FC = ({ showAssistantStatus = true, showTodos = true, agentName, + leftAccessory, }) => { const [isExpanded, setIsExpanded] = React.useState(false); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const todosRecord = useDirectorySync((state) => state.todo); - const todos: TodoItem[] = React.useMemo( - () => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS), - [todosRecord, currentSessionId], + const persistedSessionTodos = useTodosPersistStore( + React.useCallback( + (state) => (currentSessionId ? state.sessions[currentSessionId]?.todos : undefined), + [currentSessionId], + ), ); + const todos: TodoItem[] = React.useMemo(() => { + if (!currentSessionId) return EMPTY_TODOS; + const live = todosRecord[currentSessionId]; + if (live && live.length > 0) return live; + return persistedSessionTodos ?? EMPTY_TODOS; + }, [todosRecord, persistedSessionTodos, currentSessionId]); const isMobile = useUIStore((state) => state.isMobile); const isCompact = isMobile || isVSCodeRuntime(); @@ -189,17 +200,17 @@ export const StatusRow: React.FC = ({ return { active, left }; }, [visibleTodos]); - const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending"); - const hasTodoContent = showTodos && hasActiveTodos; + const hasTodoContent = showTodos && visibleTodos.length > 0; const hasAssistantContent = showAssistantStatus && ( isWorking || Boolean(wasAborted) || Boolean(showAbortStatus) ); + const hasLeftAccessory = Boolean(leftAccessory); // Original logic from ChatInput const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive); - const hasContent = hasAssistantContent || hasTodoContent; + const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory; // Close popover when clicking outside const popoverRef = React.useRef(null); @@ -239,7 +250,7 @@ export const StatusRow: React.FC = ({ > {/* Desktop: show task text; Mobile/VSCode: just "Tasks" */} {!isCompact && activeTodo ? ( - + {activeTodo.content} ) : ( @@ -262,10 +273,10 @@ export const StatusRow: React.FC = ({ } return ( -
-
- {/* Left: Abort status or Working placeholder */} -
+
+
+ {/* Left: Abort status or Working placeholder or leftAccessory */} +
{showAssistantStatus && showAbortStatus ? (
@@ -283,36 +294,43 @@ export const StatusRow: React.FC = ({ retryInfo={retryInfo} agentName={agentName} /> + ) : leftAccessory ? ( + leftAccessory ) : null}
{/* Right: Abort (mobile only) + Todo */} -
+
{abortButton} {todoTrigger} {/* Popover dropdown */} - {isExpanded && hasActiveTodos && ( + {isExpanded && hasTodoContent && (
{/* Header */} -
- Tasks - +
+ Tasks + {progress.completed}/{progress.total}
{/* Todo list */} -
+
{visibleTodos.map((todo, index) => ( ))} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 470d7249..835c21a5 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -763,6 +763,19 @@ html:not(.dark) .chat-scroll { } } +/* Status row: collapse optional text when narrow to keep both sides in one line. */ +@container status-row (max-width: 30rem) { + .status-row__active-todo { + display: none; + } +} + +@container status-row (max-width: 24rem) { + .status-row__changed-label { + display: none; + } +} + /* Animated tabs: collapse labels based on local container width. */ @container animated-tabs (max-width: 23rem) { .animated-tabs__label { diff --git a/packages/ui/src/stores/useTodosPersistStore.ts b/packages/ui/src/stores/useTodosPersistStore.ts new file mode 100644 index 00000000..b9d394fb --- /dev/null +++ b/packages/ui/src/stores/useTodosPersistStore.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; +import { createJSONStorage, devtools, persist } from 'zustand/middleware'; +import type { Todo } from '@opencode-ai/sdk/v2/client'; +import { getSafeStorage } from './utils/safeStorage'; + +const MAX_SESSIONS = 50; + +interface SessionTodosRecord { + todos: Todo[]; + touchedAt: number; +} + +interface TodosPersistState { + sessions: Record; + setSessionTodos: (sessionId: string, todos: Todo[] | undefined) => void; + getSessionTodos: (sessionId: string) => Todo[] | undefined; +} + +const evictOldest = (sessions: Record): Record => { + const ids = Object.keys(sessions); + if (ids.length <= MAX_SESSIONS) return sessions; + + const sorted = ids + .map((id) => [id, sessions[id].touchedAt] as const) + .sort((a, b) => a[1] - b[1]); + const drop = sorted.slice(0, ids.length - MAX_SESSIONS).map(([id]) => id); + const next = { ...sessions }; + for (const id of drop) delete next[id]; + return next; +}; + +export const useTodosPersistStore = create()( + devtools( + persist( + (set, get) => ({ + sessions: {}, + setSessionTodos: (sessionId, todos) => { + if (!sessionId) return; + set((state) => { + const next = { ...state.sessions }; + if (!todos || todos.length === 0) { + if (!(sessionId in next)) return state; + delete next[sessionId]; + return { sessions: next }; + } + next[sessionId] = { todos, touchedAt: Date.now() }; + return { sessions: evictOldest(next) }; + }); + }, + getSessionTodos: (sessionId) => { + if (!sessionId) return undefined; + return get().sessions[sessionId]?.todos; + }, + }), + { + name: 'openchamber-session-todos', + version: 1, + storage: createJSONStorage(() => getSafeStorage()), + partialize: (state) => ({ sessions: state.sessions }), + }, + ), + { name: 'TodosPersistStore' }, + ), +); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index dff98ec4..86e8ccd9 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -187,6 +187,10 @@ export type SessionUIState = { markSessionPlanAvailable: (sessionId: string) => void isSessionPlanAvailable: (sessionId: string) => boolean + // Non-Git mode: dismissed signature hash per session, hides bar until new turn arrives + pendingChangesBarDismissed: Map + dismissPendingChangesBar: (sessionId: string, signature: string | null) => void + // Actions — UI state management setCurrentSession: (id: string | null, directoryHint?: string | null) => void openNewSessionDraft: (options?: Partial) => void @@ -343,6 +347,7 @@ export const useSessionUIStore = create()((set, get) => ({ isLoading: false, lastLoadedDirectory: null, sessionPlanAvailable: new Map(), + pendingChangesBarDismissed: new Map(), // --------------------------------------------------------------------------- // setCurrentSession @@ -650,6 +655,16 @@ export const useSessionUIStore = create()((set, get) => ({ getWorktreeMetadata: (sessionId) => get().worktreeMetadata.get(sessionId), + dismissPendingChangesBar: (sessionId, signature) => { + const map = new Map(get().pendingChangesBarDismissed); + if (signature === null) { + map.delete(sessionId); + } else { + map.set(sessionId, signature); + } + set({ pendingChangesBarDismissed: map }); + }, + // --------------------------------------------------------------------------- // sendMessage — calls SDK, reads domain data from sync // --------------------------------------------------------------------------- @@ -664,6 +679,14 @@ export const useSessionUIStore = create()((set, get) => ({ variant?: string, inputMode?: "normal" | "shell", ) => { + // Clear non-Git changed-files bar on new user message for current session + const sid = get().currentSessionId; + if (sid) { + const map = new Map(get().pendingChangesBarDismissed); + map.delete(sid); + set({ pendingChangesBarDismissed: map }); + } + const draft = get().newSessionDraft const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index ac94031d..cbac4822 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -27,6 +27,7 @@ import { syncDebug } from "./debug" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" import { useConfigStore } from "@/stores/useConfigStore" +import { useTodosPersistStore } from "@/stores/useTodosPersistStore" import { toast } from "@/components/ui" import { appendNotification } from "./notification-store" import type { State } from "./types" @@ -1159,7 +1160,11 @@ function handleEvent( break } - if (applyDirectoryEvent(draft, payload)) { + if (applyDirectoryEvent(draft, payload, { + onSetSessionTodo: (sessionID, todos) => { + useTodosPersistStore.getState().setSessionTodos(sessionID, todos) + }, + })) { store.setState(draft) const sessionID = getSessionIdFromPayload(payload) ?? undefined const messageID = getMessageIdFromPayload(payload) ?? undefined