diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4b88d052a329ab9b7c48a4d2e85a35e277f1427 --- /dev/null +++ b/apps/server/src/open.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { launchDetached, resolveEditorLaunch } from "./open"; + +describe("resolveEditorLaunch", () => { + it("returns cursor command for cursor editor", () => { + expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "cursor" }, "darwin")).toEqual({ + command: "cursor", + args: ["/tmp/workspace"], + }); + }); + + it("maps file-manager editor to OS open commands", () => { + expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin")).toEqual( + { + command: "open", + args: ["/tmp/workspace"], + }, + ); + expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual( + { + command: "explorer", + args: ["C:\\workspace"], + }, + ); + expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "linux")).toEqual( + { + command: "xdg-open", + args: ["/tmp/workspace"], + }, + ); + }); +}); + +describe("launchDetached", () => { + it("resolves when command can be spawned", async () => { + await expect(launchDetached(process.execPath, ["-e", "process.exit(0)"])).resolves.toBe( + undefined, + ); + }); + + it("rejects when command does not exist", async () => { + await expect( + launchDetached(`t3code-no-such-command-${Date.now()}`, []), + ).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 91b63cf04478f227bd02c420cb2de1d339529507..1d8c875af0aa15f88497e9e4bff67bebeabfbba0 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -2,7 +2,6 @@ import { spawn } from "node:child_process"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export class OpenError extends Schema.TaggedErrorClass()("OpenError", { message: Schema.String, @@ -26,7 +25,7 @@ export interface OpenShape { export class Open extends ServiceMap.Service()("server/Open") {} -function resolveEditorLaunch( +export function resolveEditorLaunch( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, ): EditorLaunch { @@ -53,9 +52,25 @@ function resolveEditorLaunch( } } +export function launchDetached(command: string, args: ReadonlyArray): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + detached: true, + stdio: "ignore", + }); + + const handleSpawn = () => { + child.unref(); + resolve(); + }; + + child.once("spawn", handleSpawn); + child.once("error", (error) => reject(error)); + }); +} + const make = Effect.gen(function* () { const open = yield* Effect.promise(() => import("open")); - const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner; return { openBrowser: (target) => @@ -64,14 +79,16 @@ const make = Effect.gen(function* () { catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), }), openInEditor: (input) => - Effect.gen(function* () { - const launch = resolveEditorLaunch(input); - const child = yield* spawn(ChildProcess.make(launch.command, launch.args)); - yield* Effect.forkDetach(child.exitCode); - }).pipe( - Effect.catch((error) => Effect.logError("Failed to open editor", { cause: error })), - Effect.scoped, - ), + Effect.tryPromise({ + try: () => { + const launch = resolveEditorLaunch(input); + return launchDetached(launch.command, launch.args); + }, + catch: (cause) => + cause instanceof OpenError + ? cause + : new OpenError({ message: "Failed to open editor", cause }), + }), } satisfies OpenShape; }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e8e2b9d85c8ae6ecdeafba3ae5a089efa6c7f471..91972d787bbc5fd0c98948aacc0c241fb3358071 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,20 +1,43 @@ -import { memo } from "react"; +import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import rehypeHighlight from "rehype-highlight"; import remarkGfm from "remark-gfm"; +import { useNativeApi } from "../hooks/useNativeApi"; +import { resolveMarkdownFileLinkTarget } from "../markdown-links"; +import { preferredTerminalEditor } from "../terminal-links"; interface ChatMarkdownProps { text: string; + cwd: string | undefined; } -const markdownComponents: Components = { - a({ node: _node, ...props }) { - return ; - }, -}; +function ChatMarkdown({ text, cwd }: ChatMarkdownProps) { + const api = useNativeApi(); + const markdownComponents = useMemo( + () => ({ + a({ node: _node, href, ...props }) { + const targetPath = resolveMarkdownFileLinkTarget(href, cwd); + if (!targetPath || !api) { + return ; + } + + return ( + { + event.preventDefault(); + event.stopPropagation(); + void api.shell.openInEditor(targetPath, preferredTerminalEditor()); + }} + /> + ); + }, + }), + [api, cwd], + ); -function ChatMarkdown({ text }: ChatMarkdownProps) { return (
parseDiffRouteSearch(params), + }); const api = useNativeApi(); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); @@ -387,6 +390,26 @@ export default function ChatView({ threadId }: ChatViewProps) { const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); + + useEffect(() => { + if (!activeThread?.id) return; + if (!activeThread.latestTurnCompletedAt) return; + const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); + if (Number.isNaN(turnCompletedAt)) return; + const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; + if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; + + dispatch({ + type: "MARK_THREAD_VISITED", + threadId: activeThread.id, + }); + }, [ + activeThread?.id, + activeThread?.lastVisitedAt, + activeThread?.latestTurnCompletedAt, + dispatch, + ]); + const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); @@ -799,47 +822,33 @@ export default function ChatView({ threadId }: ChatViewProps) { keybinding?: string | null; keybindingCommand: string; }) => { - dispatch({ - type: "SET_PROJECT_SCRIPTS", - projectId: input.projectId, - scripts: input.nextScripts, - }); - if (!api) return; - try { - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), - projectId: asProjectId(input.projectId), - scripts: input.nextScripts, + + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId: asProjectId(input.projectId), + scripts: input.nextScripts, + }); + + if (isElectron && input.keybinding) { + const keybindingUpdate = await api.server.upsertKeybinding({ + key: input.keybinding, + command: input.keybindingCommand, }); - - if (isElectron && input.keybinding) { - const keybindingUpdate = await api.server.upsertKeybinding({ - key: input.keybinding, - command: input.keybindingCommand, - }); - queryClient.setQueryData( - serverQueryKeys.config(), - (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => - current - ? { ...current, keybindings: keybindingUpdate.keybindings } - : { - cwd: input.projectCwd, - keybindings: keybindingUpdate.keybindings, + queryClient.setQueryData( + serverQueryKeys.config(), + (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => + current + ? { ...current, keybindings: keybindingUpdate.keybindings } + : { + cwd: input.projectCwd, + keybindings: keybindingUpdate.keybindings, }, - ); - } - } catch (error) { - dispatch({ - type: "SET_PROJECT_SCRIPTS", - projectId: input.projectId, - scripts: input.previousScripts, - }); - throw error; + ); } }, - [api, dispatch, queryClient], + [api, queryClient], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { @@ -1338,15 +1347,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setIsRevertingCheckpoint(false); } }, - [ - activeThread, - api, - isConnecting, - isRevertingCheckpoint, - isSending, - phase, - setThreadError, - ], + [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], ); const onSend = async (e: FormEvent) => { @@ -1425,7 +1426,9 @@ export default function ChatView({ threadId }: ChatViewProps) { try { const turnAttachments = await Promise.all( composerImagesSnapshot.map( - async (image): Promise<{ + async ( + image, + ): Promise<{ type: "image"; name: string; mimeType: string; @@ -1749,12 +1752,17 @@ export default function ChatView({ threadId }: ChatViewProps) { onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} + markdownCwd={gitCwd ?? undefined} />
{/* Input bar */}
-
+
-
+
{/* Model picker */} @@ -1869,7 +1877,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Runtime mode toggle */} - + }>
); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 4b287947651f7bd857031b3588e44d0dbf89b7b3..af88bc8001a9be67dd0be6ea748e41b6d1263485 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -136,11 +136,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ - api, - gitCwd, - activeThreadId, -}: GitActionsControlProps) { +export default function GitActionsControl({ api, gitCwd, activeThreadId }: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -501,7 +497,9 @@ export default function GitActionsControl({ } > - {quickAction.label} + + {quickAction.label} + {quickActionDisabledReason} @@ -515,10 +513,12 @@ export default function GitActionsControl({ onClick={runQuickAction} > - {quickAction.label} + + {quickAction.label} + )} - + { if (open) void invalidateGitQueries(queryClient); diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 067ae5792fe29dbd2207481349ceb04a353d8d71..2217a9098897a886544ea8f5647182a05ec0221c 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -266,9 +266,11 @@ export default function ProjectScriptsControl({ title={`Run ${primaryScript.name}`} > - {primaryScript.name} + + {primaryScript.name} + - + } @@ -329,7 +331,9 @@ export default function ProjectScriptsControl({ ) : ( )} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4ebcf05412ba1ad4346256f8dab3e42a80940fc1..566b386914b62322f07fca8acc2b1e36a46e78a0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -33,7 +33,6 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, - SidebarRail, SidebarSeparator, SidebarTrigger, } from "./ui/sidebar"; @@ -710,8 +709,6 @@ export default function Sidebar() { )} - - ); } diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index fbe2308f6bfff1a1d1879c193096b26c95003be9..c3bdf6576c03f26fec67d2f11da319a0b48f3f4f 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -25,6 +25,7 @@ const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "calc(100vw - var(--spacing(3)))"; const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; +const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; type SidebarContextProps = { state: "expanded" | "collapsed"; @@ -36,7 +37,43 @@ type SidebarContextProps = { toggleSidebar: () => void; }; +type SidebarResizableOptions = { + maxWidth?: number; + minWidth?: number; + onResize?: (width: number) => void; + shouldAcceptWidth?: (context: { + currentWidth: number; + nextWidth: number; + rail: HTMLButtonElement; + side: "left" | "right"; + sidebarRoot: HTMLElement; + wrapper: HTMLElement; + }) => boolean; + storageKey?: string; +}; + +type SidebarResolvedResizableOptions = { + maxWidth: number; + minWidth: number; + onResize?: (width: number) => void; + shouldAcceptWidth?: (context: { + currentWidth: number; + nextWidth: number; + rail: HTMLButtonElement; + side: "left" | "right"; + sidebarRoot: HTMLElement; + wrapper: HTMLElement; + }) => boolean; + storageKey: string | null; +}; + +type SidebarInstanceContextProps = { + resizable: SidebarResolvedResizableOptions | null; + side: "left" | "right"; +}; + const SidebarContext = React.createContext(null); +const SidebarInstanceContext = React.createContext(null); function useSidebar() { const context = React.useContext(SidebarContext); @@ -149,6 +186,7 @@ function Sidebar({ side = "left", variant = "sidebar", collapsible = "offcanvas", + resizable = false, className, children, ...props @@ -156,98 +194,123 @@ function Sidebar({ side?: "left" | "right"; variant?: "sidebar" | "floating" | "inset"; collapsible?: "offcanvas" | "icon" | "none"; + resizable?: boolean | SidebarResizableOptions; }) { const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + const resolvedResizable = React.useMemo(() => { + if (isMobile || collapsible === "none" || !resizable) { + return null; + } + + const options = typeof resizable === "boolean" ? {} : resizable; + return { + maxWidth: options.maxWidth ?? Number.POSITIVE_INFINITY, + minWidth: options.minWidth ?? SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH, + onResize: options.onResize, + shouldAcceptWidth: options.shouldAcceptWidth, + storageKey: options.storageKey ?? null, + }; + }, [collapsible, isMobile, resizable]); + const instanceContextValue = React.useMemo( + () => ({ side, resizable: resolvedResizable }), + [resolvedResizable, side], + ); if (collapsible === "none") { return ( -
- {children} -
- ); - } - - if (isMobile) { - return ( - - +
- - Sidebar - Displays the mobile sidebar. - -
{children}
- - - ); - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- +
+
+

Appearance

+

+ Choose how T3 Code handles light and dark mode. +

+
-

- Active theme: {resolvedTheme} -

-
+
+ {THEME_OPTIONS.map((option) => { + const selected = theme === option.value; + return ( + + ); + })} +
-
-
-

Codex App Server

-

- These overrides apply to new sessions and let you use a non-default Codex install. +

+ Active theme: {resolvedTheme}

-
+
-
- - - - -
-

- Binary source: {codexBinaryPath || "PATH"} +

+
+

Codex App Server

+

+ These overrides apply to new sessions and let you use a non-default Codex install.

-
-
- -
-
-

Runtime Mode

-

- Select the default execution policy for this client. -

-
+
+ + + -
- {RUNTIME_MODE_OPTIONS.map((option) => { - const selected = state.runtimeMode === option.value; - return ( - - ); - })} -
-
+ Reset codex overrides + +
+
+ -
-
-

Safety

-

- Additional guardrails for destructive local actions. -

-
+
+
+

Runtime Mode

+

+ Select the default execution policy for this client. +

+
-
-
-

Confirm thread deletion

-

- Ask for confirmation before deleting a thread and its chat history. +

+ {RUNTIME_MODE_OPTIONS.map((option) => { + const selected = state.runtimeMode === option.value; + return ( + + ); + })} +
+
+ +
+
+

Safety

+

+ Additional guardrails for destructive local actions.

- - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> -
- {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( -
- + aria-label="Confirm thread deletion" + />
- ) : null} - + + {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( +
+ +
+ ) : null} + +
-
+ ); } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 38cf5bcccd4f55fc241b54d9edb258a211b9686d..0c535c68fa0df17c0e6335ee15742848ae4b5e2d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -33,11 +33,9 @@ function ChatRouteLayout() { > - - - - - + + + ); } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c19f2618fe3f932cbe020878e43b32698a4ea057..bf2691fbde1817f105cdfce9219539f429a83cf9 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -8,10 +8,7 @@ import { useReducer, } from "react"; -import { - type OrchestrationReadModel, - type OrchestrationSessionStatus, -} from "@t3tools/contracts"; +import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts"; import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, @@ -30,8 +27,7 @@ import { type Action = | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } - | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } - | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } + | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } | { type: "TOGGLE_PROJECT"; projectId: string } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } @@ -137,10 +133,6 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } -function cloneProjectScripts(scripts: ReadonlyArray): ProjectScript[] { - return scripts.map((script) => ({ ...script })); -} - function mapProjectsFromReadModel( incoming: OrchestrationReadModel["projects"], previous: Project[], @@ -159,7 +151,7 @@ function mapProjectsFromReadModel( (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.workspaceRoot) : true), - scripts: cloneProjectScripts(project.scripts), + scripts: project.scripts.map((script) => ({ ...script })), }; }); } @@ -187,7 +179,6 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { return providerName === "claudeCode" ? "claudeCode" : "codex"; } - function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); @@ -208,7 +199,6 @@ function normalizeRunningTerminalIds( .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); } - function normalizeTerminalGroupIds(terminalIds: string[]): string[] { return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; } @@ -374,7 +364,6 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { }); } - // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { @@ -384,94 +373,100 @@ export function reducer(state: AppState, action: Action): AppState { action.readModel.projects.filter((project) => project.deletedAt === null), state.projects, ); - const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); + const existingThreadById = new Map( + state.threads.map((thread) => [thread.id, thread] as const), + ); const threads = action.readModel.threads .filter((thread) => thread.deletedAt === null) .map((thread) => { - const existing = existingThreadById.get(thread.id); - const latestTurnStartedAt = - thread.latestTurnId === null - ? undefined - : thread.messages.find( - (message) => message.turnId === thread.latestTurnId && message.role === "user", - )?.createdAt; - const latestTurnCompletedAt = thread.checkpoints.find( - (checkpoint) => checkpoint.turnId === thread.latestTurnId, - )?.completedAt; - const latestTurnDurationMs = - latestTurnStartedAt && latestTurnCompletedAt - ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt)) - : undefined; + const existing = existingThreadById.get(thread.id); + const latestTurnStartedAt = + thread.latestTurnId === null + ? undefined + : thread.messages.find( + (message) => message.turnId === thread.latestTurnId && message.role === "user", + )?.createdAt; + const latestTurnCompletedAt = thread.checkpoints.find( + (checkpoint) => checkpoint.turnId === thread.latestTurnId, + )?.completedAt; + const latestTurnDurationMs = + latestTurnStartedAt && latestTurnCompletedAt + ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt)) + : undefined; - return normalizeThreadTerminals({ - id: thread.id, - codexThreadId: thread.session?.providerThreadId ?? null, - projectId: thread.projectId, - title: thread.title, - model: resolveModelSlug(thread.model), - terminalOpen: existing?.terminalOpen ?? false, - terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: existing?.runningTerminalIds ?? [], - activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: existing?.terminalGroups ?? [ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ], - activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: thread.session - ? { - sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, - provider: toLegacyProvider(thread.session.providerName), - status: toLegacySessionStatus(thread.session.status), - orchestrationStatus: thread.session.status, - threadId: thread.session.providerThreadId, - activeTurnId: thread.session.activeTurnId ?? undefined, - createdAt: thread.session.updatedAt, - updatedAt: thread.session.updatedAt, - ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), - } - : null, - messages: thread.messages.map((message) => { - const attachments = message.attachments?.map((attachment, index) => ({ - type: "image" as const, - id: `${message.id}:${index}`, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: attachment.dataUrl, - })); - const normalizedMessage: ChatMessage = { - id: message.id, - role: message.role, - text: message.text, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; - return normalizedMessage; - }), - error: thread.session?.lastError ?? null, - createdAt: thread.createdAt, - latestTurnId: thread.latestTurnId ?? undefined, - latestTurnStartedAt, - latestTurnCompletedAt, - latestTurnDurationMs, - lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - })), - activities: thread.activities.map((activity) => ({ ...activity })), + return normalizeThreadTerminals({ + id: thread.id, + codexThreadId: thread.session?.providerThreadId ?? null, + projectId: thread.projectId, + title: thread.title, + model: resolveModelSlug(thread.model), + terminalOpen: existing?.terminalOpen ?? false, + terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: existing?.runningTerminalIds ?? [], + activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: existing?.terminalGroups ?? [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: + existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, + session: thread.session + ? { + sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, + provider: toLegacyProvider(thread.session.providerName), + status: toLegacySessionStatus(thread.session.status), + orchestrationStatus: thread.session.status, + threadId: thread.session.providerThreadId, + activeTurnId: thread.session.activeTurnId ?? undefined, + createdAt: thread.session.updatedAt, + updatedAt: thread.session.updatedAt, + ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), + } + : null, + messages: thread.messages.map((message) => { + const attachments = message.attachments?.map((attachment, index) => ({ + type: "image" as const, + id: `${message.id}:${index}`, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.dataUrl, + })); + const normalizedMessage: ChatMessage = { + id: message.id, + role: message.role, + text: message.text, + createdAt: message.createdAt, + streaming: message.streaming, + ...(message.streaming ? {} : { completedAt: message.updatedAt }), + ...(attachments && attachments.length > 0 ? { attachments } : {}), + }; + return normalizedMessage; + }), + error: thread.session?.lastError ?? null, + createdAt: thread.createdAt, + latestTurnId: thread.latestTurnId ?? undefined, + latestTurnStartedAt, + latestTurnCompletedAt, + latestTurnDurationMs, + lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ + turnId: checkpoint.turnId, + completedAt: checkpoint.completedAt, + status: checkpoint.status, + assistantMessageId: checkpoint.assistantMessageId ?? undefined, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: checkpoint.checkpointRef, + files: checkpoint.files.map((file) => ({ ...file })), + })), + activities: thread.activities.map((activity) => ({ ...activity })), + }); }); - }); return { ...state, projects, @@ -480,24 +475,27 @@ export function reducer(state: AppState, action: Action): AppState { }; } - case "SET_PROJECT_SCRIPTS": + case "MARK_THREAD_VISITED": { + const visitedAt = action.visitedAt ?? new Date().toISOString(); + const visitedAtMs = Date.parse(visitedAt); return { ...state, - projects: state.projects.map((project) => - project.id === action.projectId - ? { ...project, scripts: cloneProjectScripts(action.scripts) } - : project, - ), - }; - - case "SET_THREADS_HYDRATED": - if (state.threadsHydrated === action.hydrated) { - return state; - } - return { - ...state, - threadsHydrated: action.hydrated, + threads: updateThread(state.threads, action.threadId, (thread) => { + const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; + if ( + Number.isFinite(previousVisitedAtMs) && + Number.isFinite(visitedAtMs) && + previousVisitedAtMs >= visitedAtMs + ) { + return thread; + } + return { + ...thread, + lastVisitedAt: visitedAt, + }; + }), }; + } case "TOGGLE_PROJECT": return { @@ -540,10 +538,7 @@ export function reducer(state: AppState, action: Action): AppState { threads: updateThread(state.threads, action.threadId, (thread) => { const normalizedThread = normalizeThreadTerminals(thread); const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); - if ( - isNewTerminal && - normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT - ) { + if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { return normalizedThread; } const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) @@ -613,10 +608,7 @@ export function reducer(state: AppState, action: Action): AppState { threads: updateThread(state.threads, action.threadId, (thread) => { const normalizedThread = normalizeThreadTerminals(thread); const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); - if ( - isNewTerminal && - normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT - ) { + if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { return normalizedThread; } const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)