diff --git a/.oxlintrc.json b/.oxlintrc.json index d45179a4c468..fba6a9e2e368 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,13 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": ["dist", "dist-electron", "node_modules", "bun.lock", "*.tsbuildinfo"], + "ignorePatterns": [ + "dist", + "dist-electron", + "node_modules", + "bun.lock", + "*.tsbuildinfo", + "**/routeTree.gen.ts" + ], "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], "categories": { "correctness": "warn", diff --git a/apps/web/package.json b/apps/web/package.json index f777c3222089..e3aa497bd76a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,8 @@ "@t3tools/contracts": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", + "@tanstack/react-router": "^1.160.2", + "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -31,6 +33,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.1.4", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index 3afec7e3a298..000000000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import { Activity, Suspense, lazy, useEffect, useRef } from "react"; - -import ChatView from "./components/ChatView"; -import Sidebar from "./components/Sidebar"; -import { isElectron } from "./env"; -import { DEFAULT_MODEL } from "./model-logic"; -import { StoreProvider, useStore } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; -import { onServerWelcome } from "./wsNativeApi"; -import { useNativeApi } from "./hooks/useNativeApi"; -import { useMediaQuery } from "./hooks/useMediaQuery"; -import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; -import { Sheet, SheetPopup } from "./components/ui/sheet"; -import { invalidateGitQueries } from "./lib/gitReactQuery"; - -const DiffPanel = lazy(() => import("./components/DiffPanel")); -const DiffWorkerPoolProvider = lazy(() => - import("./components/DiffPanel").then((module) => ({ - default: module.DiffWorkerPoolProvider, - })), -); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; - -const DiffPanelWrapper = (props: { children: React.ReactNode; sheet: boolean }) => { - const { state, dispatch } = useStore(); - if (props.sheet) { - return ( - { - if (!open) { - dispatch({ type: "CLOSE_DIFF" }); - } - }} - > - - {props.children} - - - ); - } - - return ( - - ); -}; - -function EventRouter() { - const api = useNativeApi(); - const { dispatch } = useStore(); - const queryClient = useQueryClient(); - const activeAssistantItemRef = useRef(null); - - useEffect(() => { - if (!api) return; - return api.providers.onEvent((event) => { - if (event.method === "turn/completed") { - void invalidateGitQueries(queryClient); - } - dispatch({ - type: "APPLY_EVENT", - event, - activeAssistantItemRef, - }); - }); - }, [api, dispatch, queryClient]); - - useEffect(() => { - if (!api) return; - return api.terminal.onEvent((event) => { - dispatch({ - type: "APPLY_TERMINAL_EVENT", - event, - }); - }); - }, [api, dispatch]); - - return null; -} - -function AutoProjectBootstrap() { - const { state, dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - // Browser mode bootstraps from server welcome. - // Electron bootstraps from persisted projects via DesktopProjectBootstrap. - if (isElectron) return; - - return onServerWelcome((payload) => { - if (bootstrappedRef.current) return; - - // Don't create duplicate projects for the same cwd - const existing = state.projects.find((p) => p.cwd === payload.cwd); - if (existing) { - bootstrappedRef.current = true; - // Ensure a thread is active - const existingThread = state.threads.find((t) => t.projectId === existing.id); - if (existingThread && !state.activeThreadId) { - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: existingThread.id, - }); - } - return; - } - - bootstrappedRef.current = true; - - // Create project + thread from server cwd - const projectId = crypto.randomUUID(); - dispatch({ - type: "ADD_PROJECT", - project: { - id: projectId, - name: payload.projectName, - cwd: payload.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: [], - }, - }); - dispatch({ - type: "ADD_THREAD", - thread: { - id: crypto.randomUUID(), - codexThreadId: null, - projectId, - title: "New thread", - model: DEFAULT_MODEL, - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: null, - messages: [], - events: [], - turnDiffSummaries: [], - error: null, - createdAt: new Date().toISOString(), - branch: null, - worktreePath: null, - }, - }); - }); - }, [state.projects, state.threads, state.activeThreadId, dispatch]); - - return null; -} - -function DesktopProjectBootstrap() { - const api = useNativeApi(); - const { dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - if (!isElectron || !api || bootstrappedRef.current) return; - - let disposed = false; - let retryDelayMs = 500; - let retryTimer: ReturnType | null = null; - - const attemptBootstrap = async () => { - try { - const projects = await api.projects.list(); - if (disposed) return; - dispatch({ - type: "SYNC_PROJECTS", - projects: projects.map((project) => ({ - id: project.id, - name: project.name, - cwd: project.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: project.scripts, - })), - }); - bootstrappedRef.current = true; - } catch { - if (disposed) return; - retryTimer = setTimeout(() => { - retryTimer = null; - void attemptBootstrap(); - }, retryDelayMs); - retryDelayMs = Math.min(retryDelayMs * 2, 5_000); - } - }; - - void attemptBootstrap(); - - return () => { - disposed = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [api, dispatch]); - - return null; -} - -function Layout() { - const api = useNativeApi(); - const { state } = useStore(); - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - - const diffLoadingFallback = - !state.diffOpen || shouldUseDiffSheet ? ( -
- Loading diff viewer... -
- ) : ( - - ); - - if (!api) { - return ( -
-
-

Connecting to T3 Code server...

-
-
- ); - } - - return ( -
- - - - - - - - - - - - - - -
- ); -} - -const queryClient = new QueryClient(); - -export default function App() { - return ( - - - - - - - - - - ); -} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index f13490a3f702..21113658fcd3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -23,6 +23,7 @@ import { import { ChevronDownIcon } from "lucide-react"; interface BranchToolbarProps { + threadId: string; envMode: "local" | "worktree"; onEnvModeChange: (mode: "local" | "worktree") => void; envLocked: boolean; @@ -30,6 +31,7 @@ interface BranchToolbarProps { } export default function BranchToolbar({ + threadId, envMode, onEnvModeChange, envLocked, @@ -42,7 +44,7 @@ export default function BranchToolbar({ const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); + const activeThread = state.threads.find((thread) => thread.id === threadId); const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); const activeThreadId = activeThread?.id; const activeThreadBranch = activeThread?.branch ?? null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 232c07d97658..2bccb9ad3454 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -16,7 +16,6 @@ import { type ClipboardEvent, type DragEvent, type FormEvent, - Fragment, type KeyboardEvent, memo, type RefObject, @@ -29,6 +28,7 @@ import { } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; +import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; @@ -63,6 +63,7 @@ import { } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; +import { truncateTitle } from "../truncateTitle"; import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, @@ -306,7 +307,9 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { { - props.onHighlightedItemChange(typeof highlightedValue === "string" ? highlightedValue : null); + props.onHighlightedItemChange( + typeof highlightedValue === "string" ? highlightedValue : null, + ); }} >
@@ -337,7 +340,11 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }); -export default function ChatView() { +interface ChatViewProps { + threadId: string; +} + +export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); const api = useNativeApi(); const { resolvedTheme } = useTheme(); @@ -366,7 +373,6 @@ export default function ChatView() { Record >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef(null); - const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef(null); const composerCommandInputRef = useRef(null); @@ -375,7 +381,7 @@ export default function ChatView() { const terminalOpenByThreadRef = useRef>({}); const checkpointHydrationSessionRequestRef = useRef(new Set()); - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + const activeThread = state.threads.find((t) => t.id === threadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = @@ -684,7 +690,7 @@ export default function ChatView() { ); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; const threadTerminalRuntimeEnv = useMemo(() => { - if (!activeProject) return {}; + if (!activeProject?.cwd) return {}; return projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, @@ -820,9 +826,7 @@ export default function ChatView() { } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = - activeThread.activeTerminalId || - activeThread.terminalIds[0] || - DEFAULT_THREAD_TERMINAL_ID; + activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = @@ -1125,6 +1129,9 @@ export default function ChatView() { revokePreviewUrls(existing); return []; }); + setPrompt(""); + promptRef.current = ""; + setIsSending(false); setComposerCursor(0); setComposerHighlightedItemId(null); dragDepthRef.current = 0; @@ -1646,7 +1653,7 @@ export default function ChatView() { (composerImagesSnapshot.length > 0 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` : "New thread"); - const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed; + const title = truncateTitle(titleSeed); dispatch({ type: "SET_THREAD_TITLE", threadId: activeThread.id, @@ -1940,6 +1947,7 @@ export default function ChatView() { className={`flex items-center justify-between border-b border-border px-5 ${isElectron ? "drag-region h-[52px]" : "py-3"}`} > 0} isWorking={isWorking} + scrollContainerRef={messagesScrollRef} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} @@ -1989,7 +1998,6 @@ export default function ChatView() { onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} - messagesEndRef={messagesEndRef} />
@@ -2206,6 +2214,7 @@ export default function ChatView() { {isGitRepo && ( )} - {activeProjectName && } - {activeProjectName && } + {activeProjectName && ( + + )} + {activeProjectName && ( + + )} ; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; @@ -2447,12 +2463,29 @@ interface MessagesTimelineProps { onRevertUserMessage: (messageId: string) => void; isRevertingCheckpoint: boolean; onImageExpand: (image: ExpandedImagePreview) => void; - messagesEndRef: RefObject; } +type TimelineEntry = ReturnType[number]; +type TimelineMessage = Extract["message"]; +type TimelineWorkEntry = Extract["entry"]; +type TimelineRow = + | { + kind: "work"; + id: string; + groupedEntries: TimelineWorkEntry[]; + } + | { + kind: "message"; + id: string; + message: TimelineMessage; + showCompletionDivider: boolean; + } + | { kind: "working"; id: string }; + const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, + scrollContainerRef, timelineEntries, completionDividerBeforeEntryId, completionSummary, @@ -2466,8 +2499,67 @@ const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, isRevertingCheckpoint, onImageExpand, - messagesEndRef, }: MessagesTimelineProps) { + const rows = useMemo(() => { + const nextRows: TimelineRow[] = []; + + for (let index = 0; index < timelineEntries.length; index += 1) { + const timelineEntry = timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + groupedEntries, + }); + index = cursor - 1; + continue; + } + + nextRows.push({ + kind: "message", + id: timelineEntry.id, + message: timelineEntry.message, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (isWorking) { + nextRows.push({ kind: "working", id: "working-indicator-row" }); + } + + return nextRows; + }, [timelineEntries, completionDividerBeforeEntryId, isWorking]); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: (index: number) => { + const row = rows[index]; + if (!row) return 96; + if (row.kind === "work") return 112; + if (row.kind === "working") return 40; + return row.message.role === "assistant" ? 220 : 170; + }, + measureElement: (element: HTMLElement) => element.getBoundingClientRect().height, + overscan: 8, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + if (!hasMessages && !isWorking) { return (
@@ -2479,296 +2571,316 @@ const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
- {timelineEntries.map((timelineEntry, index) => { - if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { - return null; - } - - const showCompletionDivider = - timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id; - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - - const groupId = timelineEntry.id; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; - - return ( - -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - <> - {workEntry.label} - - {workEntry.detail} - - - ) : ( - workEntry.label - )} -

-
- ))} -
-
-
- ); - } - - if (timelineEntry.message.role === "user") { - const userImages = timelineEntry.message.attachments ?? []; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(timelineEntry.message.id); - return ( - -
-
- {userImages.length > 0 && ( -
- {userImages.map((image) => ( -
- {image.previewUrl ? ( - {image.name} - onImageExpand({ src: image.previewUrl!, name: image.name }) - } - /> - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {timelineEntry.message.text && ( -
-                      {timelineEntry.message.text}
-                    
- )} -
- {canRevertAgentWork && ( - - )} -

- {formatTimestamp(timelineEntry.message.createdAt)} -

-
-
-
-
- ); - } +
+ {virtualRows.map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]; + if (!row) return null; return ( - - {showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get( - timelineEntry.message.id, - ); - if (!turnSummary) return null; - const isCheckpointDiffLoading = - !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; - const summaryStat = turnSummary.unifiedDiff - ? countDiffStat(turnSummary.unifiedDiff) - : turnSummary.files.reduce( - (acc, file) => { - const next = - typeof file.additions === "number" && typeof file.deletions === "number" - ? { additions: file.additions, deletions: file.deletions } - : file.diff - ? countDiffStat(file.diff) - : null; - if (!next) { - return acc; - } - return { - additions: acc.additions + next.additions, - deletions: acc.deletions + next.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); - const changedFileCountLabel = isCheckpointDiffLoading - ? "..." - : String(turnSummary.files.length); - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {!isCheckpointDiffLoading && - (summaryStat.additions > 0 || summaryStat.deletions > 0) && ( - <> - - +{summaryStat.additions} - / - -{summaryStat.deletions} - - )} -

- -
- {isCheckpointDiffLoading && ( -

- Loading checkpoint diff... -

- )} - {turnSummary.files.length > 0 ? ( -
- {turnSummary.files.map((file) => ( +
+
+ {row.kind === "work" && + (() => { + const groupId = row.id; + const groupedEntries = row.groupedEntries; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; + + return ( +
+
+

+ {groupLabel} +

+ {hasOverflow && ( + )} +
+
+ {visibleEntries.map((workEntry) => ( +
- {(() => { - const stat = - typeof file.additions === "number" && - typeof file.deletions === "number" - ? { additions: file.additions, deletions: file.deletions } - : file.diff - ? countDiffStat(file.diff) - : null; - if (!stat) { - return file.path; - } - return ( + +

+ {workEntry.detail ? ( <> - {file.path} - ( - +{stat.additions} - / - -{stat.deletions} - ) + {workEntry.label} + + {workEntry.detail} + - ); - })()} - + ) : ( + workEntry.label + )} +

+
))}
- ) : !isCheckpointDiffLoading ? ( -

No changed files.

- ) : null} +
+ ); + })()} + + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + (image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + {image.name} + onImageExpand({ src: image.previewUrl!, name: image.name }) + } + /> + ) : ( +
+ {image.name} +
+ )} +
+ ), + )} +
+ )} + {row.message.text && ( +
+                            {row.message.text}
+                          
+ )} +
+ {canRevertAgentWork && ( + + )} +

+ {formatTimestamp(row.message.createdAt)} +

+
+
+
+ ); + })()} + + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = + row.message.text || (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ + {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get( + row.message.id, + ); + if (!turnSummary) return null; + const isCheckpointDiffLoading = + !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; + const summaryStat = turnSummary.unifiedDiff + ? countDiffStat(turnSummary.unifiedDiff) + : turnSummary.files.reduce( + (acc, file) => { + const next = + typeof file.additions === "number" && + typeof file.deletions === "number" + ? { additions: file.additions, deletions: file.deletions } + : file.diff + ? countDiffStat(file.diff) + : null; + if (!next) { + return acc; + } + return { + additions: acc.additions + next.additions, + deletions: acc.deletions + next.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); + const changedFileCountLabel = isCheckpointDiffLoading + ? "..." + : String(turnSummary.files.length); + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {!isCheckpointDiffLoading && + (summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + <> + + + +{summaryStat.additions} + + / + + -{summaryStat.deletions} + + + )} +

+ +
+ {isCheckpointDiffLoading && ( +

+ Loading checkpoint diff... +

+ )} + {turnSummary.files.length > 0 ? ( +
+ {turnSummary.files.map((file) => ( + + ))} +
+ ) : !isCheckpointDiffLoading ? ( +

+ No changed files. +

+ ) : null} +
+ ); + })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.message.createdAt, nowIso) + : formatElapsed( + row.message.createdAt, + assistantCompletionByItemId.get(row.message.id), + ), + )} +

+
+ + ); + })()} + + {row.kind === "working" && ( +
+ +
+ + + + +
- ); - })()} -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed(timelineEntry.message.createdAt, nowIso) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get(timelineEntry.message.id), - ), - )} -

+
+ )}
- +
); })} - {isWorking && ( -
- -
- - - - - -
-
- )} -
); }); @@ -2823,8 +2935,10 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { const OpenInPicker = memo(function OpenInPicker({ keybindings, + activeThreadId, }: { keybindings: ResolvedKeybindingsConfig; + activeThreadId: string | null; }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); @@ -2851,7 +2965,7 @@ const OpenInPicker = memo(function OpenInPicker({ const api = useNativeApi(); const { state } = useStore(); - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + const activeThread = state.threads.find((t) => t.id === activeThreadId); const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const openInEditor = useCallback( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 6cf7b61ced6d..bb2664564f2f 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,6 +6,7 @@ import { WorkerPoolContextProvider, } from "@pierre/diffs/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; import { Columns2Icon, Rows3Icon } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery"; @@ -105,7 +106,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const queryClient = useQueryClient(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const patchViewportRef = useRef(null); - const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); + const params = useParams({ strict: false }); + const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; + const activeThreadId = state.diffThreadId ?? routeThreadId; + const activeThread = state.threads.find((thread) => thread.id === activeThreadId); const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 50de7b6c5751..a740d6472245 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { return [...commitStages, pushStage, "Creating PR..."]; } +const withDescription = (title: string, description: string | undefined) => + description ? { title, description } : { title }; + export function summarizeGitResult(result: GitRunStackedActionResult): { title: string; description?: string; } { - const withDescription = (title: string, description: string | undefined) => - description ? { title, description } : { title }; - if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7890665a9d3b..11b5fc45236b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -38,12 +38,12 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { useStore } from "~/store"; import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; interface GitActionsControlProps { api: NativeApi | undefined; gitCwd: string | null; + activeThreadId: string | null; } function getMenuActionDisabledReason( @@ -136,9 +136,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { - const { state } = useStore(); - const activeThreadId = state.activeThreadId; +export default function GitActionsControl({ + api, + gitCwd, + activeThreadId, +}: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a8ebff1b41bf..9f9b5f15bd57 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,23 +2,20 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "@tanstack/react-router"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; -import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - type Project, - type Thread, -} from "../types"; +import { type Project, type Thread } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; +import { createThread } from "../threadFactory"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -118,6 +115,9 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); + const navigate = useNavigate(); + const params = useParams({ strict: false }); + const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, @@ -147,38 +147,21 @@ export default function Sidebar() { worktreePath?: string | null; }, ) => { + const thread = createThread(projectId, { + model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + }); dispatch({ type: "ADD_THREAD", - thread: { - id: crypto.randomUUID(), - codexThreadId: null, - projectId, - title: "New thread", - model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: null, - messages: [], - events: [], - turnDiffSummaries: [], - error: null, - createdAt: new Date().toISOString(), - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, - }, + thread, + }); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, }); }, - [dispatch, state.projects], + [dispatch, navigate, state.projects], ); const focusMostRecentThreadForProject = useCallback( @@ -192,12 +175,12 @@ export default function Sidebar() { })[0]; if (!latestThread) return; - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: latestThread.id, + void navigate({ + to: "/$threadId", + params: { threadId: latestThread.id }, }); }, - [dispatch, state.threads], + [navigate, state.threads], ); const addProjectFromPath = useCallback( @@ -325,7 +308,20 @@ export default function Sidebar() { // Terminal may already be closed } + const shouldNavigateToFallback = routeThreadId === threadId; + const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; dispatch({ type: "DELETE_THREAD", threadId }); + if (shouldNavigateToFallback) { + if (fallbackThreadId) { + void navigate({ + to: "/$threadId", + params: { threadId: fallbackThreadId }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } + } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { return; @@ -351,7 +347,7 @@ export default function Sidebar() { }); } }, - [api, dispatch, removeWorktreeMutation, state.projects, state.threads], + [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads], ); const handleProjectContextMenu = useCallback( @@ -401,7 +397,9 @@ export default function Sidebar() { useEffect(() => { const onWindowKeyDown = (event: KeyboardEvent) => { - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + const activeThread = routeThreadId + ? state.threads.find((thread) => thread.id === routeThreadId) + : undefined; if (isChatNewLocalShortcut(event, keybindings)) { const projectId = activeThread?.projectId ?? state.projects[0]?.id; if (!projectId) return; @@ -424,7 +422,7 @@ export default function Sidebar() { return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]); + }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); return (