| author | |
| committer | |
| log | 5120f7fec702a4bfd09ae64048ee99708247993a |
| tree | 274c10695e1c93816f281800f8841a5703ecbca4 |
| parent | b5bab3eb134fd7555914fe57031a064b08beb195 |
| signature | Commit is signed but in an unrecognized format. |
65 files changed, 5221 insertions(+), 5221 deletions(-)
AGENTS.md+2-2| ... | @@ -14,7 +14,7 @@ If a tradeoff is required, choose correctness and robustness over short-term con | ... | @@ -14,7 +14,7 @@ If a tradeoff is required, choose correctness and robustness over short-term con |
| 14 | 14 | ||
| 15 | ## Package Roles | 15 | ## Package Roles |
| 16 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. | 16 | - `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. |
| 17 | - `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. | 17 | - `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. |
| 18 | - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. | 18 | - `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. |
| 19 | 19 | ||
| 20 | ## Codex App Server (Important) | 20 | ## Codex App Server (Important) |
| ... | @@ -24,7 +24,7 @@ How we use it in this codebase: | ... | @@ -24,7 +24,7 @@ How we use it in this codebase: |
| 24 | - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. | 24 | - Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`. |
| 25 | - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. | 25 | - Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`. |
| 26 | - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. | 26 | - WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`. |
| 27 | - Renderer consumes provider event streams via WebSocket push on channel `providers.event`. | 27 | - Web app consumes provider event streams via WebSocket push on channel `providers.event`. |
| 28 | 28 | ||
| 29 | Docs: | 29 | Docs: |
| 30 | - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server | 30 | - Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server |
README.md+7-7| ... | @@ -29,9 +29,9 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS | ... | @@ -29,9 +29,9 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS |
| 29 | 29 | ||
| 30 | ## Workspace layout | 30 | ## Workspace layout |
| 31 | 31 | ||
| 32 | - `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built renderer, and opens the browser on start. | 32 | - `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start. |
| 33 | - `/apps/renderer`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. | 33 | - `/apps/web`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket. |
| 34 | - `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared renderer. | 34 | - `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared web app. |
| 35 | - `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. | 35 | - `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. |
| 36 | 36 | ||
| 37 | ## Codex prerequisites | 37 | ## Codex prerequisites |
| ... | @@ -61,9 +61,9 @@ npx t3 | ... | @@ -61,9 +61,9 @@ npx t3 |
| 61 | 61 | ||
| 62 | - `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner. | 62 | - `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner. |
| 63 | - `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution). | 63 | - `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution). |
| 64 | - `bun run dev:web` — Starts just the Vite dev server for the renderer. | 64 | - `bun run dev:web` — Starts just the Vite dev server for the web app. |
| 65 | - `bun run start` — Runs the production server (serves built renderer as static files). | 65 | - `bun run start` — Runs the production server (serves built web app as static files). |
| 66 | - `bun run build` — Builds contracts, renderer, and server through Turbo. | 66 | - `bun run build` — Builds contracts, web app, and server through Turbo. |
| 67 | - `bun run typecheck` — Strict TypeScript checks for all packages. | 67 | - `bun run typecheck` — Strict TypeScript checks for all packages. |
| 68 | - `bun run test` — Runs workspace tests. | 68 | - `bun run test` — Runs workspace tests. |
| 69 | 69 | ||
| ... | @@ -76,7 +76,7 @@ T3 Code has a global runtime mode switch in the chat toolbar: | ... | @@ -76,7 +76,7 @@ T3 Code has a global runtime mode switch in the chat toolbar: |
| 76 | 76 | ||
| 77 | ## Provider architecture | 77 | ## Provider architecture |
| 78 | 78 | ||
| 79 | The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol: | 79 | The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: |
| 80 | 80 | ||
| 81 | - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` | 81 | - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` |
| 82 | - **Push events**: `{ type: "push", channel, data }` for streaming provider events | 82 | - **Push events**: `{ type: "push", channel, data }` for streaming provider events |
apps/desktop/package.json+1-1| ... | @@ -4,7 +4,7 @@ | ... | @@ -4,7 +4,7 @@ |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "main": "dist-electron/main.js", | 5 | "main": "dist-electron/main.js", |
| 6 | "scripts": { | 6 | "scripts": { |
| 7 | "dev": "bun run --cwd ../server build && concurrently -k -n BUNDLE,RENDERER,ELECTRON \"bun run dev:bundle\" \"bun run --cwd ../renderer dev\" \"bun run dev:electron\"", | 7 | "dev": "bun run --cwd ../server build && concurrently -k -n BUNDLE,WEB,ELECTRON \"bun run dev:bundle\" \"bun run --cwd ../web dev\" \"bun run dev:electron\"", |
| 8 | "dev:bundle": "tsup --watch", | 8 | "dev:bundle": "tsup --watch", |
| 9 | "dev:electron": "bun run scripts/dev-electron.mjs", | 9 | "dev:electron": "bun run scripts/dev-electron.mjs", |
| 10 | "build": "tsup", | 10 | "build": "tsup", |
apps/desktop/scripts/smoke-test.mjs+1-1| ... | @@ -8,7 +8,7 @@ const rootDir = resolve(__dirname, "../../.."); | ... | @@ -8,7 +8,7 @@ const rootDir = resolve(__dirname, "../../.."); |
| 8 | const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); | 8 | const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); |
| 9 | const mainJs = resolve(desktopDir, "dist-electron/main.js"); | 9 | const mainJs = resolve(desktopDir, "dist-electron/main.js"); |
| 10 | 10 | ||
| 11 | console.log("Building contracts + renderer + server + desktop..."); | 11 | console.log("Building contracts + web + server + desktop..."); |
| 12 | execSync("bun run build", { cwd: rootDir, stdio: "inherit" }); | 12 | execSync("bun run build", { cwd: rootDir, stdio: "inherit" }); |
| 13 | 13 | ||
| 14 | console.log("\nLaunching Electron smoke test..."); | 14 | console.log("\nLaunching Electron smoke test..."); |
apps/desktop/src/main.ts+4-4| ... | @@ -14,7 +14,7 @@ fixPath(); | ... | @@ -14,7 +14,7 @@ fixPath(); |
| 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 14 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 15 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | 15 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 16 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); | 16 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js"); |
| 17 | const RENDERER_ENTRY = path.join(ROOT_DIR, "apps/renderer/dist/index.html"); | 17 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); |
| 18 | const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); | 18 | const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); |
| 19 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 19 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 20 | 20 | ||
| ... | @@ -167,10 +167,10 @@ function createWindow(): BrowserWindow { | ... | @@ -167,10 +167,10 @@ function createWindow(): BrowserWindow { |
| 167 | if (isDevelopment) { | 167 | if (isDevelopment) { |
| 168 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); | 168 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); |
| 169 | } else { | 169 | } else { |
| 170 | if (!fs.existsSync(RENDERER_ENTRY)) { | 170 | if (!fs.existsSync(WEB_ENTRY)) { |
| 171 | throw new Error(`Renderer bundle missing at ${RENDERER_ENTRY}`); | 171 | throw new Error(`Web bundle missing at ${WEB_ENTRY}`); |
| 172 | } | 172 | } |
| 173 | void window.loadFile(RENDERER_ENTRY); | 173 | void window.loadFile(WEB_ENTRY); |
| 174 | } | 174 | } |
| 175 | 175 | ||
| 176 | window.on("closed", () => { | 176 | window.on("closed", () => { |
apps/renderer/index.html deleted-15| ... | @@ -1,15 +0,0 @@ | ||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8" /> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| 6 | <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||
| 7 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||
| 8 | <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" /> | ||
| 9 | <title>T3 Code</title> | ||
| 10 | </head> | ||
| 11 | <body> | ||
| 12 | <div id="root"></div> | ||
| 13 | <script type="module" src="/src/main.tsx"></script> | ||
| 14 | </body> | ||
| 15 | </html> | ||
apps/renderer/package.json deleted-34| ... | @@ -1,34 +0,0 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/renderer", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "type": "module", | ||
| 6 | "scripts": { | ||
| 7 | "dev": "vite", | ||
| 8 | "build": "vite build", | ||
| 9 | "preview": "vite preview", | ||
| 10 | "typecheck": "tsc --noEmit", | ||
| 11 | "test": "vitest run --passWithNoTests" | ||
| 12 | }, | ||
| 13 | "dependencies": { | ||
| 14 | "@t3tools/contracts": "workspace:*", | ||
| 15 | "highlight.js": "^11.11.1", | ||
| 16 | "lucide-react": "^0.563.0", | ||
| 17 | "react": "^19.0.0", | ||
| 18 | "react-dom": "^19.0.0", | ||
| 19 | "react-markdown": "^10.1.0", | ||
| 20 | "rehype-highlight": "^7.0.2", | ||
| 21 | "remark-gfm": "^4.0.1", | ||
| 22 | "zod": "^3.24.1" | ||
| 23 | }, | ||
| 24 | "devDependencies": { | ||
| 25 | "@tailwindcss/vite": "^4.0.0", | ||
| 26 | "@types/react": "^19.0.0", | ||
| 27 | "@types/react-dom": "^19.0.0", | ||
| 28 | "@vitejs/plugin-react": "^4.3.4", | ||
| 29 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", | ||
| 30 | "tailwindcss": "^4.0.0", | ||
| 31 | "typescript": "^5.7.3", | ||
| 32 | "vite": "^6.0.5" | ||
| 33 | } | ||
| 34 | } | ||
apps/renderer/src/App.tsx deleted-175| ... | @@ -1,175 +0,0 @@ | ||
| 1 | import { useEffect, useMemo, useRef } from "react"; | ||
| 2 | |||
| 3 | import ChatView from "./components/ChatView"; | ||
| 4 | import DiffPanel from "./components/DiffPanel"; | ||
| 5 | import Sidebar from "./components/Sidebar"; | ||
| 6 | import { isElectron } from "./env"; | ||
| 7 | import { DEFAULT_MODEL } from "./model-logic"; | ||
| 8 | import { readNativeApi } from "./session-logic"; | ||
| 9 | import { StoreProvider, useStore } from "./store"; | ||
| 10 | import { onServerWelcome } from "./wsNativeApi"; | ||
| 11 | |||
| 12 | function EventRouter() { | ||
| 13 | const api = useMemo(() => readNativeApi(), []); | ||
| 14 | const { dispatch } = useStore(); | ||
| 15 | const activeAssistantItemRef = useRef<string | null>(null); | ||
| 16 | |||
| 17 | useEffect(() => { | ||
| 18 | if (!api) return; | ||
| 19 | return api.providers.onEvent((event) => { | ||
| 20 | dispatch({ | ||
| 21 | type: "APPLY_EVENT", | ||
| 22 | event, | ||
| 23 | activeAssistantItemRef, | ||
| 24 | }); | ||
| 25 | }); | ||
| 26 | }, [api, dispatch]); | ||
| 27 | |||
| 28 | return null; | ||
| 29 | } | ||
| 30 | |||
| 31 | function AutoProjectBootstrap() { | ||
| 32 | const { state, dispatch } = useStore(); | ||
| 33 | const bootstrappedRef = useRef(false); | ||
| 34 | |||
| 35 | useEffect(() => { | ||
| 36 | // Browser mode bootstraps from server welcome. | ||
| 37 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | ||
| 38 | if (isElectron) return; | ||
| 39 | |||
| 40 | return onServerWelcome((payload) => { | ||
| 41 | if (bootstrappedRef.current) return; | ||
| 42 | |||
| 43 | // Don't create duplicate projects for the same cwd | ||
| 44 | const existing = state.projects.find((p) => p.cwd === payload.cwd); | ||
| 45 | if (existing) { | ||
| 46 | bootstrappedRef.current = true; | ||
| 47 | // Ensure a thread is active | ||
| 48 | const existingThread = state.threads.find((t) => t.projectId === existing.id); | ||
| 49 | if (existingThread && !state.activeThreadId) { | ||
| 50 | dispatch({ | ||
| 51 | type: "SET_ACTIVE_THREAD", | ||
| 52 | threadId: existingThread.id, | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | |||
| 58 | bootstrappedRef.current = true; | ||
| 59 | |||
| 60 | // Create project + thread from server cwd | ||
| 61 | const projectId = crypto.randomUUID(); | ||
| 62 | dispatch({ | ||
| 63 | type: "ADD_PROJECT", | ||
| 64 | project: { | ||
| 65 | id: projectId, | ||
| 66 | name: payload.projectName, | ||
| 67 | cwd: payload.cwd, | ||
| 68 | model: DEFAULT_MODEL, | ||
| 69 | expanded: true, | ||
| 70 | }, | ||
| 71 | }); | ||
| 72 | dispatch({ | ||
| 73 | type: "ADD_THREAD", | ||
| 74 | thread: { | ||
| 75 | id: crypto.randomUUID(), | ||
| 76 | codexThreadId: null, | ||
| 77 | projectId, | ||
| 78 | title: "New thread", | ||
| 79 | model: DEFAULT_MODEL, | ||
| 80 | session: null, | ||
| 81 | messages: [], | ||
| 82 | events: [], | ||
| 83 | error: null, | ||
| 84 | createdAt: new Date().toISOString(), | ||
| 85 | }, | ||
| 86 | }); | ||
| 87 | }); | ||
| 88 | }, [state.projects, state.threads, state.activeThreadId, dispatch]); | ||
| 89 | |||
| 90 | return null; | ||
| 91 | } | ||
| 92 | |||
| 93 | function DesktopProjectBootstrap() { | ||
| 94 | const api = useMemo(() => readNativeApi(), []); | ||
| 95 | const { dispatch } = useStore(); | ||
| 96 | const bootstrappedRef = useRef(false); | ||
| 97 | |||
| 98 | useEffect(() => { | ||
| 99 | if (!isElectron || !api || bootstrappedRef.current) return; | ||
| 100 | |||
| 101 | let disposed = false; | ||
| 102 | let retryDelayMs = 500; | ||
| 103 | let retryTimer: ReturnType<typeof setTimeout> | null = null; | ||
| 104 | |||
| 105 | const attemptBootstrap = async () => { | ||
| 106 | try { | ||
| 107 | const projects = await api.projects.list(); | ||
| 108 | if (disposed) return; | ||
| 109 | dispatch({ | ||
| 110 | type: "SYNC_PROJECTS", | ||
| 111 | projects: projects.map((project) => ({ | ||
| 112 | id: project.id, | ||
| 113 | name: project.name, | ||
| 114 | cwd: project.cwd, | ||
| 115 | model: DEFAULT_MODEL, | ||
| 116 | expanded: true, | ||
| 117 | })), | ||
| 118 | }); | ||
| 119 | bootstrappedRef.current = true; | ||
| 120 | } catch { | ||
| 121 | if (disposed) return; | ||
| 122 | retryTimer = setTimeout(() => { | ||
| 123 | retryTimer = null; | ||
| 124 | void attemptBootstrap(); | ||
| 125 | }, retryDelayMs); | ||
| 126 | retryDelayMs = Math.min(retryDelayMs * 2, 5_000); | ||
| 127 | } | ||
| 128 | }; | ||
| 129 | |||
| 130 | void attemptBootstrap(); | ||
| 131 | |||
| 132 | return () => { | ||
| 133 | disposed = true; | ||
| 134 | if (retryTimer) { | ||
| 135 | clearTimeout(retryTimer); | ||
| 136 | } | ||
| 137 | }; | ||
| 138 | }, [api, dispatch]); | ||
| 139 | |||
| 140 | return null; | ||
| 141 | } | ||
| 142 | |||
| 143 | function Layout() { | ||
| 144 | const api = useMemo(() => readNativeApi(), []); | ||
| 145 | const { state } = useStore(); | ||
| 146 | |||
| 147 | if (!api) { | ||
| 148 | return ( | ||
| 149 | <div className="flex h-screen flex-col bg-background text-foreground"> | ||
| 150 | <div className="flex flex-1 items-center justify-center"> | ||
| 151 | <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p> | ||
| 152 | </div> | ||
| 153 | </div> | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | |||
| 157 | return ( | ||
| 158 | <div className="flex h-screen overflow-hidden bg-background text-foreground"> | ||
| 159 | <EventRouter /> | ||
| 160 | <AutoProjectBootstrap /> | ||
| 161 | <DesktopProjectBootstrap /> | ||
| 162 | <Sidebar /> | ||
| 163 | <ChatView /> | ||
| 164 | {state.diffOpen && <DiffPanel />} | ||
| 165 | </div> | ||
| 166 | ); | ||
| 167 | } | ||
| 168 | |||
| 169 | export default function App() { | ||
| 170 | return ( | ||
| 171 | <StoreProvider> | ||
| 172 | <Layout /> | ||
| 173 | </StoreProvider> | ||
| 174 | ); | ||
| 175 | } | ||
apps/renderer/src/components/ChatMarkdown.tsx deleted-28| ... | @@ -1,28 +0,0 @@ | ||
| 1 | import type { Components } from "react-markdown"; | ||
| 2 | import ReactMarkdown from "react-markdown"; | ||
| 3 | import rehypeHighlight from "rehype-highlight"; | ||
| 4 | import remarkGfm from "remark-gfm"; | ||
| 5 | |||
| 6 | interface ChatMarkdownProps { | ||
| 7 | text: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | const markdownComponents: Components = { | ||
| 11 | a({ node: _node, ...props }) { | ||
| 12 | return <a {...props} target="_blank" rel="noreferrer" />; | ||
| 13 | }, | ||
| 14 | }; | ||
| 15 | |||
| 16 | export default function ChatMarkdown({ text }: ChatMarkdownProps) { | ||
| 17 | return ( | ||
| 18 | <div className="chat-markdown text-sm leading-relaxed text-foreground/80"> | ||
| 19 | <ReactMarkdown | ||
| 20 | remarkPlugins={[remarkGfm]} | ||
| 21 | rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]} | ||
| 22 | components={markdownComponents} | ||
| 23 | > | ||
| 24 | {text} | ||
| 25 | </ReactMarkdown> | ||
| 26 | </div> | ||
| 27 | ); | ||
| 28 | } | ||
apps/renderer/src/components/ChatView.tsx deleted-1170| ... | @@ -1,1170 +0,0 @@ | ||
| 1 | import { | ||
| 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 3 | type ProviderApprovalDecision, | ||
| 4 | type ProviderEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | type FormEvent, | ||
| 8 | Fragment, | ||
| 9 | type KeyboardEvent, | ||
| 10 | useEffect, | ||
| 11 | useMemo, | ||
| 12 | useRef, | ||
| 13 | useState, | ||
| 14 | } from "react"; | ||
| 15 | |||
| 16 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | ||
| 17 | import { isElectron } from "../env"; | ||
| 18 | import { buildBootstrapInput } from "../historyBootstrap"; | ||
| 19 | import { | ||
| 20 | DEFAULT_MODEL, | ||
| 21 | DEFAULT_REASONING, | ||
| 22 | MODEL_OPTIONS, | ||
| 23 | REASONING_OPTIONS, | ||
| 24 | resolveModelSlug, | ||
| 25 | } from "../model-logic"; | ||
| 26 | import { | ||
| 27 | derivePhase, | ||
| 28 | deriveTimelineEntries, | ||
| 29 | deriveWorkLogEntries, | ||
| 30 | formatDuration, | ||
| 31 | formatElapsed, | ||
| 32 | formatTimestamp, | ||
| 33 | readNativeApi, | ||
| 34 | } from "../session-logic"; | ||
| 35 | import { useStore } from "../store"; | ||
| 36 | import ChatMarkdown from "./ChatMarkdown"; | ||
| 37 | |||
| 38 | function formatMessageMeta(createdAt: string, duration: string | null): string { | ||
| 39 | if (!duration) return formatTimestamp(createdAt); | ||
| 40 | return `${formatTimestamp(createdAt)} • ${duration}`; | ||
| 41 | } | ||
| 42 | |||
| 43 | const FILE_MANAGER_LABEL = navigator.platform.includes("Mac") | ||
| 44 | ? "Finder" | ||
| 45 | : navigator.platform.includes("Win") | ||
| 46 | ? "Explorer" | ||
| 47 | : "Files"; | ||
| 48 | |||
| 49 | function editorLabel(editor: (typeof EDITORS)[number]): string { | ||
| 50 | return editor.command ? editor.label : FILE_MANAGER_LABEL; | ||
| 51 | } | ||
| 52 | |||
| 53 | const LAST_EDITOR_KEY = "t3code:last-editor"; | ||
| 54 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | ||
| 55 | |||
| 56 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ||
| 57 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | ||
| 58 | if (tone === "tool") return "text-muted-foreground/70"; | ||
| 59 | if (tone === "thinking") return "text-muted-foreground/50"; | ||
| 60 | return "text-muted-foreground/40"; | ||
| 61 | } | ||
| 62 | |||
| 63 | interface PendingApprovalCard { | ||
| 64 | requestId: string; | ||
| 65 | requestKind: "command" | "file-change"; | ||
| 66 | createdAt: string; | ||
| 67 | detail?: string; | ||
| 68 | } | ||
| 69 | |||
| 70 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; | ||
| 71 | |||
| 72 | interface EnsuredSessionInfo { | ||
| 73 | sessionId: string; | ||
| 74 | resolvedThreadId: string | null; | ||
| 75 | continuityState: SessionContinuityState; | ||
| 76 | } | ||
| 77 | |||
| 78 | function asRecord(value: unknown): Record<string, unknown> | undefined { | ||
| 79 | if (!value || typeof value !== "object") return undefined; | ||
| 80 | return value as Record<string, unknown>; | ||
| 81 | } | ||
| 82 | |||
| 83 | function asString(value: unknown): string | undefined { | ||
| 84 | return typeof value === "string" ? value : undefined; | ||
| 85 | } | ||
| 86 | |||
| 87 | function approvalDetail(event: ProviderEvent): string | undefined { | ||
| 88 | const payload = asRecord(event.payload); | ||
| 89 | const command = asString(payload?.command); | ||
| 90 | if (command) return command; | ||
| 91 | return asString(payload?.reason); | ||
| 92 | } | ||
| 93 | |||
| 94 | function derivePendingApprovals( | ||
| 95 | events: ProviderEvent[], | ||
| 96 | ): PendingApprovalCard[] { | ||
| 97 | const pending = new Map<string, PendingApprovalCard>(); | ||
| 98 | const ordered = [...events].toReversed(); | ||
| 99 | |||
| 100 | for (const event of ordered) { | ||
| 101 | if ( | ||
| 102 | event.method === "session/closed" || | ||
| 103 | event.method === "session/exited" | ||
| 104 | ) { | ||
| 105 | pending.clear(); | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | |||
| 109 | const requestId = | ||
| 110 | event.requestId ?? asString(asRecord(event.payload)?.requestId); | ||
| 111 | if (!requestId) continue; | ||
| 112 | |||
| 113 | if ( | ||
| 114 | event.kind === "request" && | ||
| 115 | (event.requestKind === "command" || event.requestKind === "file-change") | ||
| 116 | ) { | ||
| 117 | const detail = approvalDetail(event); | ||
| 118 | pending.set(requestId, { | ||
| 119 | requestId, | ||
| 120 | requestKind: event.requestKind, | ||
| 121 | createdAt: event.createdAt, | ||
| 122 | ...(detail ? { detail } : {}), | ||
| 123 | }); | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | |||
| 127 | if (event.method === "item/requestApproval/decision") { | ||
| 128 | pending.delete(requestId); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | return Array.from(pending.values()); | ||
| 133 | } | ||
| 134 | |||
| 135 | export default function ChatView() { | ||
| 136 | const { state, dispatch } = useStore(); | ||
| 137 | const api = useMemo(() => readNativeApi(), []); | ||
| 138 | const [prompt, setPrompt] = useState(""); | ||
| 139 | const [isSending, setIsSending] = useState(false); | ||
| 140 | const [isConnecting, setIsConnecting] = useState(false); | ||
| 141 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); | ||
| 142 | const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); | ||
| 143 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | ||
| 144 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | ||
| 145 | return EDITORS.some((e) => e.id === stored) | ||
| 146 | ? (stored as EditorId) | ||
| 147 | : EDITORS[0].id; | ||
| 148 | }); | ||
| 149 | const [selectedEffort, setSelectedEffort] = | ||
| 150 | useState<string>(DEFAULT_REASONING); | ||
| 151 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | ||
| 152 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>( | ||
| 153 | [], | ||
| 154 | ); | ||
| 155 | const [expandedWorkGroups, setExpandedWorkGroups] = useState< | ||
| 156 | Record<string, boolean> | ||
| 157 | >({}); | ||
| 158 | const [nowTick, setNowTick] = useState(() => Date.now()); | ||
| 159 | const messagesEndRef = useRef<HTMLDivElement>(null); | ||
| 160 | const textareaRef = useRef<HTMLTextAreaElement>(null); | ||
| 161 | const modelMenuRef = useRef<HTMLDivElement>(null); | ||
| 162 | const editorMenuRef = useRef<HTMLDivElement>(null); | ||
| 163 | |||
| 164 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | ||
| 165 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | ||
| 166 | const selectedModel = resolveModelSlug( | ||
| 167 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | ||
| 168 | ); | ||
| 169 | const phase = derivePhase(activeThread?.session ?? null); | ||
| 170 | const isWorking = phase === "running" || isSending || isConnecting; | ||
| 171 | const nowIso = new Date(nowTick).toISOString(); | ||
| 172 | const modelOptions = MODEL_OPTIONS; | ||
| 173 | const workLogEntries = useMemo( | ||
| 174 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), | ||
| 175 | [activeThread?.events], | ||
| 176 | ); | ||
| 177 | const latestTurnWorkEntries = useMemo( | ||
| 178 | () => | ||
| 179 | deriveWorkLogEntries( | ||
| 180 | activeThread?.events ?? [], | ||
| 181 | activeThread?.latestTurnId, | ||
| 182 | ), | ||
| 183 | [activeThread?.events, activeThread?.latestTurnId], | ||
| 184 | ); | ||
| 185 | const pendingApprovals = useMemo( | ||
| 186 | () => derivePendingApprovals(activeThread?.events ?? []), | ||
| 187 | [activeThread?.events], | ||
| 188 | ); | ||
| 189 | const assistantCompletionByItemId = useMemo(() => { | ||
| 190 | const map = new Map<string, string>(); | ||
| 191 | const ordered = [...(activeThread?.events ?? [])].toReversed(); | ||
| 192 | for (const event of ordered) { | ||
| 193 | if (event.method !== "item/completed") continue; | ||
| 194 | if (!event.itemId) continue; | ||
| 195 | map.set(event.itemId, event.createdAt); | ||
| 196 | } | ||
| 197 | return map; | ||
| 198 | }, [activeThread?.events]); | ||
| 199 | const timelineEntries = useMemo( | ||
| 200 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), | ||
| 201 | [activeThread?.messages, workLogEntries], | ||
| 202 | ); | ||
| 203 | const completionSummary = useMemo(() => { | ||
| 204 | if (!activeThread?.latestTurnStartedAt) return null; | ||
| 205 | if (!activeThread.latestTurnCompletedAt) return null; | ||
| 206 | if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { | ||
| 207 | return null; | ||
| 208 | } | ||
| 209 | |||
| 210 | if ( | ||
| 211 | typeof activeThread.latestTurnDurationMs === "number" && | ||
| 212 | Number.isFinite(activeThread.latestTurnDurationMs) && | ||
| 213 | activeThread.latestTurnDurationMs >= 0 | ||
| 214 | ) { | ||
| 215 | return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; | ||
| 216 | } | ||
| 217 | |||
| 218 | const elapsed = formatElapsed( | ||
| 219 | activeThread.latestTurnStartedAt, | ||
| 220 | activeThread.latestTurnCompletedAt, | ||
| 221 | ); | ||
| 222 | return elapsed ? `Worked for ${elapsed}` : null; | ||
| 223 | }, [ | ||
| 224 | activeThread?.latestTurnStartedAt, | ||
| 225 | activeThread?.latestTurnCompletedAt, | ||
| 226 | activeThread?.latestTurnDurationMs, | ||
| 227 | latestTurnWorkEntries, | ||
| 228 | ]); | ||
| 229 | const completionDividerBeforeEntryId = useMemo(() => { | ||
| 230 | if (!activeThread?.latestTurnStartedAt) return null; | ||
| 231 | if (!activeThread.latestTurnCompletedAt) return null; | ||
| 232 | if (!completionSummary) return null; | ||
| 233 | |||
| 234 | const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); | ||
| 235 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | ||
| 236 | if (Number.isNaN(turnStartedAt)) return null; | ||
| 237 | if (Number.isNaN(turnCompletedAt)) return null; | ||
| 238 | |||
| 239 | let inRangeMatch: string | null = null; | ||
| 240 | let fallbackMatch: string | null = null; | ||
| 241 | for (const timelineEntry of timelineEntries) { | ||
| 242 | if (timelineEntry.kind !== "message") continue; | ||
| 243 | if (timelineEntry.message.role !== "assistant") continue; | ||
| 244 | const messageAt = Date.parse(timelineEntry.message.createdAt); | ||
| 245 | if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; | ||
| 246 | fallbackMatch = timelineEntry.id; | ||
| 247 | if (messageAt <= turnCompletedAt) { | ||
| 248 | inRangeMatch = timelineEntry.id; | ||
| 249 | } | ||
| 250 | } | ||
| 251 | return inRangeMatch ?? fallbackMatch; | ||
| 252 | }, [ | ||
| 253 | activeThread?.latestTurnCompletedAt, | ||
| 254 | activeThread?.latestTurnStartedAt, | ||
| 255 | completionSummary, | ||
| 256 | timelineEntries, | ||
| 257 | ]); | ||
| 258 | const runtimeSessionConfig = | ||
| 259 | state.runtimeMode === "full-access" | ||
| 260 | ? ({ | ||
| 261 | approvalPolicy: "never", | ||
| 262 | sandboxMode: "danger-full-access", | ||
| 263 | } as const) | ||
| 264 | : ({ | ||
| 265 | approvalPolicy: "on-request", | ||
| 266 | sandboxMode: "workspace-write", | ||
| 267 | } as const); | ||
| 268 | |||
| 269 | const handleRuntimeModeChange = async ( | ||
| 270 | mode: "approval-required" | "full-access", | ||
| 271 | ) => { | ||
| 272 | if (mode === state.runtimeMode) return; | ||
| 273 | dispatch({ type: "SET_RUNTIME_MODE", mode }); | ||
| 274 | if (!api) return; | ||
| 275 | |||
| 276 | const sessionIds = state.threads | ||
| 277 | .map((t) => t.session) | ||
| 278 | .filter( | ||
| 279 | (s): s is NonNullable<typeof s> => s !== null && s.status !== "closed", | ||
| 280 | ) | ||
| 281 | .map((s) => s.sessionId); | ||
| 282 | |||
| 283 | if (sessionIds.length === 0) return; | ||
| 284 | |||
| 285 | setIsSwitchingRuntimeMode(true); | ||
| 286 | try { | ||
| 287 | await Promise.all( | ||
| 288 | sessionIds.map((id) => | ||
| 289 | api.providers.stopSession({ sessionId: id }).catch(() => undefined), | ||
| 290 | ), | ||
| 291 | ); | ||
| 292 | } finally { | ||
| 293 | setIsSwitchingRuntimeMode(false); | ||
| 294 | } | ||
| 295 | }; | ||
| 296 | |||
| 297 | // Auto-scroll on new messages | ||
| 298 | const messageCount = activeThread?.messages.length ?? 0; | ||
| 299 | const workLogCount = workLogEntries.length; | ||
| 300 | useEffect(() => { | ||
| 301 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | ||
| 302 | }, [messageCount]); | ||
| 303 | useEffect(() => { | ||
| 304 | if (phase !== "running") return; | ||
| 305 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | ||
| 306 | }, [phase, workLogCount]); | ||
| 307 | |||
| 308 | useEffect(() => { | ||
| 309 | setExpandedWorkGroups({}); | ||
| 310 | }, [activeThread?.id]); | ||
| 311 | |||
| 312 | // Auto-resize textarea | ||
| 313 | useEffect(() => { | ||
| 314 | const ta = textareaRef.current; | ||
| 315 | if (!ta) return; | ||
| 316 | ta.style.height = "auto"; | ||
| 317 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; | ||
| 318 | }, [prompt]); | ||
| 319 | |||
| 320 | useEffect(() => { | ||
| 321 | if (phase !== "running") return; | ||
| 322 | const timer = window.setInterval(() => { | ||
| 323 | setNowTick(Date.now()); | ||
| 324 | }, 250); | ||
| 325 | return () => { | ||
| 326 | window.clearInterval(timer); | ||
| 327 | }; | ||
| 328 | }, [phase]); | ||
| 329 | |||
| 330 | useEffect(() => { | ||
| 331 | if (!isModelMenuOpen) return; | ||
| 332 | |||
| 333 | const handleClickOutside = (event: MouseEvent) => { | ||
| 334 | if (!modelMenuRef.current) return; | ||
| 335 | if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) { | ||
| 336 | setIsModelMenuOpen(false); | ||
| 337 | } | ||
| 338 | }; | ||
| 339 | |||
| 340 | window.addEventListener("mousedown", handleClickOutside); | ||
| 341 | return () => { | ||
| 342 | window.removeEventListener("mousedown", handleClickOutside); | ||
| 343 | }; | ||
| 344 | }, [isModelMenuOpen]); | ||
| 345 | |||
| 346 | useEffect(() => { | ||
| 347 | if (!isEditorMenuOpen) return; | ||
| 348 | |||
| 349 | const handleClickOutside = (event: MouseEvent) => { | ||
| 350 | if (!editorMenuRef.current) return; | ||
| 351 | if ( | ||
| 352 | event.target instanceof Node && | ||
| 353 | !editorMenuRef.current.contains(event.target) | ||
| 354 | ) { | ||
| 355 | setIsEditorMenuOpen(false); | ||
| 356 | } | ||
| 357 | }; | ||
| 358 | |||
| 359 | window.addEventListener("mousedown", handleClickOutside); | ||
| 360 | return () => { | ||
| 361 | window.removeEventListener("mousedown", handleClickOutside); | ||
| 362 | }; | ||
| 363 | }, [isEditorMenuOpen]); | ||
| 364 | |||
| 365 | // Cmd+O / Ctrl+O to open in last-used editor | ||
| 366 | useEffect(() => { | ||
| 367 | const handler = (e: globalThis.KeyboardEvent) => { | ||
| 368 | if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { | ||
| 369 | if (api && activeProject) { | ||
| 370 | e.preventDefault(); | ||
| 371 | void api.shell.openInEditor(activeProject.cwd, lastEditor); | ||
| 372 | } | ||
| 373 | } | ||
| 374 | }; | ||
| 375 | window.addEventListener("keydown", handler); | ||
| 376 | return () => window.removeEventListener("keydown", handler); | ||
| 377 | }, [api, activeProject, lastEditor]); | ||
| 378 | |||
| 379 | const openInEditor = (editorId: EditorId) => { | ||
| 380 | if (!api || !activeProject) return; | ||
| 381 | void api.shell.openInEditor(activeProject.cwd, editorId); | ||
| 382 | setLastEditor(editorId); | ||
| 383 | localStorage.setItem(LAST_EDITOR_KEY, editorId); | ||
| 384 | setIsEditorMenuOpen(false); | ||
| 385 | }; | ||
| 386 | |||
| 387 | const ensureSession = async (): Promise<EnsuredSessionInfo | null> => { | ||
| 388 | if (!api || !activeThread || !activeProject) return null; | ||
| 389 | if (activeThread.session && activeThread.session.status !== "closed") { | ||
| 390 | const sessionThreadId = activeThread.session.threadId ?? null; | ||
| 391 | const continuityState: SessionContinuityState = | ||
| 392 | activeThread.codexThreadId === null | ||
| 393 | ? "new" | ||
| 394 | : sessionThreadId === activeThread.codexThreadId | ||
| 395 | ? "resumed" | ||
| 396 | : "fallback_new"; | ||
| 397 | return { | ||
| 398 | sessionId: activeThread.session.sessionId, | ||
| 399 | resolvedThreadId: sessionThreadId, | ||
| 400 | continuityState, | ||
| 401 | } satisfies EnsuredSessionInfo; | ||
| 402 | } | ||
| 403 | |||
| 404 | const priorCodexThreadId = activeThread.codexThreadId; | ||
| 405 | setIsConnecting(true); | ||
| 406 | try { | ||
| 407 | const session = await api.providers.startSession({ | ||
| 408 | provider: "codex", | ||
| 409 | cwd: activeProject.cwd || undefined, | ||
| 410 | model: selectedModel || undefined, | ||
| 411 | resumeThreadId: priorCodexThreadId ?? undefined, | ||
| 412 | approvalPolicy: runtimeSessionConfig.approvalPolicy, | ||
| 413 | sandboxMode: runtimeSessionConfig.sandboxMode, | ||
| 414 | }); | ||
| 415 | dispatch({ | ||
| 416 | type: "UPDATE_SESSION", | ||
| 417 | threadId: activeThread.id, | ||
| 418 | session, | ||
| 419 | }); | ||
| 420 | const resolvedThreadId = session.threadId ?? null; | ||
| 421 | const continuityState: SessionContinuityState = | ||
| 422 | priorCodexThreadId === null | ||
| 423 | ? "new" | ||
| 424 | : resolvedThreadId === priorCodexThreadId | ||
| 425 | ? "resumed" | ||
| 426 | : "fallback_new"; | ||
| 427 | return { | ||
| 428 | sessionId: session.sessionId, | ||
| 429 | resolvedThreadId, | ||
| 430 | continuityState, | ||
| 431 | }; | ||
| 432 | } catch (err) { | ||
| 433 | dispatch({ | ||
| 434 | type: "SET_ERROR", | ||
| 435 | threadId: activeThread.id, | ||
| 436 | error: err instanceof Error ? err.message : "Failed to connect.", | ||
| 437 | }); | ||
| 438 | return null; | ||
| 439 | } finally { | ||
| 440 | setIsConnecting(false); | ||
| 441 | } | ||
| 442 | }; | ||
| 443 | |||
| 444 | const onSend = async (e: FormEvent) => { | ||
| 445 | e.preventDefault(); | ||
| 446 | if (!api || !activeThread || isSending || isConnecting) return; | ||
| 447 | const trimmed = prompt.trim(); | ||
| 448 | if (!trimmed) return; | ||
| 449 | |||
| 450 | // Auto-title from first message | ||
| 451 | if (activeThread.messages.length === 0) { | ||
| 452 | const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed; | ||
| 453 | dispatch({ | ||
| 454 | type: "SET_THREAD_TITLE", | ||
| 455 | threadId: activeThread.id, | ||
| 456 | title, | ||
| 457 | }); | ||
| 458 | } | ||
| 459 | |||
| 460 | dispatch({ | ||
| 461 | type: "SET_ERROR", | ||
| 462 | threadId: activeThread.id, | ||
| 463 | error: null, | ||
| 464 | }); | ||
| 465 | dispatch({ | ||
| 466 | type: "PUSH_USER_MESSAGE", | ||
| 467 | threadId: activeThread.id, | ||
| 468 | id: crypto.randomUUID(), | ||
| 469 | text: trimmed, | ||
| 470 | }); | ||
| 471 | const previousMessages = activeThread.messages; | ||
| 472 | setPrompt(""); | ||
| 473 | |||
| 474 | const sessionInfo = await ensureSession(); | ||
| 475 | if (!sessionInfo) return; | ||
| 476 | |||
| 477 | setIsSending(true); | ||
| 478 | try { | ||
| 479 | const shouldBootstrap = | ||
| 480 | previousMessages.length > 0 && | ||
| 481 | (sessionInfo.continuityState === "new" || | ||
| 482 | sessionInfo.continuityState === "fallback_new"); | ||
| 483 | const input = shouldBootstrap | ||
| 484 | ? buildBootstrapInput( | ||
| 485 | previousMessages, | ||
| 486 | trimmed, | ||
| 487 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 488 | ).text | ||
| 489 | : trimmed; | ||
| 490 | await api.providers.sendTurn({ | ||
| 491 | sessionId: sessionInfo.sessionId, | ||
| 492 | input, | ||
| 493 | model: selectedModel || undefined, | ||
| 494 | effort: selectedEffort || undefined, | ||
| 495 | }); | ||
| 496 | } catch (err) { | ||
| 497 | dispatch({ | ||
| 498 | type: "SET_ERROR", | ||
| 499 | threadId: activeThread.id, | ||
| 500 | error: err instanceof Error ? err.message : "Failed to send message.", | ||
| 501 | }); | ||
| 502 | } finally { | ||
| 503 | setIsSending(false); | ||
| 504 | } | ||
| 505 | }; | ||
| 506 | |||
| 507 | const onInterrupt = async () => { | ||
| 508 | if (!api || !activeThread?.session) return; | ||
| 509 | await api.providers.interruptTurn({ | ||
| 510 | sessionId: activeThread.session.sessionId, | ||
| 511 | turnId: activeThread.session.activeTurnId, | ||
| 512 | }); | ||
| 513 | }; | ||
| 514 | |||
| 515 | const onRespondToApproval = async ( | ||
| 516 | requestId: string, | ||
| 517 | decision: ProviderApprovalDecision, | ||
| 518 | ) => { | ||
| 519 | if (!api || !activeThread?.session) return; | ||
| 520 | |||
| 521 | setRespondingRequestIds((existing) => | ||
| 522 | existing.includes(requestId) ? existing : [...existing, requestId], | ||
| 523 | ); | ||
| 524 | try { | ||
| 525 | await api.providers.respondToRequest({ | ||
| 526 | sessionId: activeThread.session.sessionId, | ||
| 527 | requestId, | ||
| 528 | decision, | ||
| 529 | }); | ||
| 530 | } catch (err) { | ||
| 531 | dispatch({ | ||
| 532 | type: "SET_ERROR", | ||
| 533 | threadId: activeThread.id, | ||
| 534 | error: | ||
| 535 | err instanceof Error | ||
| 536 | ? err.message | ||
| 537 | : "Failed to submit approval decision.", | ||
| 538 | }); | ||
| 539 | } finally { | ||
| 540 | setRespondingRequestIds((existing) => | ||
| 541 | existing.filter((id) => id !== requestId), | ||
| 542 | ); | ||
| 543 | } | ||
| 544 | }; | ||
| 545 | |||
| 546 | const onModelSelect = (model: string) => { | ||
| 547 | if (!activeThread) return; | ||
| 548 | dispatch({ | ||
| 549 | type: "SET_THREAD_MODEL", | ||
| 550 | threadId: activeThread.id, | ||
| 551 | model: resolveModelSlug(model), | ||
| 552 | }); | ||
| 553 | setIsModelMenuOpen(false); | ||
| 554 | }; | ||
| 555 | |||
| 556 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | ||
| 557 | if (e.key === "Enter" && !e.shiftKey) { | ||
| 558 | e.preventDefault(); | ||
| 559 | void onSend(e as unknown as FormEvent); | ||
| 560 | } | ||
| 561 | }; | ||
| 562 | |||
| 563 | // Empty state: no active thread | ||
| 564 | if (!activeThread) { | ||
| 565 | return ( | ||
| 566 | <div className="flex flex-1 flex-col bg-background text-muted-foreground/40"> | ||
| 567 | {isElectron && <div className="drag-region h-[52px] shrink-0" />} | ||
| 568 | <div className="flex flex-1 items-center justify-center"> | ||
| 569 | <div className="text-center"> | ||
| 570 | <p className="text-sm">Select a thread or create a new one to get started.</p> | ||
| 571 | </div> | ||
| 572 | </div> | ||
| 573 | </div> | ||
| 574 | ); | ||
| 575 | } | ||
| 576 | |||
| 577 | return ( | ||
| 578 | <div className="flex flex-1 flex-col bg-background"> | ||
| 579 | {/* Top bar */} | ||
| 580 | <header className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}> | ||
| 581 | <div className="flex items-center gap-3"> | ||
| 582 | <h2 className="text-sm font-medium text-foreground"> | ||
| 583 | {activeThread.title} | ||
| 584 | </h2> | ||
| 585 | </div> | ||
| 586 | <div className="flex items-center gap-3"> | ||
| 587 | {/* Open in editor */} | ||
| 588 | {activeProject && ( | ||
| 589 | <div className="relative" ref={editorMenuRef}> | ||
| 590 | <button | ||
| 591 | type="button" | ||
| 592 | className="rounded-md px-2 py-1 text-[10px] text-muted-foreground/40 transition-colors duration-150 hover:text-muted-foreground/60" | ||
| 593 | onClick={() => setIsEditorMenuOpen((v) => !v)} | ||
| 594 | > | ||
| 595 | Open in&hellip; | ||
| 596 | </button> | ||
| 597 | {isEditorMenuOpen && ( | ||
| 598 | <div className="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-md border border-border bg-popover py-1 shadow-xl"> | ||
| 599 | {EDITORS.map((editor) => ( | ||
| 600 | <button | ||
| 601 | key={editor.id} | ||
| 602 | type="button" | ||
| 603 | className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-foreground hover:bg-accent" | ||
| 604 | onClick={() => openInEditor(editor.id)} | ||
| 605 | > | ||
| 606 | {editorLabel(editor)} | ||
| 607 | {editor.id === lastEditor && ( | ||
| 608 | <kbd className="ml-auto text-[9px] text-muted-foreground/40"> | ||
| 609 | {navigator.platform.includes("Mac") | ||
| 610 | ? "\u2318O" | ||
| 611 | : "Ctrl+O"} | ||
| 612 | </kbd> | ||
| 613 | )} | ||
| 614 | </button> | ||
| 615 | ))} | ||
| 616 | </div> | ||
| 617 | )} | ||
| 618 | </div> | ||
| 619 | )} | ||
| 620 | {/* Diff toggle */} | ||
| 621 | <button | ||
| 622 | type="button" | ||
| 623 | className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${ | ||
| 624 | state.diffOpen | ||
| 625 | ? "bg-accent text-foreground" | ||
| 626 | : "text-muted-foreground/40 hover:text-muted-foreground/60" | ||
| 627 | }`} | ||
| 628 | onClick={() => dispatch({ type: "TOGGLE_DIFF" })} | ||
| 629 | > | ||
| 630 | Diff | ||
| 631 | </button> | ||
| 632 | </div> | ||
| 633 | </header> | ||
| 634 | |||
| 635 | {/* Error banner */} | ||
| 636 | {activeThread.error && ( | ||
| 637 | <div className="mx-4 mt-3 rounded-lg border border-rose-400/20 bg-rose-900/20 px-3 py-2 text-xs text-rose-200"> | ||
| 638 | {activeThread.error} | ||
| 639 | </div> | ||
| 640 | )} | ||
| 641 | |||
| 642 | {pendingApprovals.length > 0 && ( | ||
| 643 | <div className="mx-4 mt-3 space-y-2"> | ||
| 644 | {pendingApprovals.map((approval) => { | ||
| 645 | const isResponding = respondingRequestIds.includes( | ||
| 646 | approval.requestId, | ||
| 647 | ); | ||
| 648 | return ( | ||
| 649 | <div | ||
| 650 | key={approval.requestId} | ||
| 651 | className="rounded-lg border border-amber-300/20 bg-amber-500/[0.07] px-3 py-2" | ||
| 652 | > | ||
| 653 | <p className="text-xs font-medium text-amber-100"> | ||
| 654 | {approval.requestKind === "command" | ||
| 655 | ? "Command approval requested" | ||
| 656 | : "File-change approval requested"} | ||
| 657 | </p> | ||
| 658 | {approval.detail && ( | ||
| 659 | <p | ||
| 660 | className="mt-1 truncate font-mono text-[11px] text-amber-100/75" | ||
| 661 | title={approval.detail} | ||
| 662 | > | ||
| 663 | {approval.detail} | ||
| 664 | </p> | ||
| 665 | )} | ||
| 666 | <div className="mt-2 flex flex-wrap gap-1.5"> | ||
| 667 | <button | ||
| 668 | type="button" | ||
| 669 | className="rounded-md border border-border bg-accent px-2 py-1 text-[11px] text-foreground transition-colors duration-150 hover:bg-accent/80 disabled:cursor-not-allowed disabled:opacity-50" | ||
| 670 | disabled={isResponding} | ||
| 671 | onClick={() => | ||
| 672 | void onRespondToApproval(approval.requestId, "accept") | ||
| 673 | } | ||
| 674 | > | ||
| 675 | Approve once | ||
| 676 | </button> | ||
| 677 | <button | ||
| 678 | type="button" | ||
| 679 | className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 680 | disabled={isResponding} | ||
| 681 | onClick={() => | ||
| 682 | void onRespondToApproval( | ||
| 683 | approval.requestId, | ||
| 684 | "acceptForSession", | ||
| 685 | ) | ||
| 686 | } | ||
| 687 | > | ||
| 688 | Always allow this session | ||
| 689 | </button> | ||
| 690 | <button | ||
| 691 | type="button" | ||
| 692 | className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground/90 transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" | ||
| 693 | disabled={isResponding} | ||
| 694 | onClick={() => | ||
| 695 | void onRespondToApproval(approval.requestId, "decline") | ||
| 696 | } | ||
| 697 | > | ||
| 698 | Decline | ||
| 699 | </button> | ||
| 700 | <button | ||
| 701 | type="button" | ||
| 702 | className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 703 | disabled={isResponding} | ||
| 704 | onClick={() => | ||
| 705 | void onRespondToApproval(approval.requestId, "cancel") | ||
| 706 | } | ||
| 707 | > | ||
| 708 | Cancel turn | ||
| 709 | </button> | ||
| 710 | </div> | ||
| 711 | </div> | ||
| 712 | ); | ||
| 713 | })} | ||
| 714 | </div> | ||
| 715 | )} | ||
| 716 | |||
| 717 | {/* Messages */} | ||
| 718 | <div className="flex-1 overflow-y-auto px-5 py-4"> | ||
| 719 | {activeThread.messages.length === 0 && !isWorking ? ( | ||
| 720 | <div className="flex h-full items-center justify-center"> | ||
| 721 | <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p> | ||
| 722 | </div> | ||
| 723 | ) : ( | ||
| 724 | <div className="mx-auto max-w-3xl space-y-4"> | ||
| 725 | {timelineEntries.map((timelineEntry, index) => { | ||
| 726 | if ( | ||
| 727 | timelineEntry.kind === "work" && | ||
| 728 | timelineEntries[index - 1]?.kind === "work" | ||
| 729 | ) { | ||
| 730 | return null; | ||
| 731 | } | ||
| 732 | |||
| 733 | const showCompletionDivider = | ||
| 734 | timelineEntry.kind === "message" && | ||
| 735 | timelineEntry.message.role === "assistant" && | ||
| 736 | completionDividerBeforeEntryId === timelineEntry.id; | ||
| 737 | |||
| 738 | if (timelineEntry.kind === "work") { | ||
| 739 | const groupedEntries = [timelineEntry.entry]; | ||
| 740 | let cursor = index + 1; | ||
| 741 | while (cursor < timelineEntries.length) { | ||
| 742 | const nextEntry = timelineEntries[cursor]; | ||
| 743 | if (!nextEntry || nextEntry.kind !== "work") break; | ||
| 744 | groupedEntries.push(nextEntry.entry); | ||
| 745 | cursor += 1; | ||
| 746 | } | ||
| 747 | |||
| 748 | const groupId = timelineEntry.id; | ||
| 749 | const isExpanded = expandedWorkGroups[groupId] ?? false; | ||
| 750 | const hasOverflow = | ||
| 751 | groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 752 | const visibleEntries = | ||
| 753 | hasOverflow && !isExpanded | ||
| 754 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | ||
| 755 | : groupedEntries; | ||
| 756 | const hiddenCount = groupedEntries.length - visibleEntries.length; | ||
| 757 | const onlyToolEntries = groupedEntries.every( | ||
| 758 | (entry) => entry.tone === "tool", | ||
| 759 | ); | ||
| 760 | const groupLabel = onlyToolEntries | ||
| 761 | ? groupedEntries.length === 1 | ||
| 762 | ? "Tool call" | ||
| 763 | : `Tool calls (${groupedEntries.length})` | ||
| 764 | : groupedEntries.length === 1 | ||
| 765 | ? "Work event" | ||
| 766 | : `Work log (${groupedEntries.length})`; | ||
| 767 | |||
| 768 | return ( | ||
| 769 | <Fragment key={timelineEntry.id}> | ||
| 770 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | ||
| 771 | <div className="mb-1.5 flex items-center justify-between gap-3"> | ||
| 772 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | ||
| 773 | {groupLabel} | ||
| 774 | </p> | ||
| 775 | {hasOverflow && ( | ||
| 776 | <button | ||
| 777 | type="button" | ||
| 778 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | ||
| 779 | onClick={() => | ||
| 780 | setExpandedWorkGroups((existing) => ({ | ||
| 781 | ...existing, | ||
| 782 | [groupId]: !existing[groupId], | ||
| 783 | })) | ||
| 784 | } | ||
| 785 | > | ||
| 786 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | ||
| 787 | </button> | ||
| 788 | )} | ||
| 789 | </div> | ||
| 790 | <div className="space-y-1"> | ||
| 791 | {visibleEntries.map((workEntry) => ( | ||
| 792 | <div | ||
| 793 | key={`work-row:${workEntry.id}`} | ||
| 794 | className="flex items-start gap-2 py-0.5" | ||
| 795 | > | ||
| 796 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 797 | <p | ||
| 798 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | ||
| 799 | > | ||
| 800 | {workEntry.detail ? ( | ||
| 801 | <> | ||
| 802 | {workEntry.label} | ||
| 803 | <span | ||
| 804 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | ||
| 805 | title={workEntry.detail} | ||
| 806 | > | ||
| 807 | {workEntry.detail} | ||
| 808 | </span> | ||
| 809 | </> | ||
| 810 | ) : ( | ||
| 811 | workEntry.label | ||
| 812 | )} | ||
| 813 | </p> | ||
| 814 | </div> | ||
| 815 | ))} | ||
| 816 | </div> | ||
| 817 | </div> | ||
| 818 | </Fragment> | ||
| 819 | ); | ||
| 820 | } | ||
| 821 | |||
| 822 | if (timelineEntry.message.role === "user") { | ||
| 823 | return ( | ||
| 824 | <Fragment key={timelineEntry.id}> | ||
| 825 | <div className="flex justify-end"> | ||
| 826 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | ||
| 827 | <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground"> | ||
| 828 | {timelineEntry.message.text} | ||
| 829 | </pre> | ||
| 830 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | ||
| 831 | {formatTimestamp(timelineEntry.message.createdAt)} | ||
| 832 | </p> | ||
| 833 | </div> | ||
| 834 | </div> | ||
| 835 | </Fragment> | ||
| 836 | ); | ||
| 837 | } | ||
| 838 | |||
| 839 | return ( | ||
| 840 | <Fragment key={timelineEntry.id}> | ||
| 841 | {showCompletionDivider && ( | ||
| 842 | <div className="my-3 flex items-center gap-3"> | ||
| 843 | <span className="h-px flex-1 bg-border" /> | ||
| 844 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | ||
| 845 | {completionSummary | ||
| 846 | ? `Response • ${completionSummary}` | ||
| 847 | : "Response"} | ||
| 848 | </span> | ||
| 849 | <span className="h-px flex-1 bg-border" /> | ||
| 850 | </div> | ||
| 851 | )} | ||
| 852 | <div className="px-1 py-0.5"> | ||
| 853 | <ChatMarkdown | ||
| 854 | text={ | ||
| 855 | timelineEntry.message.text || | ||
| 856 | (timelineEntry.message.streaming | ||
| 857 | ? "" | ||
| 858 | : "(empty response)") | ||
| 859 | } | ||
| 860 | /> | ||
| 861 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | ||
| 862 | {formatMessageMeta( | ||
| 863 | timelineEntry.message.createdAt, | ||
| 864 | timelineEntry.message.streaming | ||
| 865 | ? formatElapsed( | ||
| 866 | timelineEntry.message.createdAt, | ||
| 867 | nowIso, | ||
| 868 | ) | ||
| 869 | : formatElapsed( | ||
| 870 | timelineEntry.message.createdAt, | ||
| 871 | assistantCompletionByItemId.get( | ||
| 872 | timelineEntry.message.id, | ||
| 873 | ), | ||
| 874 | ), | ||
| 875 | )} | ||
| 876 | </p> | ||
| 877 | </div> | ||
| 878 | </Fragment> | ||
| 879 | ); | ||
| 880 | })} | ||
| 881 | {isWorking && ( | ||
| 882 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | ||
| 883 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 884 | <div className="flex items-center pt-1"> | ||
| 885 | <span className="inline-flex items-center gap-[3px]"> | ||
| 886 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | ||
| 887 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | ||
| 888 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | ||
| 889 | </span> | ||
| 890 | </div> | ||
| 891 | </div> | ||
| 892 | )} | ||
| 893 | <div ref={messagesEndRef} /> | ||
| 894 | </div> | ||
| 895 | )} | ||
| 896 | </div> | ||
| 897 | |||
| 898 | {/* Input bar */} | ||
| 899 | <div className="px-5 pb-4 pt-2"> | ||
| 900 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> | ||
| 901 | <div className="group rounded-[20px] border border-border bg-card transition-colors duration-200 focus-within:border-ring"> | ||
| 902 | {/* Textarea area */} | ||
| 903 | <div className="px-4 pt-4 pb-2"> | ||
| 904 | <textarea | ||
| 905 | ref={textareaRef} | ||
| 906 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" | ||
| 907 | rows={2} | ||
| 908 | value={prompt} | ||
| 909 | onChange={(e) => setPrompt(e.target.value)} | ||
| 910 | onKeyDown={onKeyDown} | ||
| 911 | placeholder={ | ||
| 912 | phase === "disconnected" ? "Ask for follow-up changes" : "Ask anything..." | ||
| 913 | } | ||
| 914 | disabled={isSending || isConnecting} | ||
| 915 | /> | ||
| 916 | </div> | ||
| 917 | |||
| 918 | {/* Bottom toolbar */} | ||
| 919 | <div className="flex items-center justify-between px-3 pb-3"> | ||
| 920 | <div className="flex items-center gap-1"> | ||
| 921 | {/* Model picker */} | ||
| 922 | <div className="relative" ref={modelMenuRef}> | ||
| 923 | <button | ||
| 924 | type="button" | ||
| 925 | className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80" | ||
| 926 | onClick={() => setIsModelMenuOpen((open) => !open)} | ||
| 927 | > | ||
| 928 | <span className="max-w-[180px] truncate">{selectedModel}</span> | ||
| 929 | <svg | ||
| 930 | width="10" | ||
| 931 | height="10" | ||
| 932 | viewBox="0 0 10 10" | ||
| 933 | fill="none" | ||
| 934 | className="opacity-50" | ||
| 935 | aria-hidden="true" | ||
| 936 | > | ||
| 937 | <path | ||
| 938 | d="M2.5 4L5 6.5L7.5 4" | ||
| 939 | stroke="currentColor" | ||
| 940 | strokeWidth="1.2" | ||
| 941 | strokeLinecap="round" | ||
| 942 | strokeLinejoin="round" | ||
| 943 | /> | ||
| 944 | </svg> | ||
| 945 | </button> | ||
| 946 | {isModelMenuOpen && ( | ||
| 947 | <div className="absolute bottom-full left-0 z-20 mb-2 w-[320px] rounded-2xl border border-border bg-popover/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur"> | ||
| 948 | <p className="px-2 py-1 text-[11px] text-muted-foreground/70">Select model</p> | ||
| 949 | <div className="max-h-72 overflow-y-auto"> | ||
| 950 | {modelOptions.map((model) => { | ||
| 951 | const isSelected = model === selectedModel; | ||
| 952 | return ( | ||
| 953 | <button | ||
| 954 | key={model} | ||
| 955 | type="button" | ||
| 956 | className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-2 text-left font-mono text-sm transition-colors duration-150 ${ | ||
| 957 | isSelected | ||
| 958 | ? "bg-accent text-foreground" | ||
| 959 | : "text-foreground/90 hover:bg-accent" | ||
| 960 | }`} | ||
| 961 | onClick={() => onModelSelect(model)} | ||
| 962 | > | ||
| 963 | <span className="truncate">{model}</span> | ||
| 964 | <span | ||
| 965 | className={`pt-0.5 text-sm ${ | ||
| 966 | isSelected ? "text-foreground" : "text-transparent" | ||
| 967 | }`} | ||
| 968 | > | ||
| 969 | ✓ | ||
| 970 | </span> | ||
| 971 | </button> | ||
| 972 | ); | ||
| 973 | })} | ||
| 974 | </div> | ||
| 975 | </div> | ||
| 976 | )} | ||
| 977 | </div> | ||
| 978 | |||
| 979 | {/* Divider */} | ||
| 980 | <div className="mx-0.5 h-4 w-px bg-border" /> | ||
| 981 | |||
| 982 | {/* Reasoning effort */} | ||
| 983 | <label | ||
| 984 | className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80" | ||
| 985 | htmlFor="reasoning-effort" | ||
| 986 | > | ||
| 987 | <span>{selectedEffort.charAt(0).toUpperCase() + selectedEffort.slice(1)}</span> | ||
| 988 | <select | ||
| 989 | id="reasoning-effort" | ||
| 990 | className="absolute opacity-0 w-0 h-0" | ||
| 991 | value={selectedEffort} | ||
| 992 | onChange={(event) => setSelectedEffort(event.target.value)} | ||
| 993 | > | ||
| 994 | {REASONING_OPTIONS.map((effort) => ( | ||
| 995 | <option key={effort} value={effort} className="bg-popover"> | ||
| 996 | {effort} | ||
| 997 | {effort === DEFAULT_REASONING ? " (default)" : ""} | ||
| 998 | </option> | ||
| 999 | ))} | ||
| 1000 | </select> | ||
| 1001 | <svg | ||
| 1002 | width="10" | ||
| 1003 | height="10" | ||
| 1004 | viewBox="0 0 10 10" | ||
| 1005 | fill="none" | ||
| 1006 | className="opacity-50" | ||
| 1007 | aria-hidden="true" | ||
| 1008 | > | ||
| 1009 | <path | ||
| 1010 | d="M2.5 4L5 6.5L7.5 4" | ||
| 1011 | stroke="currentColor" | ||
| 1012 | strokeWidth="1.2" | ||
| 1013 | strokeLinecap="round" | ||
| 1014 | strokeLinejoin="round" | ||
| 1015 | /> | ||
| 1016 | </svg> | ||
| 1017 | </label> | ||
| 1018 | |||
| 1019 | {/* Divider */} | ||
| 1020 | <div className="mx-0.5 h-4 w-px bg-border" /> | ||
| 1021 | |||
| 1022 | {/* Runtime mode toggle */} | ||
| 1023 | <button | ||
| 1024 | type="button" | ||
| 1025 | className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80" | ||
| 1026 | disabled={isSwitchingRuntimeMode} | ||
| 1027 | onClick={() => | ||
| 1028 | void handleRuntimeModeChange( | ||
| 1029 | state.runtimeMode === "full-access" | ||
| 1030 | ? "approval-required" | ||
| 1031 | : "full-access", | ||
| 1032 | ) | ||
| 1033 | } | ||
| 1034 | title={ | ||
| 1035 | state.runtimeMode === "full-access" | ||
| 1036 | ? "Full access — click to require approvals" | ||
| 1037 | : "Approval required — click for full access" | ||
| 1038 | } | ||
| 1039 | > | ||
| 1040 | {state.runtimeMode === "full-access" ? ( | ||
| 1041 | <svg | ||
| 1042 | width="14" | ||
| 1043 | height="14" | ||
| 1044 | viewBox="0 0 14 14" | ||
| 1045 | fill="none" | ||
| 1046 | aria-hidden="true" | ||
| 1047 | > | ||
| 1048 | <rect | ||
| 1049 | x="2" | ||
| 1050 | y="5.5" | ||
| 1051 | width="10" | ||
| 1052 | height="7" | ||
| 1053 | rx="1.5" | ||
| 1054 | stroke="currentColor" | ||
| 1055 | strokeWidth="1.3" | ||
| 1056 | /> | ||
| 1057 | <path | ||
| 1058 | d="M9.5 5.5V4a2.5 2.5 0 0 0-5 0" | ||
| 1059 | stroke="currentColor" | ||
| 1060 | strokeWidth="1.3" | ||
| 1061 | strokeLinecap="round" | ||
| 1062 | /> | ||
| 1063 | </svg> | ||
| 1064 | ) : ( | ||
| 1065 | <svg | ||
| 1066 | width="14" | ||
| 1067 | height="14" | ||
| 1068 | viewBox="0 0 14 14" | ||
| 1069 | fill="none" | ||
| 1070 | aria-hidden="true" | ||
| 1071 | > | ||
| 1072 | <rect | ||
| 1073 | x="2" | ||
| 1074 | y="5.5" | ||
| 1075 | width="10" | ||
| 1076 | height="7" | ||
| 1077 | rx="1.5" | ||
| 1078 | stroke="currentColor" | ||
| 1079 | strokeWidth="1.3" | ||
| 1080 | /> | ||
| 1081 | <path | ||
| 1082 | d="M4.5 5.5V4a2.5 2.5 0 0 1 5 0v1.5" | ||
| 1083 | stroke="currentColor" | ||
| 1084 | strokeWidth="1.3" | ||
| 1085 | strokeLinecap="round" | ||
| 1086 | /> | ||
| 1087 | </svg> | ||
| 1088 | )} | ||
| 1089 | <span> | ||
| 1090 | {state.runtimeMode === "full-access" | ||
| 1091 | ? "Full access" | ||
| 1092 | : "Supervised"} | ||
| 1093 | </span> | ||
| 1094 | </button> | ||
| 1095 | </div> | ||
| 1096 | |||
| 1097 | {/* Right side: send / stop button */} | ||
| 1098 | <div className="flex items-center gap-2"> | ||
| 1099 | {phase === "running" ? ( | ||
| 1100 | <button | ||
| 1101 | type="button" | ||
| 1102 | className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105" | ||
| 1103 | onClick={() => void onInterrupt()} | ||
| 1104 | aria-label="Stop generation" | ||
| 1105 | > | ||
| 1106 | <svg | ||
| 1107 | width="12" | ||
| 1108 | height="12" | ||
| 1109 | viewBox="0 0 12 12" | ||
| 1110 | fill="currentColor" | ||
| 1111 | aria-hidden="true" | ||
| 1112 | > | ||
| 1113 | <rect x="2" y="2" width="8" height="8" rx="1.5" /> | ||
| 1114 | </svg> | ||
| 1115 | </button> | ||
| 1116 | ) : ( | ||
| 1117 | <button | ||
| 1118 | type="submit" | ||
| 1119 | className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100" | ||
| 1120 | disabled={isSending || isConnecting || !prompt.trim()} | ||
| 1121 | aria-label={ | ||
| 1122 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" | ||
| 1123 | } | ||
| 1124 | > | ||
| 1125 | {isConnecting || isSending ? ( | ||
| 1126 | <svg | ||
| 1127 | width="14" | ||
| 1128 | height="14" | ||
| 1129 | viewBox="0 0 14 14" | ||
| 1130 | fill="none" | ||
| 1131 | className="animate-spin" | ||
| 1132 | aria-hidden="true" | ||
| 1133 | > | ||
| 1134 | <circle | ||
| 1135 | cx="7" | ||
| 1136 | cy="7" | ||
| 1137 | r="5.5" | ||
| 1138 | stroke="currentColor" | ||
| 1139 | strokeWidth="1.5" | ||
| 1140 | strokeLinecap="round" | ||
| 1141 | strokeDasharray="20 12" | ||
| 1142 | /> | ||
| 1143 | </svg> | ||
| 1144 | ) : ( | ||
| 1145 | <svg | ||
| 1146 | width="14" | ||
| 1147 | height="14" | ||
| 1148 | viewBox="0 0 14 14" | ||
| 1149 | fill="none" | ||
| 1150 | aria-hidden="true" | ||
| 1151 | > | ||
| 1152 | <path | ||
| 1153 | d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" | ||
| 1154 | stroke="currentColor" | ||
| 1155 | strokeWidth="1.8" | ||
| 1156 | strokeLinecap="round" | ||
| 1157 | strokeLinejoin="round" | ||
| 1158 | /> | ||
| 1159 | </svg> | ||
| 1160 | )} | ||
| 1161 | </button> | ||
| 1162 | )} | ||
| 1163 | </div> | ||
| 1164 | </div> | ||
| 1165 | </div> | ||
| 1166 | </form> | ||
| 1167 | </div> | ||
| 1168 | </div> | ||
| 1169 | ); | ||
| 1170 | } | ||
apps/renderer/src/components/DiffPanel.tsx deleted-53| ... | @@ -1,53 +0,0 @@ | ||
| 1 | import { isElectron } from "../env"; | ||
| 2 | import { useStore } from "../store"; | ||
| 3 | |||
| 4 | export default function DiffPanel() { | ||
| 5 | const { dispatch } = useStore(); | ||
| 6 | |||
| 7 | return ( | ||
| 8 | <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card"> | ||
| 9 | {/* Header */} | ||
| 10 | <div className={`flex items-center justify-between border-b border-border px-4 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}> | ||
| 11 | <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3> | ||
| 12 | <button | ||
| 13 | type="button" | ||
| 14 | className="text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground" | ||
| 15 | onClick={() => dispatch({ type: "TOGGLE_DIFF" })} | ||
| 16 | > | ||
| 17 | <span className="text-sm">&times;</span> | ||
| 18 | </button> | ||
| 19 | </div> | ||
| 20 | |||
| 21 | {/* Placeholder content */} | ||
| 22 | <div className="flex-1 overflow-y-auto p-4"> | ||
| 23 | <div className="space-y-3"> | ||
| 24 | <div className="rounded-lg border border-border/80 bg-muted p-3"> | ||
| 25 | <div className="mb-2 flex items-center gap-2"> | ||
| 26 | <span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400"> | ||
| 27 | M | ||
| 28 | </span> | ||
| 29 | <span className="font-mono text-xs text-muted-foreground/80">src/App.tsx</span> | ||
| 30 | </div> | ||
| 31 | <div className="space-y-0.5 font-mono text-[11px]"> | ||
| 32 | <div className="text-red-400/60">- const oldValue = "hello";</div> | ||
| 33 | <div className="text-emerald-400/60">+ const newValue = "world";</div> | ||
| 34 | </div> | ||
| 35 | </div> | ||
| 36 | |||
| 37 | <div className="rounded-lg border border-border/80 bg-muted p-3"> | ||
| 38 | <div className="flex items-center gap-2"> | ||
| 39 | <span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-medium text-blue-400"> | ||
| 40 | A | ||
| 41 | </span> | ||
| 42 | <span className="font-mono text-xs text-muted-foreground/80">src/utils.ts</span> | ||
| 43 | </div> | ||
| 44 | </div> | ||
| 45 | </div> | ||
| 46 | |||
| 47 | <p className="mt-6 text-center text-[10px] text-muted-foreground/30"> | ||
| 48 | Diff integration coming soon | ||
| 49 | </p> | ||
| 50 | </div> | ||
| 51 | </aside> | ||
| 52 | ); | ||
| 53 | } | ||
apps/renderer/src/components/Sidebar.tsx deleted-390| ... | @@ -1,390 +0,0 @@ | ||
| 1 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; | ||
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 3 | import { isElectron } from "../env"; | ||
| 4 | import { useTheme } from "../hooks/useTheme"; | ||
| 5 | import { DEFAULT_MODEL } from "../model-logic"; | ||
| 6 | import { readNativeApi } from "../session-logic"; | ||
| 7 | import { useStore } from "../store"; | ||
| 8 | import type { Project } from "../types"; | ||
| 9 | |||
| 10 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | ||
| 11 | function formatRelativeTime(iso: string): string { | ||
| 12 | const diff = Date.now() - new Date(iso).getTime(); | ||
| 13 | const minutes = Math.floor(diff / 60_000); | ||
| 14 | if (minutes < 1) return "just now"; | ||
| 15 | if (minutes < 60) return `${minutes}m ago`; | ||
| 16 | const hours = Math.floor(minutes / 60); | ||
| 17 | if (hours < 24) return `${hours}h ago`; | ||
| 18 | return `${Math.floor(hours / 24)}d ago`; | ||
| 19 | } | ||
| 20 | |||
| 21 | function inferProjectName(cwd: string): string { | ||
| 22 | const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); | ||
| 23 | const parts = normalized.split("/"); | ||
| 24 | const last = parts[parts.length - 1]; | ||
| 25 | return last && last.length > 0 ? last : "project"; | ||
| 26 | } | ||
| 27 | |||
| 28 | function threadStatusLabel( | ||
| 29 | status: "connecting" | "ready" | "running" | "error" | "closed" | undefined, | ||
| 30 | ): "Working" | "Connecting" | null { | ||
| 31 | if (status === "running") return "Working"; | ||
| 32 | if (status === "connecting") return "Connecting"; | ||
| 33 | return null; | ||
| 34 | } | ||
| 35 | |||
| 36 | export default function Sidebar() { | ||
| 37 | const { state, dispatch } = useStore(); | ||
| 38 | const api = useMemo(() => readNativeApi(), []); | ||
| 39 | const { theme, setTheme } = useTheme(); | ||
| 40 | const [addingProject, setAddingProject] = useState(false); | ||
| 41 | const [newCwd, setNewCwd] = useState(""); | ||
| 42 | const [isPickingFolder, setIsPickingFolder] = useState(false); | ||
| 43 | const [isAddingProject, setIsAddingProject] = useState(false); | ||
| 44 | |||
| 45 | const handleNewThread = useCallback( | ||
| 46 | (projectId: string) => { | ||
| 47 | dispatch({ | ||
| 48 | type: "ADD_THREAD", | ||
| 49 | thread: { | ||
| 50 | id: crypto.randomUUID(), | ||
| 51 | codexThreadId: null, | ||
| 52 | projectId, | ||
| 53 | title: "New thread", | ||
| 54 | model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, | ||
| 55 | session: null, | ||
| 56 | messages: [], | ||
| 57 | events: [], | ||
| 58 | error: null, | ||
| 59 | createdAt: new Date().toISOString(), | ||
| 60 | }, | ||
| 61 | }); | ||
| 62 | }, | ||
| 63 | [dispatch, state.projects], | ||
| 64 | ); | ||
| 65 | |||
| 66 | const focusMostRecentThreadForProject = useCallback( | ||
| 67 | (projectId: string) => { | ||
| 68 | const latestThread = state.threads | ||
| 69 | .filter((thread) => thread.projectId === projectId) | ||
| 70 | .toSorted((a, b) => { | ||
| 71 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | ||
| 72 | if (byDate !== 0) return byDate; | ||
| 73 | return b.id.localeCompare(a.id); | ||
| 74 | })[0]; | ||
| 75 | if (!latestThread) return; | ||
| 76 | |||
| 77 | dispatch({ | ||
| 78 | type: "SET_ACTIVE_THREAD", | ||
| 79 | threadId: latestThread.id, | ||
| 80 | }); | ||
| 81 | }, | ||
| 82 | [dispatch, state.threads], | ||
| 83 | ); | ||
| 84 | |||
| 85 | const addProjectFromPath = useCallback( | ||
| 86 | async (rawCwd: string) => { | ||
| 87 | const cwd = rawCwd.trim(); | ||
| 88 | if (!cwd || isAddingProject) return; | ||
| 89 | |||
| 90 | setIsAddingProject(true); | ||
| 91 | try { | ||
| 92 | if (isElectron && api) { | ||
| 93 | const result = await api.projects.add({ cwd }); | ||
| 94 | const project: Project = { | ||
| 95 | id: result.project.id, | ||
| 96 | name: result.project.name, | ||
| 97 | cwd: result.project.cwd, | ||
| 98 | model: DEFAULT_MODEL, | ||
| 99 | expanded: true, | ||
| 100 | }; | ||
| 101 | const existingById = state.projects.find((p) => p.id === project.id); | ||
| 102 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); | ||
| 103 | if (!existingById && !existingByCwd) { | ||
| 104 | dispatch({ type: "ADD_PROJECT", project }); | ||
| 105 | } | ||
| 106 | const resolvedProjectId = existingByCwd?.id ?? project.id; | ||
| 107 | |||
| 108 | if (result.created) { | ||
| 109 | handleNewThread(resolvedProjectId); | ||
| 110 | } else { | ||
| 111 | focusMostRecentThreadForProject(resolvedProjectId); | ||
| 112 | } | ||
| 113 | } else { | ||
| 114 | const existing = state.projects.find((project) => project.cwd === cwd); | ||
| 115 | if (existing) { | ||
| 116 | focusMostRecentThreadForProject(existing.id); | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | |||
| 120 | const name = inferProjectName(cwd); | ||
| 121 | const project: Project = { | ||
| 122 | id: crypto.randomUUID(), | ||
| 123 | name, | ||
| 124 | cwd, | ||
| 125 | model: DEFAULT_MODEL, | ||
| 126 | expanded: true, | ||
| 127 | }; | ||
| 128 | dispatch({ type: "ADD_PROJECT", project }); | ||
| 129 | handleNewThread(project.id); | ||
| 130 | } | ||
| 131 | } finally { | ||
| 132 | setIsAddingProject(false); | ||
| 133 | setNewCwd(""); | ||
| 134 | setAddingProject(false); | ||
| 135 | } | ||
| 136 | }, | ||
| 137 | [ | ||
| 138 | api, | ||
| 139 | dispatch, | ||
| 140 | focusMostRecentThreadForProject, | ||
| 141 | handleNewThread, | ||
| 142 | isAddingProject, | ||
| 143 | state.projects, | ||
| 144 | ], | ||
| 145 | ); | ||
| 146 | |||
| 147 | const handleAddProject = () => { | ||
| 148 | void addProjectFromPath(newCwd); | ||
| 149 | }; | ||
| 150 | |||
| 151 | const handlePickFolder = async () => { | ||
| 152 | if (!api || isPickingFolder) return; | ||
| 153 | setIsPickingFolder(true); | ||
| 154 | try { | ||
| 155 | const pickedPath = await api.dialogs.pickFolder(); | ||
| 156 | if (!pickedPath) return; | ||
| 157 | await addProjectFromPath(pickedPath); | ||
| 158 | } finally { | ||
| 159 | setIsPickingFolder(false); | ||
| 160 | } | ||
| 161 | }; | ||
| 162 | |||
| 163 | useEffect(() => { | ||
| 164 | const onWindowKeyDown = (event: KeyboardEvent) => { | ||
| 165 | const isNewThreadShortcut = | ||
| 166 | event.metaKey && | ||
| 167 | event.shiftKey && | ||
| 168 | !event.ctrlKey && | ||
| 169 | !event.altKey && | ||
| 170 | event.key.toLowerCase() === "o"; | ||
| 171 | if (!isNewThreadShortcut) return; | ||
| 172 | |||
| 173 | const target = event.target; | ||
| 174 | if (target instanceof HTMLElement) { | ||
| 175 | const tagName = target.tagName; | ||
| 176 | if ( | ||
| 177 | target.isContentEditable || | ||
| 178 | tagName === "INPUT" || | ||
| 179 | tagName === "TEXTAREA" || | ||
| 180 | tagName === "SELECT" | ||
| 181 | ) { | ||
| 182 | return; | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | ||
| 187 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | ||
| 188 | if (!projectId) return; | ||
| 189 | |||
| 190 | event.preventDefault(); | ||
| 191 | handleNewThread(projectId); | ||
| 192 | }; | ||
| 193 | |||
| 194 | window.addEventListener("keydown", onWindowKeyDown); | ||
| 195 | return () => { | ||
| 196 | window.removeEventListener("keydown", onWindowKeyDown); | ||
| 197 | }; | ||
| 198 | }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); | ||
| 199 | |||
| 200 | return ( | ||
| 201 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> | ||
| 202 | {/* Drag region / traffic light space (Electron only) */} | ||
| 203 | {isElectron && <div className="drag-region h-[52px] shrink-0" />} | ||
| 204 | {/* Branding */} | ||
| 205 | <div className={`flex items-center gap-2.5 px-4 pb-4 ${isElectron ? "" : "pt-4"}`}> | ||
| 206 | <span className="flex-1 text-sm font-semibold tracking-tight text-foreground"> | ||
| 207 | T3 <span className="font-normal text-muted-foreground">Code</span> | ||
| 208 | </span> | ||
| 209 | <button | ||
| 210 | type="button" | ||
| 211 | className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground" | ||
| 212 | onClick={() => setTheme(THEME_CYCLE[theme])} | ||
| 213 | aria-label={`Theme: ${theme}`} | ||
| 214 | title={`Theme: ${theme}`} | ||
| 215 | > | ||
| 216 | {theme === "system" ? ( | ||
| 217 | <MonitorIcon className="size-3.5" /> | ||
| 218 | ) : theme === "light" ? ( | ||
| 219 | <SunIcon className="size-3.5" /> | ||
| 220 | ) : ( | ||
| 221 | <MoonIcon className="size-3.5" /> | ||
| 222 | )} | ||
| 223 | </button> | ||
| 224 | </div> | ||
| 225 | |||
| 226 | {/* New thread (global) */} | ||
| 227 | <div className="px-3 pb-3"> | ||
| 228 | <button | ||
| 229 | type="button" | ||
| 230 | className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" | ||
| 231 | onClick={() => { | ||
| 232 | if (state.projects.length === 0) { | ||
| 233 | setAddingProject(true); | ||
| 234 | return; | ||
| 235 | } | ||
| 236 | const firstProject = state.projects[0]; | ||
| 237 | if (firstProject) handleNewThread(firstProject.id); | ||
| 238 | }} | ||
| 239 | > | ||
| 240 | <span className="text-foreground">+</span> | ||
| 241 | New thread | ||
| 242 | </button> | ||
| 243 | </div> | ||
| 244 | |||
| 245 | {/* Project list */} | ||
| 246 | <nav className="flex-1 overflow-y-auto px-2"> | ||
| 247 | {state.projects.map((project) => { | ||
| 248 | const threads = state.threads | ||
| 249 | .filter((t) => t.projectId === project.id) | ||
| 250 | .toSorted((a, b) => { | ||
| 251 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | ||
| 252 | if (byDate !== 0) return byDate; | ||
| 253 | return b.id.localeCompare(a.id); | ||
| 254 | }); | ||
| 255 | return ( | ||
| 256 | <div key={project.id} className="mb-1"> | ||
| 257 | {/* Project header */} | ||
| 258 | <button | ||
| 259 | type="button" | ||
| 260 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" | ||
| 261 | onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} | ||
| 262 | > | ||
| 263 | <span className="text-[10px] text-muted-foreground/70"> | ||
| 264 | {project.expanded ? "â–¼" : "â–¶"} | ||
| 265 | </span> | ||
| 266 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | ||
| 267 | {project.name} | ||
| 268 | </span> | ||
| 269 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | ||
| 270 | </button> | ||
| 271 | |||
| 272 | {/* Threads */} | ||
| 273 | {project.expanded && ( | ||
| 274 | <div className="ml-2 border-l border-border/80 pl-2"> | ||
| 275 | {threads.map((thread) => { | ||
| 276 | const isActive = state.activeThreadId === thread.id; | ||
| 277 | const threadStatus = threadStatusLabel(thread.session?.status); | ||
| 278 | return ( | ||
| 279 | <button | ||
| 280 | key={thread.id} | ||
| 281 | type="button" | ||
| 282 | className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${ | ||
| 283 | isActive | ||
| 284 | ? "bg-accent text-foreground" | ||
| 285 | : "text-muted-foreground hover:bg-secondary" | ||
| 286 | }`} | ||
| 287 | onClick={() => | ||
| 288 | dispatch({ | ||
| 289 | type: "SET_ACTIVE_THREAD", | ||
| 290 | threadId: thread.id, | ||
| 291 | }) | ||
| 292 | } | ||
| 293 | > | ||
| 294 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | ||
| 295 | {threadStatus && ( | ||
| 296 | <span className="inline-flex items-center gap-1 text-[10px] text-sky-600 dark:text-sky-300/80"> | ||
| 297 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-500 dark:bg-sky-300/80" /> | ||
| 298 | <span className="hidden xl:inline">{threadStatus}</span> | ||
| 299 | </span> | ||
| 300 | )} | ||
| 301 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | ||
| 302 | </div> | ||
| 303 | <span className="shrink-0 text-[10px] text-muted-foreground/40"> | ||
| 304 | {formatRelativeTime(thread.createdAt)} | ||
| 305 | </span> | ||
| 306 | </button> | ||
| 307 | ); | ||
| 308 | })} | ||
| 309 | |||
| 310 | {/* New thread within project */} | ||
| 311 | <button | ||
| 312 | type="button" | ||
| 313 | className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80" | ||
| 314 | onClick={() => handleNewThread(project.id)} | ||
| 315 | > | ||
| 316 | <span>+</span> New thread | ||
| 317 | </button> | ||
| 318 | </div> | ||
| 319 | )} | ||
| 320 | </div> | ||
| 321 | ); | ||
| 322 | })} | ||
| 323 | |||
| 324 | {state.projects.length === 0 && !addingProject && ( | ||
| 325 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | ||
| 326 | No projects yet. | ||
| 327 | <br /> | ||
| 328 | Add one to get started. | ||
| 329 | </div> | ||
| 330 | )} | ||
| 331 | </nav> | ||
| 332 | |||
| 333 | {/* Add project form */} | ||
| 334 | {addingProject ? ( | ||
| 335 | <div className="border-t border-border p-3"> | ||
| 336 | <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | ||
| 337 | Add project | ||
| 338 | </p> | ||
| 339 | <input | ||
| 340 | className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | ||
| 341 | placeholder="/path/to/project" | ||
| 342 | value={newCwd} | ||
| 343 | onChange={(e) => setNewCwd(e.target.value)} | ||
| 344 | onKeyDown={(e) => { | ||
| 345 | if (e.key === "Enter") handleAddProject(); | ||
| 346 | if (e.key === "Escape") setAddingProject(false); | ||
| 347 | }} | ||
| 348 | /> | ||
| 349 | {isElectron && api && ( | ||
| 350 | <button | ||
| 351 | type="button" | ||
| 352 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | ||
| 353 | onClick={() => void handlePickFolder()} | ||
| 354 | disabled={isPickingFolder || isAddingProject} | ||
| 355 | > | ||
| 356 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | ||
| 357 | </button> | ||
| 358 | )} | ||
| 359 | <div className="flex gap-2"> | ||
| 360 | <button | ||
| 361 | type="button" | ||
| 362 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" | ||
| 363 | onClick={handleAddProject} | ||
| 364 | disabled={isAddingProject} | ||
| 365 | > | ||
| 366 | {isAddingProject ? "Adding..." : "Add"} | ||
| 367 | </button> | ||
| 368 | <button | ||
| 369 | type="button" | ||
| 370 | className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | ||
| 371 | onClick={() => setAddingProject(false)} | ||
| 372 | > | ||
| 373 | Cancel | ||
| 374 | </button> | ||
| 375 | </div> | ||
| 376 | </div> | ||
| 377 | ) : ( | ||
| 378 | <div className="border-t border-border p-3"> | ||
| 379 | <button | ||
| 380 | type="button" | ||
| 381 | className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground" | ||
| 382 | onClick={() => setAddingProject(true)} | ||
| 383 | > | ||
| 384 | + Add project | ||
| 385 | </button> | ||
| 386 | </div> | ||
| 387 | )} | ||
| 388 | </aside> | ||
| 389 | ); | ||
| 390 | } | ||
apps/renderer/src/env.ts deleted-8| ... | @@ -1,8 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * True when running inside the Electron preload bridge, false in a regular browser. | ||
| 3 | * The preload script sets window.nativeApi via contextBridge before any renderer | ||
| 4 | * code executes, so this is reliable at module load time. | ||
| 5 | */ | ||
| 6 | export const isElectron = | ||
| 7 | typeof window !== "undefined" && | ||
| 8 | (window.desktopBridge !== undefined || window.nativeApi !== undefined); | ||
apps/renderer/src/historyBootstrap.test.ts deleted-94| ... | @@ -1,94 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { buildBootstrapInput } from "./historyBootstrap"; | ||
| 4 | |||
| 5 | describe("buildBootstrapInput", () => { | ||
| 6 | it("includes full transcript when under budget", () => { | ||
| 7 | const result = buildBootstrapInput( | ||
| 8 | [ | ||
| 9 | { | ||
| 10 | id: "u-1", | ||
| 11 | role: "user", | ||
| 12 | text: "hello", | ||
| 13 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 14 | streaming: false, | ||
| 15 | }, | ||
| 16 | { | ||
| 17 | id: "a-1", | ||
| 18 | role: "assistant", | ||
| 19 | text: "world", | ||
| 20 | createdAt: "2026-02-09T00:00:01.000Z", | ||
| 21 | streaming: false, | ||
| 22 | }, | ||
| 23 | ], | ||
| 24 | "what's next?", | ||
| 25 | 1_500, | ||
| 26 | ); | ||
| 27 | |||
| 28 | expect(result.includedCount).toBe(2); | ||
| 29 | expect(result.omittedCount).toBe(0); | ||
| 30 | expect(result.truncated).toBe(false); | ||
| 31 | expect(result.text).toContain("USER:\nhello"); | ||
| 32 | expect(result.text).toContain("ASSISTANT:\nworld"); | ||
| 33 | expect(result.text).toContain("Latest user request (answer this now):"); | ||
| 34 | expect(result.text).toContain("what's next?"); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("truncates older transcript messages when over budget", () => { | ||
| 38 | const result = buildBootstrapInput( | ||
| 39 | [ | ||
| 40 | { | ||
| 41 | id: "u-1", | ||
| 42 | role: "user", | ||
| 43 | text: "first question with details", | ||
| 44 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 45 | streaming: false, | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | id: "a-1", | ||
| 49 | role: "assistant", | ||
| 50 | text: "first answer with details", | ||
| 51 | createdAt: "2026-02-09T00:00:01.000Z", | ||
| 52 | streaming: false, | ||
| 53 | }, | ||
| 54 | { | ||
| 55 | id: "u-2", | ||
| 56 | role: "user", | ||
| 57 | text: "second question with details", | ||
| 58 | createdAt: "2026-02-09T00:00:02.000Z", | ||
| 59 | streaming: false, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | "final request", | ||
| 63 | 320, | ||
| 64 | ); | ||
| 65 | |||
| 66 | expect(result.truncated).toBe(true); | ||
| 67 | expect(result.omittedCount).toBeGreaterThan(0); | ||
| 68 | expect(result.includedCount).toBeLessThan(3); | ||
| 69 | expect(result.text).toContain("omitted to stay within input limits"); | ||
| 70 | expect(result.text.length).toBeLessThanOrEqual(320); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("preserves the latest prompt when prompt-only fallback is required", () => { | ||
| 74 | const latestPrompt = "Please keep this exact latest prompt."; | ||
| 75 | const result = buildBootstrapInput( | ||
| 76 | [ | ||
| 77 | { | ||
| 78 | id: "u-1", | ||
| 79 | role: "user", | ||
| 80 | text: "old context", | ||
| 81 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 82 | streaming: false, | ||
| 83 | }, | ||
| 84 | ], | ||
| 85 | latestPrompt, | ||
| 86 | latestPrompt.length + 3, | ||
| 87 | ); | ||
| 88 | |||
| 89 | expect(result.text).toBe(latestPrompt); | ||
| 90 | expect(result.includedCount).toBe(0); | ||
| 91 | expect(result.omittedCount).toBe(1); | ||
| 92 | expect(result.truncated).toBe(true); | ||
| 93 | }); | ||
| 94 | }); | ||
apps/renderer/src/historyBootstrap.ts deleted-120| ... | @@ -1,120 +0,0 @@ | ||
| 1 | import type { ChatMessage } from "./types"; | ||
| 2 | |||
| 3 | export interface BootstrapInputResult { | ||
| 4 | text: string; | ||
| 5 | includedCount: number; | ||
| 6 | omittedCount: number; | ||
| 7 | truncated: boolean; | ||
| 8 | } | ||
| 9 | |||
| 10 | const BOOTSTRAP_PREAMBLE = | ||
| 11 | "Continue this conversation using the transcript context below. The final section is the latest user request to answer now."; | ||
| 12 | const TRANSCRIPT_HEADER = "Transcript context:"; | ||
| 13 | const LATEST_PROMPT_HEADER = "Latest user request (answer this now):"; | ||
| 14 | const OMITTED_SUMMARY = (count: number) => | ||
| 15 | `[${count} earlier message(s) omitted to stay within input limits.]`; | ||
| 16 | |||
| 17 | function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { | ||
| 18 | return message.role === "assistant" ? "ASSISTANT" : "USER"; | ||
| 19 | } | ||
| 20 | |||
| 21 | function buildMessageBlock(message: ChatMessage): string { | ||
| 22 | return `${messageRoleLabel(message)}:\n${message.text}`; | ||
| 23 | } | ||
| 24 | |||
| 25 | function finalizeWithPrompt( | ||
| 26 | transcriptBody: string, | ||
| 27 | latestPrompt: string, | ||
| 28 | maxChars: number, | ||
| 29 | ): string | null { | ||
| 30 | const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`; | ||
| 31 | return text.length <= maxChars ? text : null; | ||
| 32 | } | ||
| 33 | |||
| 34 | export function buildBootstrapInput( | ||
| 35 | previousMessages: ChatMessage[], | ||
| 36 | latestPrompt: string, | ||
| 37 | maxChars: number, | ||
| 38 | ): BootstrapInputResult { | ||
| 39 | const budget = Number.isFinite(maxChars) | ||
| 40 | ? Math.max(1, Math.floor(maxChars)) | ||
| 41 | : 1; | ||
| 42 | const promptOnly = | ||
| 43 | latestPrompt.length <= budget | ||
| 44 | ? latestPrompt | ||
| 45 | : latestPrompt.slice(0, budget); | ||
| 46 | |||
| 47 | if (previousMessages.length === 0) { | ||
| 48 | return { | ||
| 49 | text: promptOnly, | ||
| 50 | includedCount: 0, | ||
| 51 | omittedCount: 0, | ||
| 52 | truncated: promptOnly.length !== latestPrompt.length, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | const newestFirstBlocks: string[] = []; | ||
| 57 | for (let index = previousMessages.length - 1; index >= 0; index -= 1) { | ||
| 58 | const message = previousMessages[index]; | ||
| 59 | if (!message) continue; | ||
| 60 | newestFirstBlocks.push(buildMessageBlock(message)); | ||
| 61 | } | ||
| 62 | |||
| 63 | if (newestFirstBlocks.length === 0) { | ||
| 64 | return { | ||
| 65 | text: promptOnly, | ||
| 66 | includedCount: 0, | ||
| 67 | omittedCount: previousMessages.length, | ||
| 68 | truncated: true, | ||
| 69 | }; | ||
| 70 | } | ||
| 71 | |||
| 72 | // Include a contiguous suffix from newest to oldest, then reverse to chronological. | ||
| 73 | let includedNewestFirst: string[] = []; | ||
| 74 | for (const block of newestFirstBlocks) { | ||
| 75 | const nextNewestFirst = [...includedNewestFirst, block]; | ||
| 76 | const nextChronological = [...nextNewestFirst].reverse(); | ||
| 77 | const omittedCount = newestFirstBlocks.length - nextChronological.length; | ||
| 78 | const transcriptBody = | ||
| 79 | omittedCount > 0 | ||
| 80 | ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}` | ||
| 81 | : nextChronological.join("\n\n"); | ||
| 82 | if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) { | ||
| 83 | break; | ||
| 84 | } | ||
| 85 | includedNewestFirst = nextNewestFirst; | ||
| 86 | } | ||
| 87 | |||
| 88 | let includedChronological = [...includedNewestFirst].reverse(); | ||
| 89 | while (true) { | ||
| 90 | const omittedCount = | ||
| 91 | newestFirstBlocks.length - includedChronological.length; | ||
| 92 | const transcriptBody = | ||
| 93 | omittedCount > 0 | ||
| 94 | ? includedChronological.length > 0 | ||
| 95 | ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}` | ||
| 96 | : OMITTED_SUMMARY(omittedCount) | ||
| 97 | : includedChronological.join("\n\n"); | ||
| 98 | const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget); | ||
| 99 | if (finalized) { | ||
| 100 | return { | ||
| 101 | text: finalized, | ||
| 102 | includedCount: includedChronological.length, | ||
| 103 | omittedCount, | ||
| 104 | truncated: | ||
| 105 | omittedCount > 0 || latestPrompt.length !== promptOnly.length, | ||
| 106 | }; | ||
| 107 | } | ||
| 108 | |||
| 109 | if (includedChronological.length === 0) { | ||
| 110 | return { | ||
| 111 | text: promptOnly, | ||
| 112 | includedCount: 0, | ||
| 113 | omittedCount: previousMessages.length, | ||
| 114 | truncated: true, | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | includedChronological = includedChronological.slice(1); | ||
| 119 | } | ||
| 120 | } | ||
apps/renderer/src/hooks/useTheme.ts deleted-90| ... | @@ -1,90 +0,0 @@ | ||
| 1 | import { useCallback, useEffect, useSyncExternalStore } from "react"; | ||
| 2 | |||
| 3 | type Theme = "light" | "dark" | "system"; | ||
| 4 | |||
| 5 | const STORAGE_KEY = "t3code:theme"; | ||
| 6 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; | ||
| 7 | |||
| 8 | let listeners: Array<() => void> = []; | ||
| 9 | function emitChange() { | ||
| 10 | for (const listener of listeners) listener(); | ||
| 11 | } | ||
| 12 | |||
| 13 | function getSystemDark(): boolean { | ||
| 14 | return window.matchMedia(MEDIA_QUERY).matches; | ||
| 15 | } | ||
| 16 | |||
| 17 | function getStored(): Theme { | ||
| 18 | const raw = localStorage.getItem(STORAGE_KEY); | ||
| 19 | if (raw === "light" || raw === "dark" || raw === "system") return raw; | ||
| 20 | return "system"; | ||
| 21 | } | ||
| 22 | |||
| 23 | function applyTheme(theme: Theme, suppressTransitions = false) { | ||
| 24 | if (suppressTransitions) { | ||
| 25 | document.documentElement.classList.add("no-transitions"); | ||
| 26 | } | ||
| 27 | const isDark = theme === "dark" || (theme === "system" && getSystemDark()); | ||
| 28 | document.documentElement.classList.toggle("dark", isDark); | ||
| 29 | if (suppressTransitions) { | ||
| 30 | // Force a reflow so the no-transitions class takes effect before removal | ||
| 31 | document.documentElement.offsetHeight; | ||
| 32 | requestAnimationFrame(() => { | ||
| 33 | document.documentElement.classList.remove("no-transitions"); | ||
| 34 | }); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | // Apply immediately on module load to prevent flash | ||
| 39 | applyTheme(getStored()); | ||
| 40 | |||
| 41 | function getSnapshot(): Theme { | ||
| 42 | return getStored(); | ||
| 43 | } | ||
| 44 | |||
| 45 | function subscribe(listener: () => void): () => void { | ||
| 46 | listeners.push(listener); | ||
| 47 | |||
| 48 | // Listen for system preference changes | ||
| 49 | const mq = window.matchMedia(MEDIA_QUERY); | ||
| 50 | const handleChange = () => { | ||
| 51 | if (getStored() === "system") applyTheme("system", true); | ||
| 52 | emitChange(); | ||
| 53 | }; | ||
| 54 | mq.addEventListener("change", handleChange); | ||
| 55 | |||
| 56 | // Listen for storage changes from other tabs | ||
| 57 | const handleStorage = (e: StorageEvent) => { | ||
| 58 | if (e.key === STORAGE_KEY) { | ||
| 59 | applyTheme(getStored(), true); | ||
| 60 | emitChange(); | ||
| 61 | } | ||
| 62 | }; | ||
| 63 | window.addEventListener("storage", handleStorage); | ||
| 64 | |||
| 65 | return () => { | ||
| 66 | listeners = listeners.filter((l) => l !== listener); | ||
| 67 | mq.removeEventListener("change", handleChange); | ||
| 68 | window.removeEventListener("storage", handleStorage); | ||
| 69 | }; | ||
| 70 | } | ||
| 71 | |||
| 72 | export function useTheme() { | ||
| 73 | const theme = useSyncExternalStore(subscribe, getSnapshot); | ||
| 74 | |||
| 75 | const resolvedTheme: "light" | "dark" = | ||
| 76 | theme === "system" ? (getSystemDark() ? "dark" : "light") : theme; | ||
| 77 | |||
| 78 | const setTheme = useCallback((next: Theme) => { | ||
| 79 | localStorage.setItem(STORAGE_KEY, next); | ||
| 80 | applyTheme(next, true); | ||
| 81 | emitChange(); | ||
| 82 | }, []); | ||
| 83 | |||
| 84 | // Keep DOM in sync on mount/change | ||
| 85 | useEffect(() => { | ||
| 86 | applyTheme(theme); | ||
| 87 | }, [theme]); | ||
| 88 | |||
| 89 | return { theme, setTheme, resolvedTheme } as const; | ||
| 90 | } | ||
apps/renderer/src/index.css deleted-340| ... | @@ -1,340 +0,0 @@ | ||
| 1 | @import "tailwindcss"; | ||
| 2 | |||
| 3 | @custom-variant dark (&:is(.dark, .dark *)); | ||
| 4 | |||
| 5 | @theme inline { | ||
| 6 | --animate-skeleton: skeleton 2s -1s infinite linear; | ||
| 7 | --color-warning-foreground: var(--warning-foreground); | ||
| 8 | --color-warning: var(--warning); | ||
| 9 | --color-success-foreground: var(--success-foreground); | ||
| 10 | --color-success: var(--success); | ||
| 11 | --color-info-foreground: var(--info-foreground); | ||
| 12 | --color-info: var(--info); | ||
| 13 | --color-destructive-foreground: var(--destructive-foreground); | ||
| 14 | --color-sidebar-ring: var(--sidebar-ring); | ||
| 15 | --color-sidebar-border: var(--sidebar-border); | ||
| 16 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); | ||
| 17 | --color-sidebar-accent: var(--sidebar-accent); | ||
| 18 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); | ||
| 19 | --color-sidebar-primary: var(--sidebar-primary); | ||
| 20 | --color-sidebar-foreground: var(--sidebar-foreground); | ||
| 21 | --color-sidebar: var(--sidebar); | ||
| 22 | --color-chart-5: var(--chart-5); | ||
| 23 | --color-chart-4: var(--chart-4); | ||
| 24 | --color-chart-3: var(--chart-3); | ||
| 25 | --color-chart-2: var(--chart-2); | ||
| 26 | --color-chart-1: var(--chart-1); | ||
| 27 | --color-ring: var(--ring); | ||
| 28 | --color-input: var(--input); | ||
| 29 | --color-border: var(--border); | ||
| 30 | --color-destructive: var(--destructive); | ||
| 31 | --color-accent-foreground: var(--accent-foreground); | ||
| 32 | --color-accent: var(--accent); | ||
| 33 | --color-muted-foreground: var(--muted-foreground); | ||
| 34 | --color-muted: var(--muted); | ||
| 35 | --color-secondary-foreground: var(--secondary-foreground); | ||
| 36 | --color-secondary: var(--secondary); | ||
| 37 | --color-primary-foreground: var(--primary-foreground); | ||
| 38 | --color-primary: var(--primary); | ||
| 39 | --color-popover-foreground: var(--popover-foreground); | ||
| 40 | --color-popover: var(--popover); | ||
| 41 | --color-card-foreground: var(--card-foreground); | ||
| 42 | --color-card: var(--card); | ||
| 43 | --color-foreground: var(--foreground); | ||
| 44 | --color-background: var(--background); | ||
| 45 | --radius-sm: calc(var(--radius) - 4px); | ||
| 46 | --radius-md: calc(var(--radius) - 2px); | ||
| 47 | --radius-lg: var(--radius); | ||
| 48 | --radius-xl: calc(var(--radius) + 4px); | ||
| 49 | --radius-2xl: calc(var(--radius) + 8px); | ||
| 50 | --radius-3xl: calc(var(--radius) + 12px); | ||
| 51 | --radius-4xl: calc(var(--radius) + 16px); | ||
| 52 | @keyframes skeleton { | ||
| 53 | to { | ||
| 54 | background-position: -200% 0; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | @layer base { | ||
| 60 | * { | ||
| 61 | @apply border-border outline-ring/50; | ||
| 62 | } | ||
| 63 | body { | ||
| 64 | @apply bg-background text-foreground; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | /* Suppress all transitions during theme changes */ | ||
| 69 | .no-transitions, | ||
| 70 | .no-transitions *, | ||
| 71 | .no-transitions *::before, | ||
| 72 | .no-transitions *::after { | ||
| 73 | transition-duration: 0s !important; | ||
| 74 | animation-duration: 0s !important; | ||
| 75 | } | ||
| 76 | |||
| 77 | :root { | ||
| 78 | color-scheme: light; | ||
| 79 | --radius: 0.625rem; | ||
| 80 | --background: var(--color-white); | ||
| 81 | --foreground: var(--color-neutral-800); | ||
| 82 | --card: var(--color-white); | ||
| 83 | --card-foreground: var(--color-neutral-800); | ||
| 84 | --popover: var(--color-white); | ||
| 85 | --popover-foreground: var(--color-neutral-800); | ||
| 86 | --primary: oklch(0.488 0.217 264); | ||
| 87 | --primary-foreground: var(--color-white); | ||
| 88 | --secondary: --alpha(var(--color-black) / 4%); | ||
| 89 | --secondary-foreground: var(--color-neutral-800); | ||
| 90 | --muted: --alpha(var(--color-black) / 4%); | ||
| 91 | --muted-foreground: color-mix( | ||
| 92 | in srgb, | ||
| 93 | var(--color-neutral-500) 90%, | ||
| 94 | var(--color-black) | ||
| 95 | ); | ||
| 96 | --accent: --alpha(var(--color-black) / 5%); | ||
| 97 | --accent-foreground: var(--color-neutral-800); | ||
| 98 | --destructive: var(--color-red-500); | ||
| 99 | --border: --alpha(var(--color-black) / 8%); | ||
| 100 | --input: --alpha(var(--color-black) / 10%); | ||
| 101 | --ring: oklch(0.488 0.217 264); | ||
| 102 | --chart-1: oklch(0.646 0.222 41.116); | ||
| 103 | --chart-2: oklch(0.6 0.118 184.704); | ||
| 104 | --chart-3: oklch(0.398 0.07 227.392); | ||
| 105 | --chart-4: oklch(0.828 0.189 84.429); | ||
| 106 | --chart-5: oklch(0.769 0.188 70.08); | ||
| 107 | --sidebar: oklch(0.985 0 0); | ||
| 108 | --sidebar-foreground: oklch(0.141 0.005 285.823); | ||
| 109 | --sidebar-primary: oklch(0.21 0.006 285.885); | ||
| 110 | --sidebar-primary-foreground: oklch(0.985 0 0); | ||
| 111 | --sidebar-accent: oklch(0.967 0.001 286.375); | ||
| 112 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885); | ||
| 113 | --sidebar-border: oklch(0.92 0.004 286.32); | ||
| 114 | --sidebar-ring: oklch(0.705 0.015 286.067); | ||
| 115 | --destructive-foreground: var(--color-red-700); | ||
| 116 | --info: var(--color-blue-500); | ||
| 117 | --info-foreground: var(--color-blue-700); | ||
| 118 | --success: var(--color-emerald-500); | ||
| 119 | --success-foreground: var(--color-emerald-700); | ||
| 120 | --warning: var(--color-amber-500); | ||
| 121 | --warning-foreground: var(--color-amber-700); | ||
| 122 | |||
| 123 | @variant dark { | ||
| 124 | color-scheme: dark; | ||
| 125 | --background: color-mix( | ||
| 126 | in srgb, | ||
| 127 | var(--color-neutral-950) 95%, | ||
| 128 | var(--color-white) | ||
| 129 | ); | ||
| 130 | --foreground: var(--color-neutral-100); | ||
| 131 | --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); | ||
| 132 | --card-foreground: var(--color-neutral-100); | ||
| 133 | --popover: color-mix(in srgb, var(--background) 98%, var(--color-white)); | ||
| 134 | --popover-foreground: var(--color-neutral-100); | ||
| 135 | --primary: oklch(0.588 0.217 264); | ||
| 136 | --primary-foreground: var(--color-white); | ||
| 137 | --secondary: --alpha(var(--color-white) / 4%); | ||
| 138 | --secondary-foreground: var(--color-neutral-100); | ||
| 139 | --muted: --alpha(var(--color-white) / 4%); | ||
| 140 | --muted-foreground: color-mix( | ||
| 141 | in srgb, | ||
| 142 | var(--color-neutral-500) 90%, | ||
| 143 | var(--color-white) | ||
| 144 | ); | ||
| 145 | --accent: --alpha(var(--color-white) / 4%); | ||
| 146 | --accent-foreground: var(--color-neutral-100); | ||
| 147 | --destructive: color-mix( | ||
| 148 | in srgb, | ||
| 149 | var(--color-red-500) 90%, | ||
| 150 | var(--color-white) | ||
| 151 | ); | ||
| 152 | --border: --alpha(var(--color-white) / 6%); | ||
| 153 | --input: --alpha(var(--color-white) / 8%); | ||
| 154 | --ring: oklch(0.588 0.217 264); | ||
| 155 | --chart-1: oklch(0.488 0.243 264.376); | ||
| 156 | --chart-2: oklch(0.696 0.17 162.48); | ||
| 157 | --chart-3: oklch(0.769 0.188 70.08); | ||
| 158 | --chart-4: oklch(0.627 0.265 303.9); | ||
| 159 | --chart-5: oklch(0.645 0.246 16.439); | ||
| 160 | --sidebar: oklch(0.21 0.006 285.885); | ||
| 161 | --sidebar-foreground: oklch(0.985 0 0); | ||
| 162 | --sidebar-primary: oklch(0.488 0.243 264.376); | ||
| 163 | --sidebar-primary-foreground: oklch(0.985 0 0); | ||
| 164 | --sidebar-accent: oklch(0.274 0.006 286.033); | ||
| 165 | --sidebar-accent-foreground: oklch(0.985 0 0); | ||
| 166 | --sidebar-border: oklch(1 0 0 / 10%); | ||
| 167 | --sidebar-ring: oklch(0.552 0.016 285.938); | ||
| 168 | --destructive-foreground: var(--color-red-400); | ||
| 169 | --info: var(--color-blue-500); | ||
| 170 | --info-foreground: var(--color-blue-400); | ||
| 171 | --success: var(--color-emerald-500); | ||
| 172 | --success-foreground: var(--color-emerald-400); | ||
| 173 | --warning: var(--color-amber-500); | ||
| 174 | --warning-foreground: var(--color-amber-400); | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | body { | ||
| 179 | font-family: | ||
| 180 | "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; | ||
| 181 | margin: 0; | ||
| 182 | padding: 0; | ||
| 183 | min-height: 100vh; | ||
| 184 | } | ||
| 185 | |||
| 186 | body::after { | ||
| 187 | content: ""; | ||
| 188 | position: fixed; | ||
| 189 | inset: 0; | ||
| 190 | pointer-events: none; | ||
| 191 | opacity: 0.035; | ||
| 192 | background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); | ||
| 193 | background-repeat: repeat; | ||
| 194 | background-size: 256px 256px; | ||
| 195 | } | ||
| 196 | |||
| 197 | pre, | ||
| 198 | code, | ||
| 199 | textarea, | ||
| 200 | input { | ||
| 201 | font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; | ||
| 202 | } | ||
| 203 | |||
| 204 | /* Window drag region (frameless titlebar) */ | ||
| 205 | .drag-region { | ||
| 206 | -webkit-app-region: drag; | ||
| 207 | } | ||
| 208 | |||
| 209 | .drag-region button, | ||
| 210 | .drag-region input, | ||
| 211 | .drag-region textarea, | ||
| 212 | .drag-region select, | ||
| 213 | .drag-region a { | ||
| 214 | -webkit-app-region: no-drag; | ||
| 215 | } | ||
| 216 | |||
| 217 | /* Scrollbar styling */ | ||
| 218 | ::-webkit-scrollbar { | ||
| 219 | width: 6px; | ||
| 220 | } | ||
| 221 | |||
| 222 | ::-webkit-scrollbar-track { | ||
| 223 | background: transparent; | ||
| 224 | } | ||
| 225 | |||
| 226 | ::-webkit-scrollbar-thumb { | ||
| 227 | background: rgba(0, 0, 0, 0.15); | ||
| 228 | border-radius: 3px; | ||
| 229 | } | ||
| 230 | |||
| 231 | ::-webkit-scrollbar-thumb:hover { | ||
| 232 | background: rgba(0, 0, 0, 0.25); | ||
| 233 | } | ||
| 234 | |||
| 235 | .dark ::-webkit-scrollbar-thumb { | ||
| 236 | background: rgba(255, 255, 255, 0.1); | ||
| 237 | } | ||
| 238 | |||
| 239 | .dark ::-webkit-scrollbar-thumb:hover { | ||
| 240 | background: rgba(255, 255, 255, 0.18); | ||
| 241 | } | ||
| 242 | |||
| 243 | /* Reasoning select -- clickable label surface */ | ||
| 244 | label:has(> select#reasoning-effort) { | ||
| 245 | position: relative; | ||
| 246 | } | ||
| 247 | label:has(> select#reasoning-effort) select { | ||
| 248 | position: absolute; | ||
| 249 | inset: 0; | ||
| 250 | opacity: 0; | ||
| 251 | cursor: pointer; | ||
| 252 | width: 100%; | ||
| 253 | height: 100%; | ||
| 254 | } | ||
| 255 | |||
| 256 | /* Chat markdown rendering */ | ||
| 257 | .chat-markdown { | ||
| 258 | overflow-wrap: anywhere; | ||
| 259 | } | ||
| 260 | |||
| 261 | .chat-markdown > :first-child { | ||
| 262 | margin-top: 0; | ||
| 263 | } | ||
| 264 | |||
| 265 | .chat-markdown > :last-child { | ||
| 266 | margin-bottom: 0; | ||
| 267 | } | ||
| 268 | |||
| 269 | .chat-markdown p, | ||
| 270 | .chat-markdown ul, | ||
| 271 | .chat-markdown ol, | ||
| 272 | .chat-markdown blockquote, | ||
| 273 | .chat-markdown pre, | ||
| 274 | .chat-markdown table { | ||
| 275 | margin: 0.65rem 0; | ||
| 276 | } | ||
| 277 | |||
| 278 | .chat-markdown ul, | ||
| 279 | .chat-markdown ol { | ||
| 280 | padding-left: 1.25rem; | ||
| 281 | } | ||
| 282 | |||
| 283 | .chat-markdown li + li { | ||
| 284 | margin-top: 0.25rem; | ||
| 285 | } | ||
| 286 | |||
| 287 | .chat-markdown a { | ||
| 288 | color: var(--info-foreground); | ||
| 289 | text-decoration: underline; | ||
| 290 | text-decoration-color: color-mix( | ||
| 291 | in srgb, | ||
| 292 | var(--info-foreground) 40%, | ||
| 293 | transparent | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | |||
| 297 | .chat-markdown a:hover { | ||
| 298 | opacity: 0.8; | ||
| 299 | } | ||
| 300 | |||
| 301 | .chat-markdown blockquote { | ||
| 302 | border-left: 2px solid var(--border); | ||
| 303 | padding-left: 0.8rem; | ||
| 304 | color: var(--muted-foreground); | ||
| 305 | } | ||
| 306 | |||
| 307 | .chat-markdown :not(pre) > code { | ||
| 308 | border: 1px solid var(--border); | ||
| 309 | border-radius: 0.375rem; | ||
| 310 | background: var(--muted); | ||
| 311 | padding: 0.1rem 0.35rem; | ||
| 312 | color: var(--foreground); | ||
| 313 | } | ||
| 314 | |||
| 315 | .chat-markdown pre { | ||
| 316 | overflow-x: auto; | ||
| 317 | border: 1px solid var(--border); | ||
| 318 | border-radius: 0.75rem; | ||
| 319 | background: var(--muted); | ||
| 320 | padding: 0.8rem 0.9rem; | ||
| 321 | } | ||
| 322 | |||
| 323 | .chat-markdown pre code { | ||
| 324 | border: none; | ||
| 325 | background: transparent; | ||
| 326 | padding: 0; | ||
| 327 | line-height: 1.5; | ||
| 328 | } | ||
| 329 | |||
| 330 | .chat-markdown table { | ||
| 331 | width: 100%; | ||
| 332 | border-collapse: collapse; | ||
| 333 | } | ||
| 334 | |||
| 335 | .chat-markdown th, | ||
| 336 | .chat-markdown td { | ||
| 337 | border: 1px solid var(--border); | ||
| 338 | padding: 0.35rem 0.45rem; | ||
| 339 | text-align: left; | ||
| 340 | } | ||
apps/renderer/src/main.tsx deleted-12| ... | @@ -1,12 +0,0 @@ | ||
| 1 | import React from "react"; | ||
| 2 | import ReactDOM from "react-dom/client"; | ||
| 3 | import "highlight.js/styles/github-dark.css"; | ||
| 4 | |||
| 5 | import App from "./App"; | ||
| 6 | import "./index.css"; | ||
| 7 | |||
| 8 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | ||
| 9 | <React.StrictMode> | ||
| 10 | <App /> | ||
| 11 | </React.StrictMode>, | ||
| 12 | ); | ||
apps/renderer/src/model-logic.test.ts deleted-33| ... | @@ -1,33 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; | ||
| 3 | |||
| 4 | describe("normalizeModelSlug", () => { | ||
| 5 | it("maps 5.3 aliases to canonical codex slug", () => { | ||
| 6 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | ||
| 7 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("preserves non-aliased slugs", () => { | ||
| 11 | expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 12 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("returns null for empty input", () => { | ||
| 16 | expect(normalizeModelSlug("")).toBeNull(); | ||
| 17 | expect(normalizeModelSlug(" ")).toBeNull(); | ||
| 18 | expect(normalizeModelSlug(null)).toBeNull(); | ||
| 19 | expect(normalizeModelSlug(undefined)).toBeNull(); | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | describe("resolveModelSlug", () => { | ||
| 24 | it("falls back to default for unknown models", () => { | ||
| 25 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("resolves supported hard-coded models", () => { | ||
| 29 | expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 30 | expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 31 | expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2"); | ||
| 32 | }); | ||
| 33 | }); | ||
apps/renderer/src/model-logic.ts deleted-9| ... | @@ -1,9 +0,0 @@ | ||
| 1 | export { | ||
| 2 | DEFAULT_MODEL, | ||
| 3 | MODEL_OPTIONS, | ||
| 4 | normalizeModelSlug, | ||
| 5 | resolveModelSlug, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | ||
| 9 | export const DEFAULT_REASONING = "high"; | ||
apps/renderer/src/persistenceSchema.test.ts deleted-172| ... | @@ -1,172 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { DEFAULT_MODEL } from "./model-logic"; | ||
| 4 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | ||
| 5 | import type { Thread } from "./types"; | ||
| 6 | |||
| 7 | describe("hydratePersistedState", () => { | ||
| 8 | it("returns null for invalid payloads", () => { | ||
| 9 | expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull(); | ||
| 10 | expect(hydratePersistedState("not-json", false)).toBeNull(); | ||
| 11 | }); | ||
| 12 | |||
| 13 | it("migrates the legacy default model to the current default", () => { | ||
| 14 | const payload = JSON.stringify({ | ||
| 15 | projects: [ | ||
| 16 | { | ||
| 17 | id: "p-1", | ||
| 18 | name: "Project", | ||
| 19 | cwd: "/tmp/project", | ||
| 20 | model: "gpt-5.2-codex", | ||
| 21 | expanded: true, | ||
| 22 | }, | ||
| 23 | ], | ||
| 24 | threads: [ | ||
| 25 | { | ||
| 26 | id: "t-1", | ||
| 27 | projectId: "p-1", | ||
| 28 | title: "Thread", | ||
| 29 | model: "gpt-5.2-codex", | ||
| 30 | messages: [ | ||
| 31 | { | ||
| 32 | id: "m-1", | ||
| 33 | role: "assistant", | ||
| 34 | text: "Hello", | ||
| 35 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 36 | streaming: true, | ||
| 37 | }, | ||
| 38 | ], | ||
| 39 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 40 | }, | ||
| 41 | ], | ||
| 42 | activeThreadId: "t-1", | ||
| 43 | }); | ||
| 44 | |||
| 45 | const hydrated = hydratePersistedState(payload, true); | ||
| 46 | expect(hydrated).not.toBeNull(); | ||
| 47 | expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); | ||
| 48 | expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); | ||
| 49 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); | ||
| 50 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); | ||
| 51 | expect(hydrated?.runtimeMode).toBe("full-access"); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("filters unknown project references and repairs active thread", () => { | ||
| 55 | const payload = JSON.stringify({ | ||
| 56 | projects: [ | ||
| 57 | { | ||
| 58 | id: "p-1", | ||
| 59 | name: "Project", | ||
| 60 | cwd: "/tmp/project", | ||
| 61 | model: "gpt-5.3-codex", | ||
| 62 | expanded: false, | ||
| 63 | }, | ||
| 64 | ], | ||
| 65 | threads: [ | ||
| 66 | { | ||
| 67 | id: "t-1", | ||
| 68 | projectId: "p-1", | ||
| 69 | title: "Valid thread", | ||
| 70 | model: "gpt-5.3-codex", | ||
| 71 | messages: [], | ||
| 72 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 73 | }, | ||
| 74 | { | ||
| 75 | id: "t-2", | ||
| 76 | projectId: "p-missing", | ||
| 77 | title: "Dangling thread", | ||
| 78 | model: "gpt-5.3-codex", | ||
| 79 | messages: [], | ||
| 80 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | activeThreadId: "t-2", | ||
| 84 | }); | ||
| 85 | |||
| 86 | const hydrated = hydratePersistedState(payload, false); | ||
| 87 | expect(hydrated).not.toBeNull(); | ||
| 88 | expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); | ||
| 89 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); | ||
| 90 | expect(hydrated?.activeThreadId).toBe("t-1"); | ||
| 91 | expect(hydrated?.runtimeMode).toBe("full-access"); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("hydrates runtime mode from v3 payload", () => { | ||
| 95 | const payload = JSON.stringify({ | ||
| 96 | version: 3, | ||
| 97 | runtimeMode: "approval-required", | ||
| 98 | projects: [ | ||
| 99 | { | ||
| 100 | id: "p-1", | ||
| 101 | name: "Project", | ||
| 102 | cwd: "/tmp/project", | ||
| 103 | model: "gpt-5.3-codex", | ||
| 104 | expanded: false, | ||
| 105 | }, | ||
| 106 | ], | ||
| 107 | threads: [], | ||
| 108 | activeThreadId: null, | ||
| 109 | }); | ||
| 110 | |||
| 111 | const hydrated = hydratePersistedState(payload, false); | ||
| 112 | expect(hydrated?.runtimeMode).toBe("approval-required"); | ||
| 113 | }); | ||
| 114 | }); | ||
| 115 | |||
| 116 | describe("toPersistedState", () => { | ||
| 117 | it("writes v4 payload and strips non-persisted thread fields", () => { | ||
| 118 | const thread: Thread = { | ||
| 119 | id: "t-1", | ||
| 120 | codexThreadId: "thr_1", | ||
| 121 | projectId: "p-1", | ||
| 122 | title: "Thread", | ||
| 123 | model: "gpt-5.3-codex", | ||
| 124 | session: null, | ||
| 125 | messages: [ | ||
| 126 | { | ||
| 127 | id: "m-1", | ||
| 128 | role: "user", | ||
| 129 | text: "Hi", | ||
| 130 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 131 | streaming: false, | ||
| 132 | }, | ||
| 133 | ], | ||
| 134 | events: [], | ||
| 135 | error: "boom", | ||
| 136 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 137 | }; | ||
| 138 | |||
| 139 | const persisted = toPersistedState({ | ||
| 140 | projects: [ | ||
| 141 | { | ||
| 142 | id: "p-1", | ||
| 143 | name: "Project", | ||
| 144 | cwd: "/tmp/project", | ||
| 145 | model: "gpt-5.3-codex", | ||
| 146 | expanded: true, | ||
| 147 | }, | ||
| 148 | ], | ||
| 149 | threads: [thread], | ||
| 150 | activeThreadId: "t-1", | ||
| 151 | runtimeMode: "full-access", | ||
| 152 | }); | ||
| 153 | |||
| 154 | expect(persisted.version).toBe(4); | ||
| 155 | expect(persisted.runtimeMode).toBe("full-access"); | ||
| 156 | expect(persisted.threads[0]).toEqual({ | ||
| 157 | id: "t-1", | ||
| 158 | codexThreadId: "thr_1", | ||
| 159 | projectId: "p-1", | ||
| 160 | title: "Thread", | ||
| 161 | model: "gpt-5.3-codex", | ||
| 162 | messages: thread.messages, | ||
| 163 | createdAt: thread.createdAt, | ||
| 164 | }); | ||
| 165 | const persistedThread = persisted.threads[0]; | ||
| 166 | expect(persistedThread).toBeDefined(); | ||
| 167 | if (!persistedThread) return; | ||
| 168 | |||
| 169 | expect("error" in persistedThread).toBe(false); | ||
| 170 | expect("session" in persistedThread).toBe(false); | ||
| 171 | }); | ||
| 172 | }); | ||
apps/renderer/src/persistenceSchema.ts deleted-177| ... | @@ -1,177 +0,0 @@ | ||
| 1 | import { z } from "zod"; | ||
| 2 | |||
| 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | ||
| 4 | import { | ||
| 5 | DEFAULT_RUNTIME_MODE, | ||
| 6 | type Project, | ||
| 7 | type RuntimeMode, | ||
| 8 | type Thread, | ||
| 9 | } from "./types"; | ||
| 10 | |||
| 11 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | ||
| 12 | |||
| 13 | const persistedProjectSchema = z.object({ | ||
| 14 | id: z.string().min(1), | ||
| 15 | name: z.string().min(1), | ||
| 16 | cwd: z.string().min(1), | ||
| 17 | model: z.string().min(1), | ||
| 18 | expanded: z.boolean(), | ||
| 19 | }); | ||
| 20 | |||
| 21 | const persistedMessageSchema = z.object({ | ||
| 22 | id: z.string().min(1), | ||
| 23 | role: z.enum(["user", "assistant"]), | ||
| 24 | text: z.string(), | ||
| 25 | createdAt: z.string().min(1), | ||
| 26 | streaming: z.boolean(), | ||
| 27 | }); | ||
| 28 | |||
| 29 | const persistedThreadSchema = z.object({ | ||
| 30 | id: z.string().min(1), | ||
| 31 | codexThreadId: z.string().min(1).nullable().default(null), | ||
| 32 | projectId: z.string().min(1), | ||
| 33 | title: z.string().min(1), | ||
| 34 | model: z.string().min(1), | ||
| 35 | messages: z.array(persistedMessageSchema), | ||
| 36 | createdAt: z.string().min(1), | ||
| 37 | }); | ||
| 38 | |||
| 39 | const persistedStateBodySchema = z.object({ | ||
| 40 | projects: z.array(persistedProjectSchema), | ||
| 41 | threads: z.array(persistedThreadSchema), | ||
| 42 | activeThreadId: z.string().min(1).nullable(), | ||
| 43 | }); | ||
| 44 | |||
| 45 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | ||
| 46 | |||
| 47 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ | ||
| 48 | version: z.literal(1).optional(), | ||
| 49 | }); | ||
| 50 | |||
| 51 | export const persistedStateV2Schema = persistedStateBodySchema.extend({ | ||
| 52 | version: z.literal(2).optional(), | ||
| 53 | }); | ||
| 54 | |||
| 55 | export const persistedStateV3Schema = persistedStateBodySchema.extend({ | ||
| 56 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 57 | version: z.literal(3).optional(), | ||
| 58 | }); | ||
| 59 | |||
| 60 | export const persistedStateV4Schema = persistedStateBodySchema.extend({ | ||
| 61 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 62 | version: z.literal(4).optional(), | ||
| 63 | }); | ||
| 64 | |||
| 65 | const persistedStateSchema = z.union([ | ||
| 66 | persistedStateV4Schema, | ||
| 67 | persistedStateV3Schema, | ||
| 68 | persistedStateV2Schema, | ||
| 69 | persistedStateV1Schema, | ||
| 70 | ]); | ||
| 71 | |||
| 72 | export interface PersistedStoreSnapshot { | ||
| 73 | projects: Project[]; | ||
| 74 | threads: Thread[]; | ||
| 75 | activeThreadId: string | null; | ||
| 76 | runtimeMode: RuntimeMode; | ||
| 77 | } | ||
| 78 | |||
| 79 | function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string { | ||
| 80 | if (!isLegacyPayload) { | ||
| 81 | return model; | ||
| 82 | } | ||
| 83 | |||
| 84 | return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; | ||
| 85 | } | ||
| 86 | |||
| 87 | function hydrateProject( | ||
| 88 | project: z.infer<typeof persistedProjectSchema>, | ||
| 89 | isLegacyPayload: boolean, | ||
| 90 | ): Project { | ||
| 91 | return { | ||
| 92 | ...project, | ||
| 93 | model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 | function hydrateThread( | ||
| 98 | thread: z.infer<typeof persistedThreadSchema>, | ||
| 99 | isLegacyPayload: boolean, | ||
| 100 | ): Thread { | ||
| 101 | return { | ||
| 102 | id: thread.id, | ||
| 103 | codexThreadId: thread.codexThreadId, | ||
| 104 | projectId: thread.projectId, | ||
| 105 | title: thread.title, | ||
| 106 | model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), | ||
| 107 | session: null, | ||
| 108 | messages: thread.messages.map((message) => ({ | ||
| 109 | ...message, | ||
| 110 | streaming: false, | ||
| 111 | })), | ||
| 112 | events: [], | ||
| 113 | error: null, | ||
| 114 | createdAt: thread.createdAt, | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | export function hydratePersistedState( | ||
| 119 | raw: string, | ||
| 120 | isLegacyPayload: boolean, | ||
| 121 | ): PersistedStoreSnapshot | null { | ||
| 122 | let parsedJson: unknown; | ||
| 123 | try { | ||
| 124 | parsedJson = JSON.parse(raw); | ||
| 125 | } catch { | ||
| 126 | return null; | ||
| 127 | } | ||
| 128 | |||
| 129 | const parsedState = persistedStateSchema.safeParse(parsedJson); | ||
| 130 | if (!parsedState.success) { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | |||
| 134 | const projects = parsedState.data.projects.map((project) => | ||
| 135 | hydrateProject(project, isLegacyPayload), | ||
| 136 | ); | ||
| 137 | const projectIds = new Set(projects.map((project) => project.id)); | ||
| 138 | const threads = parsedState.data.threads | ||
| 139 | .map((thread) => hydrateThread(thread, isLegacyPayload)) | ||
| 140 | .filter((thread) => projectIds.has(thread.projectId)); | ||
| 141 | const hasActiveThread = Boolean( | ||
| 142 | parsedState.data.activeThreadId && | ||
| 143 | threads.some((thread) => thread.id === parsedState.data.activeThreadId), | ||
| 144 | ); | ||
| 145 | |||
| 146 | return { | ||
| 147 | projects, | ||
| 148 | threads, | ||
| 149 | activeThreadId: hasActiveThread | ||
| 150 | ? parsedState.data.activeThreadId | ||
| 151 | : (threads[0]?.id ?? null), | ||
| 152 | runtimeMode: | ||
| 153 | "runtimeMode" in parsedState.data | ||
| 154 | ? parsedState.data.runtimeMode | ||
| 155 | : DEFAULT_RUNTIME_MODE, | ||
| 156 | }; | ||
| 157 | } | ||
| 158 | |||
| 159 | export function toPersistedState( | ||
| 160 | state: PersistedStoreSnapshot, | ||
| 161 | ): z.infer<typeof persistedStateV4Schema> { | ||
| 162 | return { | ||
| 163 | version: 4, | ||
| 164 | projects: state.projects, | ||
| 165 | threads: state.threads.map((thread) => ({ | ||
| 166 | id: thread.id, | ||
| 167 | codexThreadId: thread.codexThreadId, | ||
| 168 | projectId: thread.projectId, | ||
| 169 | title: thread.title, | ||
| 170 | model: thread.model, | ||
| 171 | messages: thread.messages, | ||
| 172 | createdAt: thread.createdAt, | ||
| 173 | })), | ||
| 174 | activeThreadId: state.activeThreadId, | ||
| 175 | runtimeMode: state.runtimeMode, | ||
| 176 | }; | ||
| 177 | } | ||
apps/renderer/src/session-logic.test.ts deleted-632| ... | @@ -1,632 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 4 | import { | ||
| 5 | type WorkLogEntry, | ||
| 6 | applyEventToMessages, | ||
| 7 | deriveTimelineEntries, | ||
| 8 | deriveWorkLogEntries, | ||
| 9 | evolveSession, | ||
| 10 | } from "./session-logic"; | ||
| 11 | import type { ChatMessage } from "./types"; | ||
| 12 | |||
| 13 | function makeEvent(overrides: Partial<ProviderEvent>): ProviderEvent { | ||
| 14 | return { | ||
| 15 | id: "evt-1", | ||
| 16 | kind: "notification", | ||
| 17 | provider: "codex", | ||
| 18 | sessionId: "sess-1", | ||
| 19 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 20 | method: "noop", | ||
| 21 | ...overrides, | ||
| 22 | }; | ||
| 23 | } | ||
| 24 | |||
| 25 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { | ||
| 26 | return { | ||
| 27 | sessionId: "sess-1", | ||
| 28 | provider: "codex", | ||
| 29 | status: "ready", | ||
| 30 | createdAt: "2026-02-08T09:59:00.000Z", | ||
| 31 | updatedAt: "2026-02-08T09:59:00.000Z", | ||
| 32 | ...overrides, | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | describe("deriveTimelineEntries", () => { | ||
| 37 | it("interleaves messages and work entries by timestamp", () => { | ||
| 38 | const messages: ChatMessage[] = [ | ||
| 39 | { | ||
| 40 | id: "m-user", | ||
| 41 | role: "user", | ||
| 42 | text: "Hi", | ||
| 43 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 44 | streaming: false, | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | id: "m-assistant", | ||
| 48 | role: "assistant", | ||
| 49 | text: "Hello", | ||
| 50 | createdAt: "2026-02-08T10:05:00.000Z", | ||
| 51 | streaming: false, | ||
| 52 | }, | ||
| 53 | ]; | ||
| 54 | const workEntries: WorkLogEntry[] = [ | ||
| 55 | { | ||
| 56 | id: "w-1", | ||
| 57 | label: "Tool call", | ||
| 58 | createdAt: "2026-02-08T10:02:00.000Z", | ||
| 59 | tone: "tool", | ||
| 60 | }, | ||
| 61 | { | ||
| 62 | id: "w-2", | ||
| 63 | label: "Preamble", | ||
| 64 | createdAt: "2026-02-08T10:03:00.000Z", | ||
| 65 | tone: "thinking", | ||
| 66 | }, | ||
| 67 | ]; | ||
| 68 | |||
| 69 | const timeline = deriveTimelineEntries(messages, workEntries); | ||
| 70 | |||
| 71 | expect(timeline.map((entry) => entry.id)).toEqual([ | ||
| 72 | "message:m-user", | ||
| 73 | "work:w-1", | ||
| 74 | "work:w-2", | ||
| 75 | "message:m-assistant", | ||
| 76 | ]); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("prefers work entries when timestamps are equal", () => { | ||
| 80 | const messages: ChatMessage[] = [ | ||
| 81 | { | ||
| 82 | id: "m-1", | ||
| 83 | role: "assistant", | ||
| 84 | text: "Done", | ||
| 85 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 86 | streaming: false, | ||
| 87 | }, | ||
| 88 | ]; | ||
| 89 | const workEntries: WorkLogEntry[] = [ | ||
| 90 | { | ||
| 91 | id: "w-1", | ||
| 92 | label: "Tool call", | ||
| 93 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 94 | tone: "tool", | ||
| 95 | }, | ||
| 96 | ]; | ||
| 97 | |||
| 98 | const timeline = deriveTimelineEntries(messages, workEntries); | ||
| 99 | |||
| 100 | expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]); | ||
| 101 | }); | ||
| 102 | }); | ||
| 103 | |||
| 104 | describe("deriveWorkLogEntries", () => { | ||
| 105 | it("drops preamble/work events from the visible work log", () => { | ||
| 106 | const entries = deriveWorkLogEntries( | ||
| 107 | [ | ||
| 108 | makeEvent({ | ||
| 109 | id: "evt-1", | ||
| 110 | method: "item/started", | ||
| 111 | turnId: "turn-1", | ||
| 112 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 113 | payload: { item: { type: "preamble", text: "thinking" } }, | ||
| 114 | }), | ||
| 115 | makeEvent({ | ||
| 116 | id: "evt-2", | ||
| 117 | method: "item/started", | ||
| 118 | turnId: "turn-1", | ||
| 119 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 120 | payload: { item: { type: "work", text: "planning" } }, | ||
| 121 | }), | ||
| 122 | makeEvent({ | ||
| 123 | id: "evt-3", | ||
| 124 | method: "item/started", | ||
| 125 | turnId: "turn-1", | ||
| 126 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 127 | payload: { item: { type: "tool_call", command: "ls -la" } }, | ||
| 128 | }), | ||
| 129 | ], | ||
| 130 | "turn-1", | ||
| 131 | ); | ||
| 132 | |||
| 133 | expect(entries).toHaveLength(1); | ||
| 134 | expect(entries[0]?.label).toBe("Tool call"); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("does not surface successful turn completion as a work-log row", () => { | ||
| 138 | const entries = deriveWorkLogEntries( | ||
| 139 | [ | ||
| 140 | makeEvent({ | ||
| 141 | id: "evt-start", | ||
| 142 | method: "turn/started", | ||
| 143 | turnId: "turn-1", | ||
| 144 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 145 | }), | ||
| 146 | makeEvent({ | ||
| 147 | id: "evt-complete", | ||
| 148 | method: "turn/completed", | ||
| 149 | turnId: "turn-1", | ||
| 150 | createdAt: "2026-02-08T10:00:10.000Z", | ||
| 151 | payload: { turn: { id: "turn-1", status: "completed" } }, | ||
| 152 | }), | ||
| 153 | ], | ||
| 154 | "turn-1", | ||
| 155 | ); | ||
| 156 | |||
| 157 | expect(entries).toHaveLength(0); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("shows failed turn completion in the work log", () => { | ||
| 161 | const entries = deriveWorkLogEntries( | ||
| 162 | [ | ||
| 163 | makeEvent({ | ||
| 164 | id: "evt-start", | ||
| 165 | method: "turn/started", | ||
| 166 | turnId: "turn-1", | ||
| 167 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 168 | }), | ||
| 169 | makeEvent({ | ||
| 170 | id: "evt-complete", | ||
| 171 | method: "turn/completed", | ||
| 172 | turnId: "turn-1", | ||
| 173 | createdAt: "2026-02-08T10:00:10.000Z", | ||
| 174 | payload: { | ||
| 175 | turn: { | ||
| 176 | id: "turn-1", | ||
| 177 | status: "failed", | ||
| 178 | error: { message: "sandbox denied" }, | ||
| 179 | }, | ||
| 180 | }, | ||
| 181 | }), | ||
| 182 | ], | ||
| 183 | "turn-1", | ||
| 184 | ); | ||
| 185 | |||
| 186 | expect(entries).toHaveLength(1); | ||
| 187 | expect(entries[0]?.label).toBe("Turn failed"); | ||
| 188 | expect(entries[0]?.detail).toBe("sandbox denied"); | ||
| 189 | }); | ||
| 190 | |||
| 191 | it("hides reasoning and agent-message noise from the visible work log", () => { | ||
| 192 | const entries = deriveWorkLogEntries( | ||
| 193 | [ | ||
| 194 | makeEvent({ | ||
| 195 | id: "evt-turn-start", | ||
| 196 | method: "turn/started", | ||
| 197 | turnId: "turn-1", | ||
| 198 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 199 | }), | ||
| 200 | makeEvent({ | ||
| 201 | id: "evt-summary-part", | ||
| 202 | method: "item/reasoning/summaryPartAdded", | ||
| 203 | turnId: "turn-1", | ||
| 204 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 205 | }), | ||
| 206 | makeEvent({ | ||
| 207 | id: "evt-summary-delta", | ||
| 208 | method: "item/reasoning/summaryTextDelta", | ||
| 209 | turnId: "turn-1", | ||
| 210 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 211 | }), | ||
| 212 | makeEvent({ | ||
| 213 | id: "evt-agent-start", | ||
| 214 | method: "item/started", | ||
| 215 | turnId: "turn-1", | ||
| 216 | createdAt: "2026-02-08T10:00:03.000Z", | ||
| 217 | payload: { | ||
| 218 | item: { id: "item-msg", type: "agentMessage", text: "Working..." }, | ||
| 219 | }, | ||
| 220 | }), | ||
| 221 | makeEvent({ | ||
| 222 | id: "evt-agent-complete", | ||
| 223 | method: "item/completed", | ||
| 224 | turnId: "turn-1", | ||
| 225 | createdAt: "2026-02-08T10:00:04.000Z", | ||
| 226 | payload: { | ||
| 227 | item: { | ||
| 228 | id: "item-msg", | ||
| 229 | type: "agentMessage", | ||
| 230 | text: "Done response", | ||
| 231 | }, | ||
| 232 | }, | ||
| 233 | }), | ||
| 234 | makeEvent({ | ||
| 235 | id: "evt-tool-start", | ||
| 236 | method: "item/started", | ||
| 237 | turnId: "turn-1", | ||
| 238 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 239 | payload: { | ||
| 240 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | ||
| 241 | }, | ||
| 242 | }), | ||
| 243 | makeEvent({ | ||
| 244 | id: "evt-tool-complete", | ||
| 245 | method: "item/completed", | ||
| 246 | turnId: "turn-1", | ||
| 247 | createdAt: "2026-02-08T10:00:06.000Z", | ||
| 248 | payload: { | ||
| 249 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | ||
| 250 | }, | ||
| 251 | }), | ||
| 252 | makeEvent({ | ||
| 253 | id: "evt-turn-complete", | ||
| 254 | method: "turn/completed", | ||
| 255 | turnId: "turn-1", | ||
| 256 | createdAt: "2026-02-08T10:00:08.000Z", | ||
| 257 | payload: { turn: { id: "turn-1", status: "completed" } }, | ||
| 258 | }), | ||
| 259 | ], | ||
| 260 | "turn-1", | ||
| 261 | ); | ||
| 262 | |||
| 263 | expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]); | ||
| 264 | }); | ||
| 265 | |||
| 266 | it("coalesces command start/completed lifecycle into one entry", () => { | ||
| 267 | const entries = deriveWorkLogEntries( | ||
| 268 | [ | ||
| 269 | makeEvent({ | ||
| 270 | id: "evt-command-start", | ||
| 271 | method: "item/started", | ||
| 272 | turnId: "turn-1", | ||
| 273 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 274 | payload: { | ||
| 275 | item: { | ||
| 276 | id: "item-command", | ||
| 277 | type: "command_execution", | ||
| 278 | command: "git status --short", | ||
| 279 | }, | ||
| 280 | }, | ||
| 281 | }), | ||
| 282 | makeEvent({ | ||
| 283 | id: "evt-command-complete", | ||
| 284 | method: "item/completed", | ||
| 285 | turnId: "turn-1", | ||
| 286 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 287 | payload: { | ||
| 288 | item: { | ||
| 289 | id: "item-command", | ||
| 290 | type: "command_execution", | ||
| 291 | command: "git status --short", | ||
| 292 | }, | ||
| 293 | }, | ||
| 294 | }), | ||
| 295 | ], | ||
| 296 | "turn-1", | ||
| 297 | ); | ||
| 298 | |||
| 299 | expect(entries).toHaveLength(1); | ||
| 300 | expect(entries[0]?.label).toBe("Command run"); | ||
| 301 | expect(entries[0]?.detail).toBe("git status --short"); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("preserves full tool-call detail text without data truncation", () => { | ||
| 305 | const longCommand = | ||
| 306 | 'node ./scripts/sync.js --project ct-round-5 --mode dry-run --include "very long argument with lots of detail and metadata to verify there is no hard truncation in session logic"'; | ||
| 307 | const entries = deriveWorkLogEntries( | ||
| 308 | [ | ||
| 309 | makeEvent({ | ||
| 310 | id: "evt-tool-start", | ||
| 311 | method: "item/started", | ||
| 312 | turnId: "turn-1", | ||
| 313 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 314 | payload: { | ||
| 315 | item: { id: "item-tool", type: "tool_call", command: longCommand }, | ||
| 316 | }, | ||
| 317 | }), | ||
| 318 | ], | ||
| 319 | "turn-1", | ||
| 320 | ); | ||
| 321 | |||
| 322 | expect(entries[0]?.detail).toBe(longCommand); | ||
| 323 | }); | ||
| 324 | |||
| 325 | it("hides generic tool-call rows that have no identifying detail", () => { | ||
| 326 | const entries = deriveWorkLogEntries( | ||
| 327 | [ | ||
| 328 | makeEvent({ | ||
| 329 | id: "evt-tool-start", | ||
| 330 | method: "item/started", | ||
| 331 | turnId: "turn-1", | ||
| 332 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 333 | payload: { | ||
| 334 | item: { id: "item-tool", type: "tool_call" }, | ||
| 335 | }, | ||
| 336 | }), | ||
| 337 | ], | ||
| 338 | "turn-1", | ||
| 339 | ); | ||
| 340 | |||
| 341 | expect(entries).toHaveLength(0); | ||
| 342 | }); | ||
| 343 | |||
| 344 | it("includes collab tool-call rows with tool name detail", () => { | ||
| 345 | const entries = deriveWorkLogEntries( | ||
| 346 | [ | ||
| 347 | makeEvent({ | ||
| 348 | id: "evt-collab-start", | ||
| 349 | method: "item/started", | ||
| 350 | turnId: "turn-1", | ||
| 351 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 352 | payload: { | ||
| 353 | item: { | ||
| 354 | id: "item-collab", | ||
| 355 | type: "collabAgentToolCall", | ||
| 356 | tool: "spawnAgent", | ||
| 357 | }, | ||
| 358 | }, | ||
| 359 | }), | ||
| 360 | ], | ||
| 361 | "turn-1", | ||
| 362 | ); | ||
| 363 | |||
| 364 | expect(entries).toHaveLength(1); | ||
| 365 | expect(entries[0]?.label).toBe("Tool call"); | ||
| 366 | expect(entries[0]?.detail).toBe("spawnAgent"); | ||
| 367 | }); | ||
| 368 | |||
| 369 | it("can derive work-log entries across all turns when not scoped", () => { | ||
| 370 | const entries = deriveWorkLogEntries( | ||
| 371 | [ | ||
| 372 | makeEvent({ | ||
| 373 | id: "evt-turn-1-tool", | ||
| 374 | method: "item/started", | ||
| 375 | turnId: "turn-1", | ||
| 376 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 377 | payload: { item: { type: "tool_call", command: "ls -la" } }, | ||
| 378 | }), | ||
| 379 | makeEvent({ | ||
| 380 | id: "evt-turn-2-tool", | ||
| 381 | method: "item/started", | ||
| 382 | turnId: "turn-2", | ||
| 383 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 384 | payload: { item: { type: "tool_call", command: "pwd" } }, | ||
| 385 | }), | ||
| 386 | ], | ||
| 387 | undefined, | ||
| 388 | ); | ||
| 389 | |||
| 390 | expect(entries).toHaveLength(2); | ||
| 391 | expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]); | ||
| 392 | }); | ||
| 393 | }); | ||
| 394 | |||
| 395 | describe("evolveSession", () => { | ||
| 396 | it("updates thread id when thread starts", () => { | ||
| 397 | const previous = makeSession(); | ||
| 398 | const next = evolveSession( | ||
| 399 | previous, | ||
| 400 | makeEvent({ | ||
| 401 | method: "thread/started", | ||
| 402 | createdAt: "2026-02-08T10:01:00.000Z", | ||
| 403 | payload: { thread: { id: "thread-1" } }, | ||
| 404 | }), | ||
| 405 | ); | ||
| 406 | |||
| 407 | expect(next.threadId).toBe("thread-1"); | ||
| 408 | expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z"); | ||
| 409 | }); | ||
| 410 | |||
| 411 | it("moves to running and records active turn on turn start", () => { | ||
| 412 | const previous = makeSession(); | ||
| 413 | const next = evolveSession( | ||
| 414 | previous, | ||
| 415 | makeEvent({ | ||
| 416 | method: "turn/started", | ||
| 417 | turnId: "turn-1", | ||
| 418 | createdAt: "2026-02-08T10:02:00.000Z", | ||
| 419 | }), | ||
| 420 | ); | ||
| 421 | |||
| 422 | expect(next.status).toBe("running"); | ||
| 423 | expect(next.activeTurnId).toBe("turn-1"); | ||
| 424 | expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z"); | ||
| 425 | }); | ||
| 426 | |||
| 427 | it("returns to ready and clears active turn on successful completion", () => { | ||
| 428 | const previous = makeSession({ | ||
| 429 | status: "running", | ||
| 430 | activeTurnId: "turn-1", | ||
| 431 | lastError: "older error", | ||
| 432 | }); | ||
| 433 | const next = evolveSession( | ||
| 434 | previous, | ||
| 435 | makeEvent({ | ||
| 436 | method: "turn/completed", | ||
| 437 | createdAt: "2026-02-08T10:03:00.000Z", | ||
| 438 | payload: { turn: { id: "turn-1", status: "completed" } }, | ||
| 439 | }), | ||
| 440 | ); | ||
| 441 | |||
| 442 | expect(next.status).toBe("ready"); | ||
| 443 | expect(next.activeTurnId).toBeUndefined(); | ||
| 444 | expect(next.lastError).toBe("older error"); | ||
| 445 | }); | ||
| 446 | |||
| 447 | it("marks session as error when turn fails", () => { | ||
| 448 | const previous = makeSession({ | ||
| 449 | status: "running", | ||
| 450 | activeTurnId: "turn-1", | ||
| 451 | }); | ||
| 452 | const next = evolveSession( | ||
| 453 | previous, | ||
| 454 | makeEvent({ | ||
| 455 | method: "turn/completed", | ||
| 456 | createdAt: "2026-02-08T10:03:00.000Z", | ||
| 457 | payload: { | ||
| 458 | turn: { id: "turn-1", status: "failed", error: { message: "boom" } }, | ||
| 459 | }, | ||
| 460 | }), | ||
| 461 | ); | ||
| 462 | |||
| 463 | expect(next.status).toBe("error"); | ||
| 464 | expect(next.activeTurnId).toBeUndefined(); | ||
| 465 | expect(next.lastError).toBe("boom"); | ||
| 466 | }); | ||
| 467 | |||
| 468 | it("moves to error on runtime error events", () => { | ||
| 469 | const previous = makeSession(); | ||
| 470 | const next = evolveSession( | ||
| 471 | previous, | ||
| 472 | makeEvent({ | ||
| 473 | kind: "error", | ||
| 474 | method: "runtime/error", | ||
| 475 | createdAt: "2026-02-08T10:04:00.000Z", | ||
| 476 | message: "runtime failure", | ||
| 477 | }), | ||
| 478 | ); | ||
| 479 | |||
| 480 | expect(next.status).toBe("error"); | ||
| 481 | expect(next.lastError).toBe("runtime failure"); | ||
| 482 | }); | ||
| 483 | |||
| 484 | it("closes session on close lifecycle events", () => { | ||
| 485 | const previous = makeSession({ | ||
| 486 | status: "running", | ||
| 487 | activeTurnId: "turn-1", | ||
| 488 | }); | ||
| 489 | const next = evolveSession( | ||
| 490 | previous, | ||
| 491 | makeEvent({ | ||
| 492 | method: "session/closed", | ||
| 493 | createdAt: "2026-02-08T10:05:00.000Z", | ||
| 494 | message: "closed", | ||
| 495 | }), | ||
| 496 | ); | ||
| 497 | |||
| 498 | expect(next.status).toBe("closed"); | ||
| 499 | expect(next.activeTurnId).toBeUndefined(); | ||
| 500 | expect(next.lastError).toBe("closed"); | ||
| 501 | }); | ||
| 502 | }); | ||
| 503 | |||
| 504 | describe("applyEventToMessages", () => { | ||
| 505 | it("handles start/delta/completed flow for assistant messages", () => { | ||
| 506 | const activeAssistantItemRef = { current: null as string | null }; | ||
| 507 | const started = applyEventToMessages( | ||
| 508 | [], | ||
| 509 | makeEvent({ | ||
| 510 | method: "item/started", | ||
| 511 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 512 | payload: { | ||
| 513 | item: { | ||
| 514 | id: "item-1", | ||
| 515 | type: "agentMessage", | ||
| 516 | text: "Hello", | ||
| 517 | }, | ||
| 518 | }, | ||
| 519 | }), | ||
| 520 | activeAssistantItemRef, | ||
| 521 | ); | ||
| 522 | |||
| 523 | expect(started).toEqual([ | ||
| 524 | { | ||
| 525 | id: "item-1", | ||
| 526 | role: "assistant", | ||
| 527 | text: "Hello", | ||
| 528 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 529 | streaming: true, | ||
| 530 | }, | ||
| 531 | ]); | ||
| 532 | expect(activeAssistantItemRef.current).toBe("item-1"); | ||
| 533 | |||
| 534 | const withDelta = applyEventToMessages( | ||
| 535 | started, | ||
| 536 | makeEvent({ | ||
| 537 | method: "item/agentMessage/delta", | ||
| 538 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 539 | itemId: "item-1", | ||
| 540 | textDelta: " world", | ||
| 541 | }), | ||
| 542 | activeAssistantItemRef, | ||
| 543 | ); | ||
| 544 | expect(withDelta[0]?.text).toBe("Hello world"); | ||
| 545 | expect(withDelta[0]?.streaming).toBe(true); | ||
| 546 | |||
| 547 | const completed = applyEventToMessages( | ||
| 548 | withDelta, | ||
| 549 | makeEvent({ | ||
| 550 | method: "item/completed", | ||
| 551 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 552 | payload: { | ||
| 553 | item: { | ||
| 554 | id: "item-1", | ||
| 555 | type: "agentMessage", | ||
| 556 | text: "Hello world!", | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | }), | ||
| 560 | activeAssistantItemRef, | ||
| 561 | ); | ||
| 562 | expect(completed[0]?.text).toBe("Hello world!"); | ||
| 563 | expect(completed[0]?.streaming).toBe(false); | ||
| 564 | expect(activeAssistantItemRef.current).toBeNull(); | ||
| 565 | }); | ||
| 566 | |||
| 567 | it("supports out-of-order delta before started/completed", () => { | ||
| 568 | const activeAssistantItemRef = { current: null as string | null }; | ||
| 569 | const withDelta = applyEventToMessages( | ||
| 570 | [], | ||
| 571 | makeEvent({ | ||
| 572 | method: "item/agentMessage/delta", | ||
| 573 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 574 | itemId: "item-2", | ||
| 575 | textDelta: "Partial", | ||
| 576 | }), | ||
| 577 | activeAssistantItemRef, | ||
| 578 | ); | ||
| 579 | expect(withDelta[0]?.id).toBe("item-2"); | ||
| 580 | expect(withDelta[0]?.text).toBe("Partial"); | ||
| 581 | expect(withDelta[0]?.streaming).toBe(true); | ||
| 582 | expect(activeAssistantItemRef.current).toBe("item-2"); | ||
| 583 | |||
| 584 | const completed = applyEventToMessages( | ||
| 585 | withDelta, | ||
| 586 | makeEvent({ | ||
| 587 | method: "item/completed", | ||
| 588 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 589 | payload: { | ||
| 590 | item: { | ||
| 591 | id: "item-2", | ||
| 592 | type: "agentMessage", | ||
| 593 | text: "Partial + final", | ||
| 594 | }, | ||
| 595 | }, | ||
| 596 | }), | ||
| 597 | activeAssistantItemRef, | ||
| 598 | ); | ||
| 599 | |||
| 600 | expect(completed[0]?.text).toBe("Partial + final"); | ||
| 601 | expect(completed[0]?.streaming).toBe(false); | ||
| 602 | }); | ||
| 603 | |||
| 604 | it("clears all streaming flags when turn completes", () => { | ||
| 605 | const previous: ChatMessage[] = [ | ||
| 606 | { | ||
| 607 | id: "m-user", | ||
| 608 | role: "user", | ||
| 609 | text: "Hi", | ||
| 610 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 611 | streaming: false, | ||
| 612 | }, | ||
| 613 | { | ||
| 614 | id: "m-assistant", | ||
| 615 | role: "assistant", | ||
| 616 | text: "Typing", | ||
| 617 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 618 | streaming: true, | ||
| 619 | }, | ||
| 620 | ]; | ||
| 621 | |||
| 622 | const next = applyEventToMessages( | ||
| 623 | previous, | ||
| 624 | makeEvent({ | ||
| 625 | method: "turn/completed", | ||
| 626 | }), | ||
| 627 | { current: null }, | ||
| 628 | ); | ||
| 629 | |||
| 630 | expect(next.every((entry) => entry.streaming === false)).toBe(true); | ||
| 631 | }); | ||
| 632 | }); | ||
apps/renderer/src/session-logic.ts deleted-636| ... | @@ -1,636 +0,0 @@ | ||
| 1 | import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; | ||
| 2 | import type { ChatMessage, SessionPhase } from "./types"; | ||
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | ||
| 4 | |||
| 5 | export const PROVIDER_OPTIONS: Array<{ | ||
| 6 | value: ProviderKind; | ||
| 7 | label: string; | ||
| 8 | available: boolean; | ||
| 9 | }> = [ | ||
| 10 | { value: "codex", label: "Codex", available: true }, | ||
| 11 | { value: "claudeCode", label: "Claude Code (soon)", available: false }, | ||
| 12 | ]; | ||
| 13 | |||
| 14 | let cachedApi: NativeApi | undefined; | ||
| 15 | |||
| 16 | export function readNativeApi(): NativeApi | undefined { | ||
| 17 | if (typeof window === "undefined") return undefined; | ||
| 18 | if (cachedApi) return cachedApi; | ||
| 19 | |||
| 20 | // Prefer Electron preload bridge if available | ||
| 21 | if (window.nativeApi) { | ||
| 22 | cachedApi = window.nativeApi; | ||
| 23 | return cachedApi; | ||
| 24 | } | ||
| 25 | |||
| 26 | // Fall back to WebSocket transport | ||
| 27 | cachedApi = createWsNativeApi(); | ||
| 28 | return cachedApi; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function asObject(value: unknown): Record<string, unknown> | undefined { | ||
| 32 | if (!value || typeof value !== "object") return undefined; | ||
| 33 | return value as Record<string, unknown>; | ||
| 34 | } | ||
| 35 | |||
| 36 | export function asString(value: unknown): string | undefined { | ||
| 37 | return typeof value === "string" ? value : undefined; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function formatTimestamp(isoDate: string): string { | ||
| 41 | return new Intl.DateTimeFormat(undefined, { | ||
| 42 | hour: "numeric", | ||
| 43 | minute: "2-digit", | ||
| 44 | second: "2-digit", | ||
| 45 | }).format(new Date(isoDate)); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function formatDuration(durationMs: number): string { | ||
| 49 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; | ||
| 50 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; | ||
| 51 | if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; | ||
| 52 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; | ||
| 53 | |||
| 54 | const minutes = Math.floor(durationMs / 60_000); | ||
| 55 | const seconds = Math.round((durationMs % 60_000) / 1_000); | ||
| 56 | if (seconds === 0) return `${minutes}m`; | ||
| 57 | if (seconds === 60) return `${minutes + 1}m`; | ||
| 58 | return `${minutes}m ${seconds}s`; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { | ||
| 62 | if (!endIso) return null; | ||
| 63 | |||
| 64 | const startedAt = Date.parse(startIso); | ||
| 65 | const endedAt = Date.parse(endIso); | ||
| 66 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | |||
| 70 | return formatDuration(endedAt - startedAt); | ||
| 71 | } | ||
| 72 | |||
| 73 | export interface WorkLogEntry { | ||
| 74 | id: string; | ||
| 75 | createdAt: string; | ||
| 76 | label: string; | ||
| 77 | detail?: string; | ||
| 78 | tone: "thinking" | "tool" | "info" | "error"; | ||
| 79 | } | ||
| 80 | |||
| 81 | export type TimelineEntry = | ||
| 82 | | { | ||
| 83 | id: string; | ||
| 84 | kind: "message"; | ||
| 85 | createdAt: string; | ||
| 86 | message: ChatMessage; | ||
| 87 | } | ||
| 88 | | { | ||
| 89 | id: string; | ||
| 90 | kind: "work"; | ||
| 91 | createdAt: string; | ||
| 92 | entry: WorkLogEntry; | ||
| 93 | }; | ||
| 94 | |||
| 95 | function normalizeDetail(value: string | undefined): string | undefined { | ||
| 96 | if (!value) return undefined; | ||
| 97 | const trimmed = value.trim(); | ||
| 98 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 99 | } | ||
| 100 | |||
| 101 | function normalizeItemType(raw: string | undefined): string { | ||
| 102 | if (!raw) return "item"; | ||
| 103 | return raw | ||
| 104 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | ||
| 105 | .replace(/[._/-]/g, " ") | ||
| 106 | .replace(/\s+/g, " ") | ||
| 107 | .trim() | ||
| 108 | .toLowerCase(); | ||
| 109 | } | ||
| 110 | |||
| 111 | function shouldDropItemType(type: string): boolean { | ||
| 112 | if ( | ||
| 113 | type.includes("preamble") || | ||
| 114 | type.includes("reasoning") || | ||
| 115 | type.includes("thought") | ||
| 116 | ) { | ||
| 117 | return true; | ||
| 118 | } | ||
| 119 | |||
| 120 | return type === "work" || type.startsWith("work "); | ||
| 121 | } | ||
| 122 | |||
| 123 | function shouldShowItemLifecycle(type: string): boolean { | ||
| 124 | return ( | ||
| 125 | type.includes("tool") || | ||
| 126 | type.includes("command") || | ||
| 127 | type.includes("file change") | ||
| 128 | ); | ||
| 129 | } | ||
| 130 | |||
| 131 | function shouldDropMethod(method: string): boolean { | ||
| 132 | return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); | ||
| 133 | } | ||
| 134 | |||
| 135 | function itemTypeMeta(type: string): { | ||
| 136 | label: string; | ||
| 137 | tone: WorkLogEntry["tone"]; | ||
| 138 | } { | ||
| 139 | if (type.includes("command")) { | ||
| 140 | return { label: "Command run", tone: "tool" }; | ||
| 141 | } | ||
| 142 | if (type.includes("file change")) { | ||
| 143 | return { label: "File change", tone: "tool" }; | ||
| 144 | } | ||
| 145 | if (type.includes("tool")) { | ||
| 146 | return { label: "Tool call", tone: "tool" }; | ||
| 147 | } | ||
| 148 | return { label: "Work item", tone: "info" }; | ||
| 149 | } | ||
| 150 | |||
| 151 | interface ItemLifecycleCandidate { | ||
| 152 | id: string; | ||
| 153 | itemId?: string; | ||
| 154 | createdAt: string; | ||
| 155 | label: string; | ||
| 156 | detail?: string; | ||
| 157 | tone: WorkLogEntry["tone"]; | ||
| 158 | phase: "started" | "completed"; | ||
| 159 | } | ||
| 160 | |||
| 161 | function extractDetail( | ||
| 162 | payload: Record<string, unknown> | undefined, | ||
| 163 | item: Record<string, unknown> | undefined, | ||
| 164 | ): string | undefined { | ||
| 165 | const candidates = [ | ||
| 166 | asString(item?.command), | ||
| 167 | asString(item?.tool), | ||
| 168 | asString(item?.name), | ||
| 169 | asString(item?.title), | ||
| 170 | asString(item?.summary), | ||
| 171 | asString(item?.text), | ||
| 172 | asString(item?.prompt), | ||
| 173 | asString(payload?.message), | ||
| 174 | asString(payload?.prompt), | ||
| 175 | asString(payload?.command), | ||
| 176 | ]; | ||
| 177 | |||
| 178 | for (const candidate of candidates) { | ||
| 179 | const detail = normalizeDetail(candidate); | ||
| 180 | if (detail) return detail; | ||
| 181 | } | ||
| 182 | return undefined; | ||
| 183 | } | ||
| 184 | |||
| 185 | function lifecycleCandidateFromItemEvent( | ||
| 186 | event: ProviderEvent, | ||
| 187 | ): ItemLifecycleCandidate | null { | ||
| 188 | const payload = asObject(event.payload); | ||
| 189 | const item = asObject(payload?.item); | ||
| 190 | const normalizedType = normalizeItemType(asString(item?.type)); | ||
| 191 | if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { | ||
| 192 | return null; | ||
| 193 | } | ||
| 194 | if (!shouldShowItemLifecycle(normalizedType)) { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | |||
| 198 | const meta = itemTypeMeta(normalizedType); | ||
| 199 | const isStarted = event.method === "item/started"; | ||
| 200 | const isCompleted = event.method === "item/completed"; | ||
| 201 | if (!isStarted && !isCompleted) { | ||
| 202 | return null; | ||
| 203 | } | ||
| 204 | |||
| 205 | const detail = extractDetail(payload, item); | ||
| 206 | const itemId = event.itemId ?? asString(item?.id); | ||
| 207 | return { | ||
| 208 | id: event.id, | ||
| 209 | ...(itemId ? { itemId } : {}), | ||
| 210 | createdAt: event.createdAt, | ||
| 211 | label: meta.label, | ||
| 212 | ...(detail ? { detail } : {}), | ||
| 213 | tone: meta.tone, | ||
| 214 | phase: isCompleted ? "completed" : "started", | ||
| 215 | }; | ||
| 216 | } | ||
| 217 | |||
| 218 | function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { | ||
| 219 | if (event.kind !== "request") return null; | ||
| 220 | if (shouldDropMethod(event.method)) return null; | ||
| 221 | |||
| 222 | if (event.method.includes("commandExecution")) { | ||
| 223 | return { | ||
| 224 | id: event.id, | ||
| 225 | createdAt: event.createdAt, | ||
| 226 | label: "Command approval requested", | ||
| 227 | tone: "tool", | ||
| 228 | }; | ||
| 229 | } | ||
| 230 | |||
| 231 | if (event.method.includes("fileChange")) { | ||
| 232 | return { | ||
| 233 | id: event.id, | ||
| 234 | createdAt: event.createdAt, | ||
| 235 | label: "File-change approval requested", | ||
| 236 | tone: "tool", | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | |||
| 240 | if (event.method.includes("requestUserInput")) { | ||
| 241 | return { | ||
| 242 | id: event.id, | ||
| 243 | createdAt: event.createdAt, | ||
| 244 | label: "Tool requested user input", | ||
| 245 | tone: "tool", | ||
| 246 | }; | ||
| 247 | } | ||
| 248 | |||
| 249 | return { | ||
| 250 | id: event.id, | ||
| 251 | createdAt: event.createdAt, | ||
| 252 | label: `Request: ${event.method}`, | ||
| 253 | tone: "info", | ||
| 254 | }; | ||
| 255 | } | ||
| 256 | |||
| 257 | function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { | ||
| 258 | if (event.kind !== "notification") return null; | ||
| 259 | if (shouldDropMethod(event.method)) return null; | ||
| 260 | if (event.method === "item/agentMessage/delta") return null; | ||
| 261 | if (event.method === "turn/started") { | ||
| 262 | return null; | ||
| 263 | } | ||
| 264 | if (event.method === "thread/started") return null; | ||
| 265 | |||
| 266 | if (event.method === "turn/completed") { | ||
| 267 | const payload = asObject(event.payload); | ||
| 268 | const turn = asObject(payload?.turn); | ||
| 269 | const status = asString(turn?.status); | ||
| 270 | if (status !== "failed") { | ||
| 271 | return null; | ||
| 272 | } | ||
| 273 | const turnError = asObject(turn?.error); | ||
| 274 | const turnErrorMessage = asString(turnError?.message); | ||
| 275 | const turnErrorDetail = normalizeDetail(turnErrorMessage); | ||
| 276 | |||
| 277 | return { | ||
| 278 | id: event.id, | ||
| 279 | createdAt: event.createdAt, | ||
| 280 | label: "Turn failed", | ||
| 281 | ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), | ||
| 282 | tone: "error", | ||
| 283 | }; | ||
| 284 | } | ||
| 285 | |||
| 286 | if (event.method.startsWith("item/")) return null; | ||
| 287 | |||
| 288 | return null; | ||
| 289 | } | ||
| 290 | |||
| 291 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { | ||
| 292 | if (event.kind !== "error") return null; | ||
| 293 | if (shouldDropMethod(event.method)) return null; | ||
| 294 | const detail = normalizeDetail(event.message); | ||
| 295 | |||
| 296 | return { | ||
| 297 | id: event.id, | ||
| 298 | createdAt: event.createdAt, | ||
| 299 | label: "Runtime error", | ||
| 300 | ...(detail ? { detail } : {}), | ||
| 301 | tone: "error", | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | function eventTurnId(event: ProviderEvent): string | undefined { | ||
| 306 | const payload = asObject(event.payload); | ||
| 307 | const turn = asObject(payload?.turn); | ||
| 308 | return event.turnId ?? asString(turn?.id); | ||
| 309 | } | ||
| 310 | |||
| 311 | export function deriveWorkLogEntries( | ||
| 312 | events: ProviderEvent[], | ||
| 313 | turnId: string | undefined, | ||
| 314 | ): WorkLogEntry[] { | ||
| 315 | const ordered = [...events].toReversed(); | ||
| 316 | const entries: WorkLogEntry[] = []; | ||
| 317 | const turnStartedAtIso = turnId | ||
| 318 | ? ordered.find((event) => { | ||
| 319 | if (event.method !== "turn/started") return false; | ||
| 320 | return eventTurnId(event) === turnId; | ||
| 321 | })?.createdAt | ||
| 322 | : undefined; | ||
| 323 | const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; | ||
| 324 | |||
| 325 | const shouldIncludeEvent = (event: ProviderEvent): boolean => { | ||
| 326 | if (!turnId) return true; | ||
| 327 | const scopedTurnId = eventTurnId(event); | ||
| 328 | if (scopedTurnId && scopedTurnId !== turnId) { | ||
| 329 | return false; | ||
| 330 | } | ||
| 331 | |||
| 332 | if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { | ||
| 333 | const eventAt = Date.parse(event.createdAt); | ||
| 334 | if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { | ||
| 335 | return false; | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | return true; | ||
| 340 | }; | ||
| 341 | |||
| 342 | const completedLifecycleItemIds = new Set<string>(); | ||
| 343 | for (const event of ordered) { | ||
| 344 | if (!shouldIncludeEvent(event)) continue; | ||
| 345 | const candidate = lifecycleCandidateFromItemEvent(event); | ||
| 346 | if (candidate?.phase === "completed" && candidate.itemId) { | ||
| 347 | completedLifecycleItemIds.add(candidate.itemId); | ||
| 348 | } | ||
| 349 | } | ||
| 350 | |||
| 351 | for (const event of ordered) { | ||
| 352 | if (!shouldIncludeEvent(event)) continue; | ||
| 353 | |||
| 354 | const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); | ||
| 355 | if (lifecycleCandidate) { | ||
| 356 | if ( | ||
| 357 | lifecycleCandidate.phase === "started" && | ||
| 358 | lifecycleCandidate.itemId && | ||
| 359 | completedLifecycleItemIds.has(lifecycleCandidate.itemId) | ||
| 360 | ) { | ||
| 361 | continue; | ||
| 362 | } | ||
| 363 | if ( | ||
| 364 | lifecycleCandidate.label === "Tool call" && | ||
| 365 | !lifecycleCandidate.detail | ||
| 366 | ) { | ||
| 367 | continue; | ||
| 368 | } | ||
| 369 | |||
| 370 | entries.push({ | ||
| 371 | id: lifecycleCandidate.id, | ||
| 372 | createdAt: lifecycleCandidate.createdAt, | ||
| 373 | label: lifecycleCandidate.label, | ||
| 374 | ...(lifecycleCandidate.detail | ||
| 375 | ? { detail: lifecycleCandidate.detail } | ||
| 376 | : {}), | ||
| 377 | tone: lifecycleCandidate.tone, | ||
| 378 | }); | ||
| 379 | continue; | ||
| 380 | } | ||
| 381 | |||
| 382 | const fromRequest = entryFromRequest(event); | ||
| 383 | if (fromRequest) { | ||
| 384 | entries.push(fromRequest); | ||
| 385 | continue; | ||
| 386 | } | ||
| 387 | |||
| 388 | const fromNotification = entryFromNotification(event); | ||
| 389 | if (fromNotification) { | ||
| 390 | entries.push(fromNotification); | ||
| 391 | continue; | ||
| 392 | } | ||
| 393 | |||
| 394 | const fromError = entryFromError(event); | ||
| 395 | if (fromError) { | ||
| 396 | entries.push(fromError); | ||
| 397 | } | ||
| 398 | } | ||
| 399 | |||
| 400 | return entries; | ||
| 401 | } | ||
| 402 | |||
| 403 | function toTimestamp(isoDate: string): number { | ||
| 404 | const parsed = Date.parse(isoDate); | ||
| 405 | return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; | ||
| 406 | } | ||
| 407 | |||
| 408 | export function deriveTimelineEntries( | ||
| 409 | messages: ChatMessage[], | ||
| 410 | workEntries: WorkLogEntry[], | ||
| 411 | ): TimelineEntry[] { | ||
| 412 | const timeline: TimelineEntry[] = []; | ||
| 413 | let messageIndex = 0; | ||
| 414 | let workIndex = 0; | ||
| 415 | |||
| 416 | while (messageIndex < messages.length || workIndex < workEntries.length) { | ||
| 417 | const message = messages[messageIndex]; | ||
| 418 | const workEntry = workEntries[workIndex]; | ||
| 419 | |||
| 420 | if (!message && workEntry) { | ||
| 421 | timeline.push({ | ||
| 422 | id: `work:${workEntry.id}`, | ||
| 423 | kind: "work", | ||
| 424 | createdAt: workEntry.createdAt, | ||
| 425 | entry: workEntry, | ||
| 426 | }); | ||
| 427 | workIndex += 1; | ||
| 428 | continue; | ||
| 429 | } | ||
| 430 | |||
| 431 | if (!workEntry && message) { | ||
| 432 | timeline.push({ | ||
| 433 | id: `message:${message.id}`, | ||
| 434 | kind: "message", | ||
| 435 | createdAt: message.createdAt, | ||
| 436 | message, | ||
| 437 | }); | ||
| 438 | messageIndex += 1; | ||
| 439 | continue; | ||
| 440 | } | ||
| 441 | |||
| 442 | if (!message || !workEntry) { | ||
| 443 | break; | ||
| 444 | } | ||
| 445 | |||
| 446 | const messageAt = toTimestamp(message.createdAt); | ||
| 447 | const workAt = toTimestamp(workEntry.createdAt); | ||
| 448 | |||
| 449 | if (workAt <= messageAt) { | ||
| 450 | timeline.push({ | ||
| 451 | id: `work:${workEntry.id}`, | ||
| 452 | kind: "work", | ||
| 453 | createdAt: workEntry.createdAt, | ||
| 454 | entry: workEntry, | ||
| 455 | }); | ||
| 456 | workIndex += 1; | ||
| 457 | continue; | ||
| 458 | } | ||
| 459 | |||
| 460 | timeline.push({ | ||
| 461 | id: `message:${message.id}`, | ||
| 462 | kind: "message", | ||
| 463 | createdAt: message.createdAt, | ||
| 464 | message, | ||
| 465 | }); | ||
| 466 | messageIndex += 1; | ||
| 467 | } | ||
| 468 | |||
| 469 | return timeline; | ||
| 470 | } | ||
| 471 | |||
| 472 | export function derivePhase(session: ProviderSession | null): SessionPhase { | ||
| 473 | if (!session || session.status === "closed") return "disconnected"; | ||
| 474 | if (session.status === "connecting") return "connecting"; | ||
| 475 | if (session.status === "running") return "running"; | ||
| 476 | return "ready"; | ||
| 477 | } | ||
| 478 | |||
| 479 | export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession { | ||
| 480 | const payload = asObject(event.payload); | ||
| 481 | |||
| 482 | if (event.method === "thread/started") { | ||
| 483 | const thread = asObject(payload?.thread); | ||
| 484 | return { | ||
| 485 | ...previous, | ||
| 486 | threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, | ||
| 487 | updatedAt: event.createdAt, | ||
| 488 | }; | ||
| 489 | } | ||
| 490 | |||
| 491 | if (event.method === "turn/started") { | ||
| 492 | const turn = asObject(payload?.turn); | ||
| 493 | return { | ||
| 494 | ...previous, | ||
| 495 | status: "running", | ||
| 496 | activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, | ||
| 497 | updatedAt: event.createdAt, | ||
| 498 | }; | ||
| 499 | } | ||
| 500 | |||
| 501 | if (event.method === "turn/completed") { | ||
| 502 | const turn = asObject(payload?.turn); | ||
| 503 | const status = asString(turn?.status); | ||
| 504 | const turnError = asObject(turn?.error); | ||
| 505 | return { | ||
| 506 | ...previous, | ||
| 507 | status: status === "failed" ? "error" : "ready", | ||
| 508 | activeTurnId: undefined, | ||
| 509 | lastError: asString(turnError?.message) ?? previous.lastError, | ||
| 510 | updatedAt: event.createdAt, | ||
| 511 | }; | ||
| 512 | } | ||
| 513 | |||
| 514 | if (event.kind === "error") { | ||
| 515 | return { | ||
| 516 | ...previous, | ||
| 517 | status: "error", | ||
| 518 | lastError: event.message ?? previous.lastError, | ||
| 519 | updatedAt: event.createdAt, | ||
| 520 | }; | ||
| 521 | } | ||
| 522 | |||
| 523 | if (event.method === "session/closed" || event.method === "session/exited") { | ||
| 524 | return { | ||
| 525 | ...previous, | ||
| 526 | status: "closed", | ||
| 527 | activeTurnId: undefined, | ||
| 528 | lastError: event.message ?? previous.lastError, | ||
| 529 | updatedAt: event.createdAt, | ||
| 530 | }; | ||
| 531 | } | ||
| 532 | |||
| 533 | return { ...previous, updatedAt: event.createdAt }; | ||
| 534 | } | ||
| 535 | |||
| 536 | export function applyEventToMessages( | ||
| 537 | previous: ChatMessage[], | ||
| 538 | event: ProviderEvent, | ||
| 539 | activeAssistantItemRef: { current: string | null }, | ||
| 540 | ): ChatMessage[] { | ||
| 541 | const payload = asObject(event.payload); | ||
| 542 | |||
| 543 | if (event.method === "item/started") { | ||
| 544 | const item = asObject(payload?.item); | ||
| 545 | if (asString(item?.type) !== "agentMessage") return previous; | ||
| 546 | const itemId = asString(item?.id); | ||
| 547 | if (!itemId) return previous; | ||
| 548 | |||
| 549 | activeAssistantItemRef.current = itemId; | ||
| 550 | const seedText = asString(item?.text) ?? ""; | ||
| 551 | const filtered = previous.filter((entry) => entry.id !== itemId); | ||
| 552 | return [ | ||
| 553 | ...filtered, | ||
| 554 | { | ||
| 555 | id: itemId, | ||
| 556 | role: "assistant", | ||
| 557 | text: seedText, | ||
| 558 | createdAt: event.createdAt, | ||
| 559 | streaming: true, | ||
| 560 | }, | ||
| 561 | ]; | ||
| 562 | } | ||
| 563 | |||
| 564 | if (event.method === "item/agentMessage/delta") { | ||
| 565 | const itemId = event.itemId ?? asString(payload?.itemId); | ||
| 566 | const delta = event.textDelta ?? asString(payload?.delta) ?? ""; | ||
| 567 | if (!itemId || !delta) return previous; | ||
| 568 | |||
| 569 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | ||
| 570 | if (existingIndex === -1) { | ||
| 571 | activeAssistantItemRef.current = itemId; | ||
| 572 | return [ | ||
| 573 | ...previous, | ||
| 574 | { | ||
| 575 | id: itemId, | ||
| 576 | role: "assistant", | ||
| 577 | text: delta, | ||
| 578 | createdAt: event.createdAt, | ||
| 579 | streaming: true, | ||
| 580 | }, | ||
| 581 | ]; | ||
| 582 | } | ||
| 583 | |||
| 584 | const updated = [...previous]; | ||
| 585 | const existing = updated[existingIndex]; | ||
| 586 | if (!existing) return previous; | ||
| 587 | updated[existingIndex] = { | ||
| 588 | ...existing, | ||
| 589 | text: `${existing.text}${delta}`, | ||
| 590 | streaming: true, | ||
| 591 | }; | ||
| 592 | return updated; | ||
| 593 | } | ||
| 594 | |||
| 595 | if (event.method === "item/completed") { | ||
| 596 | const item = asObject(payload?.item); | ||
| 597 | if (asString(item?.type) !== "agentMessage") return previous; | ||
| 598 | const itemId = asString(item?.id); | ||
| 599 | if (!itemId) return previous; | ||
| 600 | |||
| 601 | const fullText = asString(item?.text); | ||
| 602 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | ||
| 603 | if (existingIndex === -1) { | ||
| 604 | return [ | ||
| 605 | ...previous, | ||
| 606 | { | ||
| 607 | id: itemId, | ||
| 608 | role: "assistant", | ||
| 609 | text: fullText ?? "", | ||
| 610 | createdAt: event.createdAt, | ||
| 611 | streaming: false, | ||
| 612 | }, | ||
| 613 | ]; | ||
| 614 | } | ||
| 615 | |||
| 616 | const updated = [...previous]; | ||
| 617 | const existing = updated[existingIndex]; | ||
| 618 | if (!existing) return previous; | ||
| 619 | updated[existingIndex] = { | ||
| 620 | ...existing, | ||
| 621 | text: fullText ?? existing.text, | ||
| 622 | streaming: false, | ||
| 623 | }; | ||
| 624 | |||
| 625 | if (activeAssistantItemRef.current === itemId) { | ||
| 626 | activeAssistantItemRef.current = null; | ||
| 627 | } | ||
| 628 | return updated; | ||
| 629 | } | ||
| 630 | |||
| 631 | if (event.method === "turn/completed") { | ||
| 632 | return previous.map((entry) => ({ ...entry, streaming: false })); | ||
| 633 | } | ||
| 634 | |||
| 635 | return previous; | ||
| 636 | } | ||
apps/renderer/src/store.test.ts deleted-188| ... | @@ -1,188 +0,0 @@ | ||
| 1 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { type AppState, reducer } from "./store"; | ||
| 5 | import type { Thread } from "./types"; | ||
| 6 | |||
| 7 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { | ||
| 8 | return { | ||
| 9 | sessionId: "sess-1", | ||
| 10 | provider: "codex", | ||
| 11 | status: "ready", | ||
| 12 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 13 | updatedAt: "2026-02-09T00:00:00.000Z", | ||
| 14 | ...overrides, | ||
| 15 | }; | ||
| 16 | } | ||
| 17 | |||
| 18 | function makeEvent(overrides: Partial<ProviderEvent> = {}): ProviderEvent { | ||
| 19 | return { | ||
| 20 | id: "evt-1", | ||
| 21 | kind: "notification", | ||
| 22 | provider: "codex", | ||
| 23 | sessionId: "sess-1", | ||
| 24 | createdAt: "2026-02-09T00:00:01.000Z", | ||
| 25 | method: "thread/started", | ||
| 26 | ...overrides, | ||
| 27 | }; | ||
| 28 | } | ||
| 29 | |||
| 30 | function makeThread(overrides: Partial<Thread> = {}): Thread { | ||
| 31 | return { | ||
| 32 | id: "thread-local-1", | ||
| 33 | codexThreadId: null, | ||
| 34 | projectId: "project-1", | ||
| 35 | title: "Thread", | ||
| 36 | model: "gpt-5.3-codex", | ||
| 37 | session: makeSession(), | ||
| 38 | messages: [], | ||
| 39 | events: [], | ||
| 40 | error: null, | ||
| 41 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 42 | ...overrides, | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | function makeState(thread: Thread): AppState { | ||
| 47 | return { | ||
| 48 | projects: [ | ||
| 49 | { | ||
| 50 | id: "project-1", | ||
| 51 | name: "Project", | ||
| 52 | cwd: "/tmp/project", | ||
| 53 | model: "gpt-5.3-codex", | ||
| 54 | expanded: true, | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | threads: [thread], | ||
| 58 | activeThreadId: thread.id, | ||
| 59 | runtimeMode: "full-access", | ||
| 60 | diffOpen: false, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | describe("store reducer thread continuity", () => { | ||
| 65 | it("stores codexThreadId from UPDATE_SESSION", () => { | ||
| 66 | const state = makeState( | ||
| 67 | makeThread({ | ||
| 68 | session: null, | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | const next = reducer(state, { | ||
| 72 | type: "UPDATE_SESSION", | ||
| 73 | threadId: "thread-local-1", | ||
| 74 | session: makeSession({ threadId: "thr_123" }), | ||
| 75 | }); | ||
| 76 | |||
| 77 | expect(next.threads[0]?.codexThreadId).toBe("thr_123"); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("backfills codexThreadId from routed provider events", () => { | ||
| 81 | const state = makeState(makeThread({ codexThreadId: null })); | ||
| 82 | const next = reducer(state, { | ||
| 83 | type: "APPLY_EVENT", | ||
| 84 | event: makeEvent({ | ||
| 85 | method: "thread/started", | ||
| 86 | payload: { thread: { id: "thr_backfilled" } }, | ||
| 87 | }), | ||
| 88 | activeAssistantItemRef: { current: null }, | ||
| 89 | }); | ||
| 90 | |||
| 91 | expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("ignores events from a foreign thread within the same session", () => { | ||
| 95 | const state = makeState(makeThread({ codexThreadId: "thr_expected" })); | ||
| 96 | const next = reducer(state, { | ||
| 97 | type: "APPLY_EVENT", | ||
| 98 | event: makeEvent({ | ||
| 99 | method: "turn/started", | ||
| 100 | threadId: "thr_unexpected", | ||
| 101 | payload: { turn: { id: "turn-1" } }, | ||
| 102 | }), | ||
| 103 | activeAssistantItemRef: { current: null }, | ||
| 104 | }); | ||
| 105 | |||
| 106 | expect(next).toBe(state); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("rebases thread identity on thread/started during connect", () => { | ||
| 110 | const state = makeState( | ||
| 111 | makeThread({ | ||
| 112 | codexThreadId: "thr_old", | ||
| 113 | session: makeSession({ | ||
| 114 | status: "connecting", | ||
| 115 | threadId: "thr_old", | ||
| 116 | }), | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | const next = reducer(state, { | ||
| 120 | type: "APPLY_EVENT", | ||
| 121 | event: makeEvent({ | ||
| 122 | method: "thread/started", | ||
| 123 | threadId: "thr_new", | ||
| 124 | payload: { thread: { id: "thr_new" } }, | ||
| 125 | }), | ||
| 126 | activeAssistantItemRef: { current: null }, | ||
| 127 | }); | ||
| 128 | |||
| 129 | expect(next.threads[0]?.codexThreadId).toBe("thr_new"); | ||
| 130 | expect(next.threads[0]?.session?.threadId).toBe("thr_new"); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("reconciles project ids by cwd when syncing backend projects", () => { | ||
| 134 | const state: AppState = { | ||
| 135 | projects: [ | ||
| 136 | { | ||
| 137 | id: "project-old-a", | ||
| 138 | name: "A", | ||
| 139 | cwd: "/tmp/a", | ||
| 140 | model: "gpt-5.3-codex", | ||
| 141 | expanded: false, | ||
| 142 | }, | ||
| 143 | { | ||
| 144 | id: "project-old-b", | ||
| 145 | name: "B", | ||
| 146 | cwd: "/tmp/b", | ||
| 147 | model: "gpt-5.3-codex", | ||
| 148 | expanded: true, | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | threads: [ | ||
| 152 | makeThread({ | ||
| 153 | id: "thread-a", | ||
| 154 | projectId: "project-old-a", | ||
| 155 | }), | ||
| 156 | makeThread({ | ||
| 157 | id: "thread-b", | ||
| 158 | projectId: "project-old-b", | ||
| 159 | }), | ||
| 160 | ], | ||
| 161 | activeThreadId: "thread-b", | ||
| 162 | runtimeMode: "full-access", | ||
| 163 | diffOpen: false, | ||
| 164 | }; | ||
| 165 | |||
| 166 | const next = reducer(state, { | ||
| 167 | type: "SYNC_PROJECTS", | ||
| 168 | projects: [ | ||
| 169 | { | ||
| 170 | id: "project-new-a", | ||
| 171 | name: "A", | ||
| 172 | cwd: "/tmp/a", | ||
| 173 | model: "gpt-5.3-codex", | ||
| 174 | expanded: true, | ||
| 175 | }, | ||
| 176 | ], | ||
| 177 | }); | ||
| 178 | |||
| 179 | expect(next.projects).toHaveLength(1); | ||
| 180 | expect(next.projects[0]?.id).toBe("project-new-a"); | ||
| 181 | // Preserve existing project UI preferences by cwd | ||
| 182 | expect(next.projects[0]?.expanded).toBe(false); | ||
| 183 | expect(next.threads).toHaveLength(1); | ||
| 184 | expect(next.threads[0]?.id).toBe("thread-a"); | ||
| 185 | expect(next.threads[0]?.projectId).toBe("project-new-a"); | ||
| 186 | expect(next.activeThreadId).toBe("thread-a"); | ||
| 187 | }); | ||
| 188 | }); | ||
apps/renderer/src/store.ts deleted-402| ... | @@ -1,402 +0,0 @@ | ||
| 1 | import { | ||
| 2 | type Dispatch, | ||
| 3 | type ReactNode, | ||
| 4 | createContext, | ||
| 5 | createElement, | ||
| 6 | useContext, | ||
| 7 | useEffect, | ||
| 8 | useReducer, | ||
| 9 | } from "react"; | ||
| 10 | |||
| 11 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 12 | import { resolveModelSlug } from "./model-logic"; | ||
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | ||
| 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; | ||
| 15 | import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types"; | ||
| 16 | |||
| 17 | // ── Actions ────────────────────────────────────────────────────────── | ||
| 18 | |||
| 19 | type Action = | ||
| 20 | | { type: "ADD_PROJECT"; project: Project } | ||
| 21 | | { type: "SYNC_PROJECTS"; projects: Project[] } | ||
| 22 | | { type: "TOGGLE_PROJECT"; projectId: string } | ||
| 23 | | { type: "ADD_THREAD"; thread: Thread } | ||
| 24 | | { type: "SET_ACTIVE_THREAD"; threadId: string } | ||
| 25 | | { type: "TOGGLE_DIFF" } | ||
| 26 | | { | ||
| 27 | type: "APPLY_EVENT"; | ||
| 28 | event: ProviderEvent; | ||
| 29 | activeAssistantItemRef: { current: string | null }; | ||
| 30 | } | ||
| 31 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | ||
| 32 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } | ||
| 33 | | { type: "SET_ERROR"; threadId: string; error: string | null } | ||
| 34 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | ||
| 35 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } | ||
| 36 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; | ||
| 37 | |||
| 38 | // ── State ──────────────────────────────────────────────────────────── | ||
| 39 | |||
| 40 | export interface AppState { | ||
| 41 | projects: Project[]; | ||
| 42 | threads: Thread[]; | ||
| 43 | activeThreadId: string | null; | ||
| 44 | runtimeMode: RuntimeMode; | ||
| 45 | diffOpen: boolean; | ||
| 46 | } | ||
| 47 | |||
| 48 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v4"; | ||
| 49 | const LEGACY_PERSISTED_STATE_KEYS = [ | ||
| 50 | "t3code:renderer-state:v3", | ||
| 51 | "codething:renderer-state:v4", | ||
| 52 | "codething:renderer-state:v3", | ||
| 53 | "codething:renderer-state:v2", | ||
| 54 | "codething:renderer-state:v1", | ||
| 55 | ] as const; | ||
| 56 | |||
| 57 | const initialState: AppState = { | ||
| 58 | projects: [], | ||
| 59 | threads: [], | ||
| 60 | activeThreadId: null, | ||
| 61 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 62 | diffOpen: false, | ||
| 63 | }; | ||
| 64 | |||
| 65 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 66 | |||
| 67 | function readPersistedState(): AppState { | ||
| 68 | if (typeof window === "undefined") return initialState; | ||
| 69 | |||
| 70 | try { | ||
| 71 | const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); | ||
| 72 | const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS; | ||
| 73 | const rawLegacyV3 = window.localStorage.getItem(legacyV3Key); | ||
| 74 | const rawLegacyV2 = window.localStorage.getItem(legacyV2Key); | ||
| 75 | const rawLegacyV1 = window.localStorage.getItem(legacyV1Key); | ||
| 76 | const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1; | ||
| 77 | if (!raw) return initialState; | ||
| 78 | const hydrated = hydratePersistedState( | ||
| 79 | raw, | ||
| 80 | !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1), | ||
| 81 | ); | ||
| 82 | if (!hydrated) return initialState; | ||
| 83 | |||
| 84 | return { ...hydrated, diffOpen: false }; | ||
| 85 | } catch { | ||
| 86 | return initialState; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | function persistState(state: AppState): void { | ||
| 91 | if (typeof window === "undefined") return; | ||
| 92 | |||
| 93 | try { | ||
| 94 | window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); | ||
| 95 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { | ||
| 96 | window.localStorage.removeItem(legacyKey); | ||
| 97 | } | ||
| 98 | } catch { | ||
| 99 | // Ignore quota/storage errors to avoid breaking chat UX. | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | function updateThread( | ||
| 104 | threads: Thread[], | ||
| 105 | threadId: string, | ||
| 106 | updater: (t: Thread) => Thread, | ||
| 107 | ): Thread[] { | ||
| 108 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); | ||
| 109 | } | ||
| 110 | |||
| 111 | function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { | ||
| 112 | return threads.find((t) => t.session?.sessionId === sessionId); | ||
| 113 | } | ||
| 114 | |||
| 115 | function getEventTurnId(event: ProviderEvent): string | undefined { | ||
| 116 | if (event.turnId) return event.turnId; | ||
| 117 | const payload = asObject(event.payload); | ||
| 118 | const turn = asObject(payload?.turn); | ||
| 119 | return asString(turn?.id); | ||
| 120 | } | ||
| 121 | |||
| 122 | function getEventThreadId(event: ProviderEvent): string | undefined { | ||
| 123 | if (event.threadId) return event.threadId; | ||
| 124 | const payload = asObject(event.payload); | ||
| 125 | const payloadThread = asObject(payload?.thread); | ||
| 126 | const payloadMessage = asObject(payload?.msg); | ||
| 127 | return ( | ||
| 128 | asString(payload?.threadId) ?? | ||
| 129 | asString(payloadThread?.id) ?? | ||
| 130 | asString(payload?.conversationId) ?? | ||
| 131 | asString(payload?.thread_id) ?? | ||
| 132 | asString(payloadMessage?.thread_id) | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | |||
| 136 | function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { | ||
| 137 | const eventThreadId = getEventThreadId(event); | ||
| 138 | if (!eventThreadId) { | ||
| 139 | return false; | ||
| 140 | } | ||
| 141 | |||
| 142 | const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; | ||
| 143 | if (!expectedThreadId || eventThreadId === expectedThreadId) { | ||
| 144 | return false; | ||
| 145 | } | ||
| 146 | |||
| 147 | // During connect, accept a thread/started notification as an identity rebind. | ||
| 148 | if (event.method === "thread/started" && thread.session?.status === "connecting") { | ||
| 149 | return false; | ||
| 150 | } | ||
| 151 | |||
| 152 | return true; | ||
| 153 | } | ||
| 154 | |||
| 155 | function durationMs(startIso: string, endIso: string): number | undefined { | ||
| 156 | const start = Date.parse(startIso); | ||
| 157 | const end = Date.parse(endIso); | ||
| 158 | if (Number.isNaN(start) || Number.isNaN(end) || end < start) { | ||
| 159 | return undefined; | ||
| 160 | } | ||
| 161 | |||
| 162 | return end - start; | ||
| 163 | } | ||
| 164 | |||
| 165 | function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> { | ||
| 166 | if (event.method === "turn/started") { | ||
| 167 | return { | ||
| 168 | latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, | ||
| 169 | latestTurnStartedAt: event.createdAt, | ||
| 170 | latestTurnCompletedAt: undefined, | ||
| 171 | latestTurnDurationMs: undefined, | ||
| 172 | }; | ||
| 173 | } | ||
| 174 | |||
| 175 | if (event.method === "turn/completed") { | ||
| 176 | const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; | ||
| 177 | const startedAt = | ||
| 178 | completedTurnId && completedTurnId === thread.latestTurnId | ||
| 179 | ? thread.latestTurnStartedAt | ||
| 180 | : undefined; | ||
| 181 | const elapsed = | ||
| 182 | startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; | ||
| 183 | |||
| 184 | return { | ||
| 185 | latestTurnId: completedTurnId ?? thread.latestTurnId, | ||
| 186 | latestTurnCompletedAt: event.createdAt, | ||
| 187 | latestTurnDurationMs: elapsed, | ||
| 188 | }; | ||
| 189 | } | ||
| 190 | |||
| 191 | return {}; | ||
| 192 | } | ||
| 193 | |||
| 194 | // ── Reducer ────────────────────────────────────────────────────────── | ||
| 195 | |||
| 196 | export function reducer(state: AppState, action: Action): AppState { | ||
| 197 | switch (action.type) { | ||
| 198 | case "ADD_PROJECT": | ||
| 199 | if (state.projects.some((project) => project.cwd === action.project.cwd)) { | ||
| 200 | return state; | ||
| 201 | } | ||
| 202 | return { | ||
| 203 | ...state, | ||
| 204 | projects: [ | ||
| 205 | ...state.projects, | ||
| 206 | { | ||
| 207 | ...action.project, | ||
| 208 | model: resolveModelSlug(action.project.model), | ||
| 209 | }, | ||
| 210 | ], | ||
| 211 | }; | ||
| 212 | |||
| 213 | case "SYNC_PROJECTS": { | ||
| 214 | const previousByCwd = new Map( | ||
| 215 | state.projects.map((project) => [project.cwd, project] as const), | ||
| 216 | ); | ||
| 217 | const nextProjects = action.projects.map((project) => { | ||
| 218 | const previous = previousByCwd.get(project.cwd); | ||
| 219 | return { | ||
| 220 | ...project, | ||
| 221 | model: resolveModelSlug(previous?.model ?? project.model), | ||
| 222 | expanded: previous?.expanded ?? project.expanded, | ||
| 223 | }; | ||
| 224 | }); | ||
| 225 | const previousProjectById = new Map( | ||
| 226 | state.projects.map((project) => [project.id, project] as const), | ||
| 227 | ); | ||
| 228 | const nextProjectIdByCwd = new Map( | ||
| 229 | nextProjects.map((project) => [project.cwd, project.id] as const), | ||
| 230 | ); | ||
| 231 | const nextThreads = state.threads | ||
| 232 | .map((thread) => { | ||
| 233 | const previousProject = previousProjectById.get(thread.projectId); | ||
| 234 | if (!previousProject) return null; | ||
| 235 | const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); | ||
| 236 | if (!mappedProjectId) return null; | ||
| 237 | return { | ||
| 238 | ...thread, | ||
| 239 | projectId: mappedProjectId, | ||
| 240 | }; | ||
| 241 | }) | ||
| 242 | .filter((thread): thread is Thread => thread !== null); | ||
| 243 | const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) | ||
| 244 | ? state.activeThreadId | ||
| 245 | : (nextThreads[0]?.id ?? null); | ||
| 246 | |||
| 247 | return { | ||
| 248 | ...state, | ||
| 249 | projects: nextProjects, | ||
| 250 | threads: nextThreads, | ||
| 251 | activeThreadId, | ||
| 252 | }; | ||
| 253 | } | ||
| 254 | |||
| 255 | case "TOGGLE_PROJECT": | ||
| 256 | return { | ||
| 257 | ...state, | ||
| 258 | projects: state.projects.map((p) => | ||
| 259 | p.id === action.projectId ? { ...p, expanded: !p.expanded } : p, | ||
| 260 | ), | ||
| 261 | }; | ||
| 262 | |||
| 263 | case "ADD_THREAD": | ||
| 264 | return { | ||
| 265 | ...state, | ||
| 266 | threads: [ | ||
| 267 | ...state.threads, | ||
| 268 | { | ||
| 269 | ...action.thread, | ||
| 270 | model: resolveModelSlug(action.thread.model), | ||
| 271 | }, | ||
| 272 | ], | ||
| 273 | activeThreadId: action.thread.id, | ||
| 274 | }; | ||
| 275 | |||
| 276 | case "SET_ACTIVE_THREAD": | ||
| 277 | return { ...state, activeThreadId: action.threadId }; | ||
| 278 | |||
| 279 | case "TOGGLE_DIFF": | ||
| 280 | return { ...state, diffOpen: !state.diffOpen }; | ||
| 281 | |||
| 282 | case "APPLY_EVENT": { | ||
| 283 | const { event, activeAssistantItemRef } = action; | ||
| 284 | const target = findThreadBySessionId(state.threads, event.sessionId); | ||
| 285 | if (!target) return state; | ||
| 286 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; | ||
| 287 | |||
| 288 | return { | ||
| 289 | ...state, | ||
| 290 | threads: updateThread(state.threads, target.id, (t) => ({ | ||
| 291 | ...t, | ||
| 292 | ...(() => { | ||
| 293 | const eventThreadId = getEventThreadId(event); | ||
| 294 | const shouldRebindIdentity = | ||
| 295 | event.method === "thread/started" && t.session?.status === "connecting"; | ||
| 296 | return { | ||
| 297 | codexThreadId: shouldRebindIdentity | ||
| 298 | ? (eventThreadId ?? t.codexThreadId) | ||
| 299 | : (t.codexThreadId ?? eventThreadId ?? null), | ||
| 300 | error: event.kind === "error" && event.message ? event.message : t.error, | ||
| 301 | }; | ||
| 302 | })(), | ||
| 303 | session: t.session ? evolveSession(t.session, event) : t.session, | ||
| 304 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | ||
| 305 | events: [event, ...t.events], | ||
| 306 | ...updateTurnFields(t, event), | ||
| 307 | })), | ||
| 308 | }; | ||
| 309 | } | ||
| 310 | |||
| 311 | case "UPDATE_SESSION": | ||
| 312 | return { | ||
| 313 | ...state, | ||
| 314 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 315 | ...t, | ||
| 316 | session: action.session, | ||
| 317 | codexThreadId: action.session.threadId ?? t.codexThreadId, | ||
| 318 | events: [], | ||
| 319 | error: null, | ||
| 320 | latestTurnId: undefined, | ||
| 321 | latestTurnStartedAt: undefined, | ||
| 322 | latestTurnCompletedAt: undefined, | ||
| 323 | latestTurnDurationMs: undefined, | ||
| 324 | })), | ||
| 325 | }; | ||
| 326 | |||
| 327 | case "PUSH_USER_MESSAGE": | ||
| 328 | return { | ||
| 329 | ...state, | ||
| 330 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 331 | ...t, | ||
| 332 | messages: [ | ||
| 333 | ...t.messages, | ||
| 334 | { | ||
| 335 | id: action.id, | ||
| 336 | role: "user" as const, | ||
| 337 | text: action.text, | ||
| 338 | createdAt: new Date().toISOString(), | ||
| 339 | streaming: false, | ||
| 340 | }, | ||
| 341 | ], | ||
| 342 | })), | ||
| 343 | }; | ||
| 344 | |||
| 345 | case "SET_ERROR": | ||
| 346 | return { | ||
| 347 | ...state, | ||
| 348 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 349 | ...t, | ||
| 350 | error: action.error, | ||
| 351 | })), | ||
| 352 | }; | ||
| 353 | |||
| 354 | case "SET_THREAD_TITLE": | ||
| 355 | return { | ||
| 356 | ...state, | ||
| 357 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 358 | ...t, | ||
| 359 | title: action.title, | ||
| 360 | })), | ||
| 361 | }; | ||
| 362 | |||
| 363 | case "SET_THREAD_MODEL": | ||
| 364 | return { | ||
| 365 | ...state, | ||
| 366 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 367 | ...t, | ||
| 368 | model: resolveModelSlug(action.model), | ||
| 369 | })), | ||
| 370 | }; | ||
| 371 | |||
| 372 | case "SET_RUNTIME_MODE": | ||
| 373 | return { | ||
| 374 | ...state, | ||
| 375 | runtimeMode: action.mode, | ||
| 376 | }; | ||
| 377 | |||
| 378 | default: | ||
| 379 | return state; | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | // ── Context ────────────────────────────────────────────────────────── | ||
| 384 | |||
| 385 | const StoreContext = createContext<{ | ||
| 386 | state: AppState; | ||
| 387 | dispatch: Dispatch<Action>; | ||
| 388 | }>({ state: initialState, dispatch: () => {} }); | ||
| 389 | |||
| 390 | export function StoreProvider({ children }: { children: ReactNode }) { | ||
| 391 | const [state, dispatch] = useReducer(reducer, undefined, readPersistedState); | ||
| 392 | |||
| 393 | useEffect(() => { | ||
| 394 | persistState(state); | ||
| 395 | }, [state]); | ||
| 396 | |||
| 397 | return createElement(StoreContext.Provider, { value: { state, dispatch } }, children); | ||
| 398 | } | ||
| 399 | |||
| 400 | export function useStore() { | ||
| 401 | return useContext(StoreContext); | ||
| 402 | } | ||
apps/renderer/src/types.ts deleted-38| ... | @@ -1,38 +0,0 @@ | ||
| 1 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | ||
| 4 | export type RuntimeMode = "approval-required" | "full-access"; | ||
| 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | ||
| 6 | |||
| 7 | export interface ChatMessage { | ||
| 8 | id: string; | ||
| 9 | role: "user" | "assistant"; | ||
| 10 | text: string; | ||
| 11 | createdAt: string; | ||
| 12 | streaming: boolean; | ||
| 13 | } | ||
| 14 | |||
| 15 | export interface Project { | ||
| 16 | id: string; | ||
| 17 | name: string; | ||
| 18 | cwd: string; | ||
| 19 | model: string; | ||
| 20 | expanded: boolean; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface Thread { | ||
| 24 | id: string; | ||
| 25 | codexThreadId: string | null; | ||
| 26 | projectId: string; | ||
| 27 | title: string; | ||
| 28 | model: string; | ||
| 29 | session: ProviderSession | null; | ||
| 30 | messages: ChatMessage[]; | ||
| 31 | events: ProviderEvent[]; | ||
| 32 | error: string | null; | ||
| 33 | createdAt: string; | ||
| 34 | latestTurnId?: string | undefined; | ||
| 35 | latestTurnStartedAt?: string | undefined; | ||
| 36 | latestTurnCompletedAt?: string | undefined; | ||
| 37 | latestTurnDurationMs?: number | undefined; | ||
| 38 | } | ||
apps/renderer/src/vite-env.d.ts deleted-15| ... | @@ -1,15 +0,0 @@ | ||
| 1 | /// <reference types="vite/client" /> | ||
| 2 | |||
| 3 | import type { NativeApi } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | interface DesktopBridge { | ||
| 6 | getWsUrl: () => string | null; | ||
| 7 | pickFolder: () => Promise<string | null>; | ||
| 8 | } | ||
| 9 | |||
| 10 | declare global { | ||
| 11 | interface Window { | ||
| 12 | nativeApi?: NativeApi; | ||
| 13 | desktopBridge?: DesktopBridge; | ||
| 14 | } | ||
| 15 | } | ||
apps/renderer/src/wsNativeApi.ts deleted-102| ... | @@ -1,102 +0,0 @@ | ||
| 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { WsTransport } from "./wsTransport"; | ||
| 4 | |||
| 5 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | ||
| 6 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); | ||
| 7 | let lastWelcome: WsWelcomePayload | null = null; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Subscribe to the server welcome message. If a welcome was already received | ||
| 11 | * before this call, the listener fires synchronously with the cached payload. | ||
| 12 | * This avoids the race between WebSocket connect and React effect registration. | ||
| 13 | */ | ||
| 14 | export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { | ||
| 15 | welcomeListeners.add(listener); | ||
| 16 | |||
| 17 | // Replay cached welcome for late subscribers | ||
| 18 | if (lastWelcome) { | ||
| 19 | try { | ||
| 20 | listener(lastWelcome); | ||
| 21 | } catch { | ||
| 22 | // Swallow listener errors | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | return () => { | ||
| 27 | welcomeListeners.delete(listener); | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function createWsNativeApi(): NativeApi { | ||
| 32 | if (instance) return instance.api; | ||
| 33 | |||
| 34 | const transport = new WsTransport(); | ||
| 35 | |||
| 36 | // Listen for server welcome and forward to registered listeners. | ||
| 37 | // Also cache it so late subscribers (React effects) get it immediately. | ||
| 38 | transport.subscribe(WS_CHANNELS.serverWelcome, (data) => { | ||
| 39 | const payload = data as WsWelcomePayload; | ||
| 40 | lastWelcome = payload; | ||
| 41 | for (const listener of welcomeListeners) { | ||
| 42 | try { | ||
| 43 | listener(payload); | ||
| 44 | } catch { | ||
| 45 | // Swallow listener errors | ||
| 46 | } | ||
| 47 | } | ||
| 48 | }); | ||
| 49 | |||
| 50 | const api: NativeApi = { | ||
| 51 | todos: { | ||
| 52 | list: async () => [], | ||
| 53 | add: async () => [], | ||
| 54 | toggle: async () => [], | ||
| 55 | remove: async () => [], | ||
| 56 | }, | ||
| 57 | dialogs: { | ||
| 58 | pickFolder: async () => { | ||
| 59 | if (!window.desktopBridge) return null; | ||
| 60 | return window.desktopBridge.pickFolder(); | ||
| 61 | }, | ||
| 62 | }, | ||
| 63 | terminal: { | ||
| 64 | run: async () => ({ | ||
| 65 | stdout: "", | ||
| 66 | stderr: "Terminal not available in web mode", | ||
| 67 | code: 1, | ||
| 68 | signal: null, | ||
| 69 | timedOut: false, | ||
| 70 | }), | ||
| 71 | }, | ||
| 72 | agent: { | ||
| 73 | spawn: async () => "", | ||
| 74 | kill: async () => {}, | ||
| 75 | write: async () => {}, | ||
| 76 | onOutput: () => () => {}, | ||
| 77 | onExit: () => () => {}, | ||
| 78 | }, | ||
| 79 | providers: { | ||
| 80 | startSession: (input) => transport.request(WS_METHODS.providersStartSession, input), | ||
| 81 | sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input), | ||
| 82 | interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), | ||
| 83 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), | ||
| 84 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), | ||
| 85 | listSessions: () => transport.request(WS_METHODS.providersListSessions), | ||
| 86 | onEvent: (callback) => | ||
| 87 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), | ||
| 88 | }, | ||
| 89 | projects: { | ||
| 90 | list: () => transport.request(WS_METHODS.projectsList), | ||
| 91 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), | ||
| 92 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), | ||
| 93 | }, | ||
| 94 | shell: { | ||
| 95 | openInEditor: (cwd, editor) => | ||
| 96 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), | ||
| 97 | }, | ||
| 98 | }; | ||
| 99 | |||
| 100 | instance = { api, transport }; | ||
| 101 | return api; | ||
| 102 | } | ||
apps/renderer/src/wsTransport.ts deleted-197| ... | @@ -1,197 +0,0 @@ | ||
| 1 | import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | type PushListener = (data: unknown) => void; | ||
| 4 | |||
| 5 | interface PendingRequest { | ||
| 6 | resolve: (result: unknown) => void; | ||
| 7 | reject: (error: Error) => void; | ||
| 8 | timeout: ReturnType<typeof setTimeout>; | ||
| 9 | } | ||
| 10 | |||
| 11 | const REQUEST_TIMEOUT_MS = 60_000; | ||
| 12 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; | ||
| 13 | |||
| 14 | export class WsTransport { | ||
| 15 | private ws: WebSocket | null = null; | ||
| 16 | private nextId = 1; | ||
| 17 | private readonly pending = new Map<string, PendingRequest>(); | ||
| 18 | private readonly listeners = new Map<string, Set<PushListener>>(); | ||
| 19 | private reconnectAttempt = 0; | ||
| 20 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | ||
| 21 | private disposed = false; | ||
| 22 | private readonly url: string; | ||
| 23 | |||
| 24 | constructor(url?: string) { | ||
| 25 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | ||
| 26 | // In dev mode, VITE_WS_URL points to the server's WebSocket endpoint. | ||
| 27 | // In production, the page is served by the WS server on the same host:port. | ||
| 28 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | ||
| 29 | this.url = | ||
| 30 | url ?? | ||
| 31 | (bridgeUrl && bridgeUrl.length > 0 | ||
| 32 | ? bridgeUrl | ||
| 33 | : envUrl && envUrl.length > 0 | ||
| 34 | ? envUrl | ||
| 35 | : `ws://${window.location.hostname}:${window.location.port}`); | ||
| 36 | this.connect(); | ||
| 37 | } | ||
| 38 | |||
| 39 | async request<T = unknown>(method: string, params?: unknown): Promise<T> { | ||
| 40 | const id = String(this.nextId++); | ||
| 41 | const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) }; | ||
| 42 | |||
| 43 | return new Promise<T>((resolve, reject) => { | ||
| 44 | const timeout = setTimeout(() => { | ||
| 45 | this.pending.delete(id); | ||
| 46 | reject(new Error(`Request timed out: ${method}`)); | ||
| 47 | }, REQUEST_TIMEOUT_MS); | ||
| 48 | |||
| 49 | this.pending.set(id, { | ||
| 50 | resolve: resolve as (result: unknown) => void, | ||
| 51 | reject, | ||
| 52 | timeout, | ||
| 53 | }); | ||
| 54 | |||
| 55 | this.send(message); | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | subscribe(channel: string, listener: PushListener): () => void { | ||
| 60 | let channelListeners = this.listeners.get(channel); | ||
| 61 | if (!channelListeners) { | ||
| 62 | channelListeners = new Set(); | ||
| 63 | this.listeners.set(channel, channelListeners); | ||
| 64 | } | ||
| 65 | channelListeners.add(listener); | ||
| 66 | |||
| 67 | return () => { | ||
| 68 | channelListeners!.delete(listener); | ||
| 69 | if (channelListeners!.size === 0) { | ||
| 70 | this.listeners.delete(channel); | ||
| 71 | } | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | dispose() { | ||
| 76 | this.disposed = true; | ||
| 77 | if (this.reconnectTimer !== null) { | ||
| 78 | clearTimeout(this.reconnectTimer); | ||
| 79 | this.reconnectTimer = null; | ||
| 80 | } | ||
| 81 | for (const pending of this.pending.values()) { | ||
| 82 | clearTimeout(pending.timeout); | ||
| 83 | pending.reject(new Error("Transport disposed")); | ||
| 84 | } | ||
| 85 | this.pending.clear(); | ||
| 86 | this.ws?.close(); | ||
| 87 | this.ws = null; | ||
| 88 | } | ||
| 89 | |||
| 90 | private connect() { | ||
| 91 | if (this.disposed) return; | ||
| 92 | |||
| 93 | const ws = new WebSocket(this.url); | ||
| 94 | |||
| 95 | ws.addEventListener("open", () => { | ||
| 96 | this.ws = ws; | ||
| 97 | this.reconnectAttempt = 0; | ||
| 98 | }); | ||
| 99 | |||
| 100 | ws.addEventListener("message", (event) => { | ||
| 101 | this.handleMessage(event.data); | ||
| 102 | }); | ||
| 103 | |||
| 104 | ws.addEventListener("close", () => { | ||
| 105 | this.ws = null; | ||
| 106 | this.scheduleReconnect(); | ||
| 107 | }); | ||
| 108 | |||
| 109 | ws.addEventListener("error", () => { | ||
| 110 | // close event will fire after error | ||
| 111 | }); | ||
| 112 | } | ||
| 113 | |||
| 114 | private handleMessage(raw: unknown) { | ||
| 115 | let parsed: unknown; | ||
| 116 | try { | ||
| 117 | parsed = JSON.parse(String(raw)); | ||
| 118 | } catch { | ||
| 119 | return; | ||
| 120 | } | ||
| 121 | |||
| 122 | if (!parsed || typeof parsed !== "object") return; | ||
| 123 | |||
| 124 | const message = parsed as Record<string, unknown>; | ||
| 125 | |||
| 126 | // Push event | ||
| 127 | if (message.type === "push") { | ||
| 128 | const push = message as unknown as WsPush; | ||
| 129 | const channelListeners = this.listeners.get(push.channel); | ||
| 130 | if (channelListeners) { | ||
| 131 | for (const listener of channelListeners) { | ||
| 132 | try { | ||
| 133 | listener(push.data); | ||
| 134 | } catch { | ||
| 135 | // Swallow listener errors | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | |||
| 142 | // Response to a request | ||
| 143 | if (typeof message.id === "string") { | ||
| 144 | const response = message as unknown as WsResponse; | ||
| 145 | const pending = this.pending.get(response.id); | ||
| 146 | if (!pending) return; | ||
| 147 | |||
| 148 | clearTimeout(pending.timeout); | ||
| 149 | this.pending.delete(response.id); | ||
| 150 | |||
| 151 | if (response.error) { | ||
| 152 | pending.reject(new Error(response.error.message)); | ||
| 153 | } else { | ||
| 154 | pending.resolve(response.result); | ||
| 155 | } | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | private send(message: WsRequest) { | ||
| 160 | if (this.ws?.readyState === WebSocket.OPEN) { | ||
| 161 | this.ws.send(JSON.stringify(message)); | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | |||
| 165 | // If not connected, wait for connection | ||
| 166 | const waitForOpen = () => { | ||
| 167 | const check = setInterval(() => { | ||
| 168 | if (this.disposed) { | ||
| 169 | clearInterval(check); | ||
| 170 | return; | ||
| 171 | } | ||
| 172 | if (this.ws?.readyState === WebSocket.OPEN) { | ||
| 173 | clearInterval(check); | ||
| 174 | this.ws.send(JSON.stringify(message)); | ||
| 175 | } | ||
| 176 | }, 50); | ||
| 177 | |||
| 178 | // Give up after timeout (the pending request will time out on its own) | ||
| 179 | setTimeout(() => clearInterval(check), REQUEST_TIMEOUT_MS); | ||
| 180 | }; | ||
| 181 | waitForOpen(); | ||
| 182 | } | ||
| 183 | |||
| 184 | private scheduleReconnect() { | ||
| 185 | if (this.disposed) return; | ||
| 186 | |||
| 187 | const delay = | ||
| 188 | RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? | ||
| 189 | RECONNECT_DELAYS_MS[0]!; | ||
| 190 | |||
| 191 | this.reconnectAttempt++; | ||
| 192 | this.reconnectTimer = setTimeout(() => { | ||
| 193 | this.reconnectTimer = null; | ||
| 194 | this.connect(); | ||
| 195 | }, delay); | ||
| 196 | } | ||
| 197 | } | ||
apps/renderer/tsconfig.json deleted-10| ... | @@ -1,10 +0,0 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "composite": true, | ||
| 5 | "jsx": "react-jsx", | ||
| 6 | "lib": ["ES2023", "DOM", "DOM.Iterable"], | ||
| 7 | "types": ["vite/client"] | ||
| 8 | }, | ||
| 9 | "include": ["src", "vite.config.ts"] | ||
| 10 | } | ||
apps/renderer/vite.config.ts deleted-28| ... | @@ -1,28 +0,0 @@ | ||
| 1 | import tailwindcss from "@tailwindcss/vite"; | ||
| 2 | import react from "@vitejs/plugin-react"; | ||
| 3 | import { defineConfig } from "vite"; | ||
| 4 | |||
| 5 | export default defineConfig({ | ||
| 6 | plugins: [ | ||
| 7 | react({ | ||
| 8 | babel: { | ||
| 9 | plugins: [["babel-plugin-react-compiler", { target: "19" }]], | ||
| 10 | }, | ||
| 11 | }), | ||
| 12 | tailwindcss(), | ||
| 13 | ], | ||
| 14 | define: { | ||
| 15 | // In dev mode, tell the renderer where the WebSocket server lives | ||
| 16 | "import.meta.env.VITE_WS_URL": JSON.stringify( | ||
| 17 | process.env.VITE_WS_URL ?? "", | ||
| 18 | ), | ||
| 19 | }, | ||
| 20 | server: { | ||
| 21 | port: Number(process.env.PORT ?? 5173), | ||
| 22 | strictPort: true, | ||
| 23 | }, | ||
| 24 | build: { | ||
| 25 | outDir: "dist", | ||
| 26 | emptyOutDir: true, | ||
| 27 | }, | ||
| 28 | }); | ||
apps/server/package.json+1-1| ... | @@ -24,7 +24,7 @@ | ... | @@ -24,7 +24,7 @@ |
| 24 | }, | 24 | }, |
| 25 | "devDependencies": { | 25 | "devDependencies": { |
| 26 | "@t3tools/contracts": "workspace:*", | 26 | "@t3tools/contracts": "workspace:*", |
| 27 | "@t3tools/renderer": "workspace:*", | 27 | "@t3tools/web": "workspace:*", |
| 28 | "@types/node": "^22.10.2", | 28 | "@types/node": "^22.10.2", |
| 29 | "@types/ws": "^8.5.13", | 29 | "@types/ws": "^8.5.13", |
| 30 | "tsup": "^8.3.5", | 30 | "tsup": "^8.3.5", |
apps/server/scripts/bundle-client.mjs+6-6| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | /** | 1 | /** |
| 2 | * Copies the built renderer into dist/client/ so the published npm package | 2 | * Copies the built web app into dist/client/ so the published npm package |
| 3 | * includes the web UI. This runs as a post-build step. | 3 | * includes the web UI. This runs as a post-build step. |
| 4 | */ | 4 | */ |
| 5 | import fs from "node:fs"; | 5 | import fs from "node:fs"; |
| ... | @@ -7,15 +7,15 @@ import path from "node:path"; | ... | @@ -7,15 +7,15 @@ import path from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; | 7 | import { fileURLToPath } from "node:url"; |
| 8 | 8 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 10 | const rendererDist = path.resolve(__dirname, "../../renderer/dist"); | 10 | const webDist = path.resolve(__dirname, "../../web/dist"); |
| 11 | const target = path.resolve(__dirname, "../dist/client"); | 11 | const target = path.resolve(__dirname, "../dist/client"); |
| 12 | 12 | ||
| 13 | if (!fs.existsSync(rendererDist)) { | 13 | if (!fs.existsSync(webDist)) { |
| 14 | console.log( | 14 | console.log( |
| 15 | "âš Renderer dist not found — skipping client bundle. Run `bun run --cwd apps/renderer build` first.", | 15 | "âš Web dist not found — skipping client bundle. Run `bun run --cwd apps/web build` first.", |
| 16 | ); | 16 | ); |
| 17 | process.exit(0); | 17 | process.exit(0); |
| 18 | } | 18 | } |
| 19 | 19 | ||
| 20 | fs.cpSync(rendererDist, target, { recursive: true }); | 20 | fs.cpSync(webDist, target, { recursive: true }); |
| 21 | console.log("✓ Bundled renderer into dist/client"); | 21 | console.log("✓ Bundled web app into dist/client"); |
apps/server/src/index.ts+4-4| ... | @@ -84,8 +84,8 @@ function resolveStaticDir(): string | undefined { | ... | @@ -84,8 +84,8 @@ function resolveStaticDir(): string | undefined { |
| 84 | // Not bundled — check monorepo layout | 84 | // Not bundled — check monorepo layout |
| 85 | } | 85 | } |
| 86 | 86 | ||
| 87 | // Monorepo layout: apps/server/dist/index.js → apps/renderer/dist/ | 87 | // Monorepo layout: apps/server/dist/index.js → apps/web/dist/ |
| 88 | const monorepoClient = path.resolve(__dirname, "../../renderer/dist"); | 88 | const monorepoClient = path.resolve(__dirname, "../../web/dist"); |
| 89 | try { | 89 | try { |
| 90 | const stat = fs.statSync(path.join(monorepoClient, "index.html")); | 90 | const stat = fs.statSync(path.join(monorepoClient, "index.html")); |
| 91 | if (stat.isFile()) return monorepoClient; | 91 | if (stat.isFile()) return monorepoClient; |
| ... | @@ -109,8 +109,8 @@ async function main() { | ... | @@ -109,8 +109,8 @@ async function main() { |
| 109 | const staticDir = devUrl ? undefined : resolveStaticDir(); | 109 | const staticDir = devUrl ? undefined : resolveStaticDir(); |
| 110 | 110 | ||
| 111 | if (!devUrl && !staticDir) { | 111 | if (!devUrl && !staticDir) { |
| 112 | logger.warn("renderer bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { | 112 | logger.warn("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { |
| 113 | hint: "Run `bun run --cwd apps/renderer build` or set VITE_DEV_SERVER_URL for dev mode.", | 113 | hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", |
| 114 | }); | 114 | }); |
| 115 | } | 115 | } |
| 116 | 116 |
apps/server/src/wsServer.ts+1-1| ... | @@ -109,7 +109,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -109,7 +109,7 @@ export function createServer(options: ServerOptions) { |
| 109 | return; | 109 | return; |
| 110 | } | 110 | } |
| 111 | 111 | ||
| 112 | // Serve static files from the renderer build | 112 | // Serve static files from the web app build |
| 113 | if (!staticDir) { | 113 | if (!staticDir) { |
| 114 | res.writeHead(503, { "Content-Type": "text/plain" }); | 114 | res.writeHead(503, { "Content-Type": "text/plain" }); |
| 115 | res.end("No static directory configured and no dev URL set."); | 115 | res.end("No static directory configured and no dev URL set."); |
apps/web/index.html created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8" /> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| 6 | <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||
| 7 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||
| 8 | <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" /> | ||
| 9 | <title>T3 Code</title> | ||
| 10 | </head> | ||
| 11 | <body> | ||
| 12 | <div id="root"></div> | ||
| 13 | <script type="module" src="/src/main.tsx"></script> | ||
| 14 | </body> | ||
| 15 | </html> | ||
apps/web/package.json created+34| ... | @@ -0,0 +1,34 @@ | ||
| 1 | { | ||
| 2 | "name": "@t3tools/web", | ||
| 3 | "version": "0.0.0", | ||
| 4 | "private": true, | ||
| 5 | "type": "module", | ||
| 6 | "scripts": { | ||
| 7 | "dev": "vite", | ||
| 8 | "build": "vite build", | ||
| 9 | "preview": "vite preview", | ||
| 10 | "typecheck": "tsc --noEmit", | ||
| 11 | "test": "vitest run --passWithNoTests" | ||
| 12 | }, | ||
| 13 | "dependencies": { | ||
| 14 | "@t3tools/contracts": "workspace:*", | ||
| 15 | "highlight.js": "^11.11.1", | ||
| 16 | "lucide-react": "^0.563.0", | ||
| 17 | "react": "^19.0.0", | ||
| 18 | "react-dom": "^19.0.0", | ||
| 19 | "react-markdown": "^10.1.0", | ||
| 20 | "rehype-highlight": "^7.0.2", | ||
| 21 | "remark-gfm": "^4.0.1", | ||
| 22 | "zod": "^3.24.1" | ||
| 23 | }, | ||
| 24 | "devDependencies": { | ||
| 25 | "@tailwindcss/vite": "^4.0.0", | ||
| 26 | "@types/react": "^19.0.0", | ||
| 27 | "@types/react-dom": "^19.0.0", | ||
| 28 | "@vitejs/plugin-react": "^4.3.4", | ||
| 29 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", | ||
| 30 | "tailwindcss": "^4.0.0", | ||
| 31 | "typescript": "^5.7.3", | ||
| 32 | "vite": "^6.0.5" | ||
| 33 | } | ||
| 34 | } | ||
apps/web/src/App.tsx created+175| ... | @@ -0,0 +1,175 @@ | ||
| 1 | import { useEffect, useMemo, useRef } from "react"; | ||
| 2 | |||
| 3 | import ChatView from "./components/ChatView"; | ||
| 4 | import DiffPanel from "./components/DiffPanel"; | ||
| 5 | import Sidebar from "./components/Sidebar"; | ||
| 6 | import { isElectron } from "./env"; | ||
| 7 | import { DEFAULT_MODEL } from "./model-logic"; | ||
| 8 | import { readNativeApi } from "./session-logic"; | ||
| 9 | import { StoreProvider, useStore } from "./store"; | ||
| 10 | import { onServerWelcome } from "./wsNativeApi"; | ||
| 11 | |||
| 12 | function EventRouter() { | ||
| 13 | const api = useMemo(() => readNativeApi(), []); | ||
| 14 | const { dispatch } = useStore(); | ||
| 15 | const activeAssistantItemRef = useRef<string | null>(null); | ||
| 16 | |||
| 17 | useEffect(() => { | ||
| 18 | if (!api) return; | ||
| 19 | return api.providers.onEvent((event) => { | ||
| 20 | dispatch({ | ||
| 21 | type: "APPLY_EVENT", | ||
| 22 | event, | ||
| 23 | activeAssistantItemRef, | ||
| 24 | }); | ||
| 25 | }); | ||
| 26 | }, [api, dispatch]); | ||
| 27 | |||
| 28 | return null; | ||
| 29 | } | ||
| 30 | |||
| 31 | function AutoProjectBootstrap() { | ||
| 32 | const { state, dispatch } = useStore(); | ||
| 33 | const bootstrappedRef = useRef(false); | ||
| 34 | |||
| 35 | useEffect(() => { | ||
| 36 | // Browser mode bootstraps from server welcome. | ||
| 37 | // Electron bootstraps from persisted projects via DesktopProjectBootstrap. | ||
| 38 | if (isElectron) return; | ||
| 39 | |||
| 40 | return onServerWelcome((payload) => { | ||
| 41 | if (bootstrappedRef.current) return; | ||
| 42 | |||
| 43 | // Don't create duplicate projects for the same cwd | ||
| 44 | const existing = state.projects.find((p) => p.cwd === payload.cwd); | ||
| 45 | if (existing) { | ||
| 46 | bootstrappedRef.current = true; | ||
| 47 | // Ensure a thread is active | ||
| 48 | const existingThread = state.threads.find((t) => t.projectId === existing.id); | ||
| 49 | if (existingThread && !state.activeThreadId) { | ||
| 50 | dispatch({ | ||
| 51 | type: "SET_ACTIVE_THREAD", | ||
| 52 | threadId: existingThread.id, | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | |||
| 58 | bootstrappedRef.current = true; | ||
| 59 | |||
| 60 | // Create project + thread from server cwd | ||
| 61 | const projectId = crypto.randomUUID(); | ||
| 62 | dispatch({ | ||
| 63 | type: "ADD_PROJECT", | ||
| 64 | project: { | ||
| 65 | id: projectId, | ||
| 66 | name: payload.projectName, | ||
| 67 | cwd: payload.cwd, | ||
| 68 | model: DEFAULT_MODEL, | ||
| 69 | expanded: true, | ||
| 70 | }, | ||
| 71 | }); | ||
| 72 | dispatch({ | ||
| 73 | type: "ADD_THREAD", | ||
| 74 | thread: { | ||
| 75 | id: crypto.randomUUID(), | ||
| 76 | codexThreadId: null, | ||
| 77 | projectId, | ||
| 78 | title: "New thread", | ||
| 79 | model: DEFAULT_MODEL, | ||
| 80 | session: null, | ||
| 81 | messages: [], | ||
| 82 | events: [], | ||
| 83 | error: null, | ||
| 84 | createdAt: new Date().toISOString(), | ||
| 85 | }, | ||
| 86 | }); | ||
| 87 | }); | ||
| 88 | }, [state.projects, state.threads, state.activeThreadId, dispatch]); | ||
| 89 | |||
| 90 | return null; | ||
| 91 | } | ||
| 92 | |||
| 93 | function DesktopProjectBootstrap() { | ||
| 94 | const api = useMemo(() => readNativeApi(), []); | ||
| 95 | const { dispatch } = useStore(); | ||
| 96 | const bootstrappedRef = useRef(false); | ||
| 97 | |||
| 98 | useEffect(() => { | ||
| 99 | if (!isElectron || !api || bootstrappedRef.current) return; | ||
| 100 | |||
| 101 | let disposed = false; | ||
| 102 | let retryDelayMs = 500; | ||
| 103 | let retryTimer: ReturnType<typeof setTimeout> | null = null; | ||
| 104 | |||
| 105 | const attemptBootstrap = async () => { | ||
| 106 | try { | ||
| 107 | const projects = await api.projects.list(); | ||
| 108 | if (disposed) return; | ||
| 109 | dispatch({ | ||
| 110 | type: "SYNC_PROJECTS", | ||
| 111 | projects: projects.map((project) => ({ | ||
| 112 | id: project.id, | ||
| 113 | name: project.name, | ||
| 114 | cwd: project.cwd, | ||
| 115 | model: DEFAULT_MODEL, | ||
| 116 | expanded: true, | ||
| 117 | })), | ||
| 118 | }); | ||
| 119 | bootstrappedRef.current = true; | ||
| 120 | } catch { | ||
| 121 | if (disposed) return; | ||
| 122 | retryTimer = setTimeout(() => { | ||
| 123 | retryTimer = null; | ||
| 124 | void attemptBootstrap(); | ||
| 125 | }, retryDelayMs); | ||
| 126 | retryDelayMs = Math.min(retryDelayMs * 2, 5_000); | ||
| 127 | } | ||
| 128 | }; | ||
| 129 | |||
| 130 | void attemptBootstrap(); | ||
| 131 | |||
| 132 | return () => { | ||
| 133 | disposed = true; | ||
| 134 | if (retryTimer) { | ||
| 135 | clearTimeout(retryTimer); | ||
| 136 | } | ||
| 137 | }; | ||
| 138 | }, [api, dispatch]); | ||
| 139 | |||
| 140 | return null; | ||
| 141 | } | ||
| 142 | |||
| 143 | function Layout() { | ||
| 144 | const api = useMemo(() => readNativeApi(), []); | ||
| 145 | const { state } = useStore(); | ||
| 146 | |||
| 147 | if (!api) { | ||
| 148 | return ( | ||
| 149 | <div className="flex h-screen flex-col bg-background text-foreground"> | ||
| 150 | <div className="flex flex-1 items-center justify-center"> | ||
| 151 | <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p> | ||
| 152 | </div> | ||
| 153 | </div> | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | |||
| 157 | return ( | ||
| 158 | <div className="flex h-screen overflow-hidden bg-background text-foreground"> | ||
| 159 | <EventRouter /> | ||
| 160 | <AutoProjectBootstrap /> | ||
| 161 | <DesktopProjectBootstrap /> | ||
| 162 | <Sidebar /> | ||
| 163 | <ChatView /> | ||
| 164 | {state.diffOpen && <DiffPanel />} | ||
| 165 | </div> | ||
| 166 | ); | ||
| 167 | } | ||
| 168 | |||
| 169 | export default function App() { | ||
| 170 | return ( | ||
| 171 | <StoreProvider> | ||
| 172 | <Layout /> | ||
| 173 | </StoreProvider> | ||
| 174 | ); | ||
| 175 | } | ||
apps/web/src/components/ChatMarkdown.tsx created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import type { Components } from "react-markdown"; | ||
| 2 | import ReactMarkdown from "react-markdown"; | ||
| 3 | import rehypeHighlight from "rehype-highlight"; | ||
| 4 | import remarkGfm from "remark-gfm"; | ||
| 5 | |||
| 6 | interface ChatMarkdownProps { | ||
| 7 | text: string; | ||
| 8 | } | ||
| 9 | |||
| 10 | const markdownComponents: Components = { | ||
| 11 | a({ node: _node, ...props }) { | ||
| 12 | return <a {...props} target="_blank" rel="noreferrer" />; | ||
| 13 | }, | ||
| 14 | }; | ||
| 15 | |||
| 16 | export default function ChatMarkdown({ text }: ChatMarkdownProps) { | ||
| 17 | return ( | ||
| 18 | <div className="chat-markdown text-sm leading-relaxed text-foreground/80"> | ||
| 19 | <ReactMarkdown | ||
| 20 | remarkPlugins={[remarkGfm]} | ||
| 21 | rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]} | ||
| 22 | components={markdownComponents} | ||
| 23 | > | ||
| 24 | {text} | ||
| 25 | </ReactMarkdown> | ||
| 26 | </div> | ||
| 27 | ); | ||
| 28 | } | ||
apps/web/src/components/ChatView.tsx created+1170| ... | @@ -0,0 +1,1170 @@ | ||
| 1 | import { | ||
| 2 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 3 | type ProviderApprovalDecision, | ||
| 4 | type ProviderEvent, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | type FormEvent, | ||
| 8 | Fragment, | ||
| 9 | type KeyboardEvent, | ||
| 10 | useEffect, | ||
| 11 | useMemo, | ||
| 12 | useRef, | ||
| 13 | useState, | ||
| 14 | } from "react"; | ||
| 15 | |||
| 16 | import { EDITORS, type EditorId } from "@t3tools/contracts"; | ||
| 17 | import { isElectron } from "../env"; | ||
| 18 | import { buildBootstrapInput } from "../historyBootstrap"; | ||
| 19 | import { | ||
| 20 | DEFAULT_MODEL, | ||
| 21 | DEFAULT_REASONING, | ||
| 22 | MODEL_OPTIONS, | ||
| 23 | REASONING_OPTIONS, | ||
| 24 | resolveModelSlug, | ||
| 25 | } from "../model-logic"; | ||
| 26 | import { | ||
| 27 | derivePhase, | ||
| 28 | deriveTimelineEntries, | ||
| 29 | deriveWorkLogEntries, | ||
| 30 | formatDuration, | ||
| 31 | formatElapsed, | ||
| 32 | formatTimestamp, | ||
| 33 | readNativeApi, | ||
| 34 | } from "../session-logic"; | ||
| 35 | import { useStore } from "../store"; | ||
| 36 | import ChatMarkdown from "./ChatMarkdown"; | ||
| 37 | |||
| 38 | function formatMessageMeta(createdAt: string, duration: string | null): string { | ||
| 39 | if (!duration) return formatTimestamp(createdAt); | ||
| 40 | return `${formatTimestamp(createdAt)} • ${duration}`; | ||
| 41 | } | ||
| 42 | |||
| 43 | const FILE_MANAGER_LABEL = navigator.platform.includes("Mac") | ||
| 44 | ? "Finder" | ||
| 45 | : navigator.platform.includes("Win") | ||
| 46 | ? "Explorer" | ||
| 47 | : "Files"; | ||
| 48 | |||
| 49 | function editorLabel(editor: (typeof EDITORS)[number]): string { | ||
| 50 | return editor.command ? editor.label : FILE_MANAGER_LABEL; | ||
| 51 | } | ||
| 52 | |||
| 53 | const LAST_EDITOR_KEY = "t3code:last-editor"; | ||
| 54 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | ||
| 55 | |||
| 56 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ||
| 57 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; | ||
| 58 | if (tone === "tool") return "text-muted-foreground/70"; | ||
| 59 | if (tone === "thinking") return "text-muted-foreground/50"; | ||
| 60 | return "text-muted-foreground/40"; | ||
| 61 | } | ||
| 62 | |||
| 63 | interface PendingApprovalCard { | ||
| 64 | requestId: string; | ||
| 65 | requestKind: "command" | "file-change"; | ||
| 66 | createdAt: string; | ||
| 67 | detail?: string; | ||
| 68 | } | ||
| 69 | |||
| 70 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; | ||
| 71 | |||
| 72 | interface EnsuredSessionInfo { | ||
| 73 | sessionId: string; | ||
| 74 | resolvedThreadId: string | null; | ||
| 75 | continuityState: SessionContinuityState; | ||
| 76 | } | ||
| 77 | |||
| 78 | function asRecord(value: unknown): Record<string, unknown> | undefined { | ||
| 79 | if (!value || typeof value !== "object") return undefined; | ||
| 80 | return value as Record<string, unknown>; | ||
| 81 | } | ||
| 82 | |||
| 83 | function asString(value: unknown): string | undefined { | ||
| 84 | return typeof value === "string" ? value : undefined; | ||
| 85 | } | ||
| 86 | |||
| 87 | function approvalDetail(event: ProviderEvent): string | undefined { | ||
| 88 | const payload = asRecord(event.payload); | ||
| 89 | const command = asString(payload?.command); | ||
| 90 | if (command) return command; | ||
| 91 | return asString(payload?.reason); | ||
| 92 | } | ||
| 93 | |||
| 94 | function derivePendingApprovals( | ||
| 95 | events: ProviderEvent[], | ||
| 96 | ): PendingApprovalCard[] { | ||
| 97 | const pending = new Map<string, PendingApprovalCard>(); | ||
| 98 | const ordered = [...events].toReversed(); | ||
| 99 | |||
| 100 | for (const event of ordered) { | ||
| 101 | if ( | ||
| 102 | event.method === "session/closed" || | ||
| 103 | event.method === "session/exited" | ||
| 104 | ) { | ||
| 105 | pending.clear(); | ||
| 106 | continue; | ||
| 107 | } | ||
| 108 | |||
| 109 | const requestId = | ||
| 110 | event.requestId ?? asString(asRecord(event.payload)?.requestId); | ||
| 111 | if (!requestId) continue; | ||
| 112 | |||
| 113 | if ( | ||
| 114 | event.kind === "request" && | ||
| 115 | (event.requestKind === "command" || event.requestKind === "file-change") | ||
| 116 | ) { | ||
| 117 | const detail = approvalDetail(event); | ||
| 118 | pending.set(requestId, { | ||
| 119 | requestId, | ||
| 120 | requestKind: event.requestKind, | ||
| 121 | createdAt: event.createdAt, | ||
| 122 | ...(detail ? { detail } : {}), | ||
| 123 | }); | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | |||
| 127 | if (event.method === "item/requestApproval/decision") { | ||
| 128 | pending.delete(requestId); | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | return Array.from(pending.values()); | ||
| 133 | } | ||
| 134 | |||
| 135 | export default function ChatView() { | ||
| 136 | const { state, dispatch } = useStore(); | ||
| 137 | const api = useMemo(() => readNativeApi(), []); | ||
| 138 | const [prompt, setPrompt] = useState(""); | ||
| 139 | const [isSending, setIsSending] = useState(false); | ||
| 140 | const [isConnecting, setIsConnecting] = useState(false); | ||
| 141 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); | ||
| 142 | const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); | ||
| 143 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { | ||
| 144 | const stored = localStorage.getItem(LAST_EDITOR_KEY); | ||
| 145 | return EDITORS.some((e) => e.id === stored) | ||
| 146 | ? (stored as EditorId) | ||
| 147 | : EDITORS[0].id; | ||
| 148 | }); | ||
| 149 | const [selectedEffort, setSelectedEffort] = | ||
| 150 | useState<string>(DEFAULT_REASONING); | ||
| 151 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | ||
| 152 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>( | ||
| 153 | [], | ||
| 154 | ); | ||
| 155 | const [expandedWorkGroups, setExpandedWorkGroups] = useState< | ||
| 156 | Record<string, boolean> | ||
| 157 | >({}); | ||
| 158 | const [nowTick, setNowTick] = useState(() => Date.now()); | ||
| 159 | const messagesEndRef = useRef<HTMLDivElement>(null); | ||
| 160 | const textareaRef = useRef<HTMLTextAreaElement>(null); | ||
| 161 | const modelMenuRef = useRef<HTMLDivElement>(null); | ||
| 162 | const editorMenuRef = useRef<HTMLDivElement>(null); | ||
| 163 | |||
| 164 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | ||
| 165 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | ||
| 166 | const selectedModel = resolveModelSlug( | ||
| 167 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | ||
| 168 | ); | ||
| 169 | const phase = derivePhase(activeThread?.session ?? null); | ||
| 170 | const isWorking = phase === "running" || isSending || isConnecting; | ||
| 171 | const nowIso = new Date(nowTick).toISOString(); | ||
| 172 | const modelOptions = MODEL_OPTIONS; | ||
| 173 | const workLogEntries = useMemo( | ||
| 174 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), | ||
| 175 | [activeThread?.events], | ||
| 176 | ); | ||
| 177 | const latestTurnWorkEntries = useMemo( | ||
| 178 | () => | ||
| 179 | deriveWorkLogEntries( | ||
| 180 | activeThread?.events ?? [], | ||
| 181 | activeThread?.latestTurnId, | ||
| 182 | ), | ||
| 183 | [activeThread?.events, activeThread?.latestTurnId], | ||
| 184 | ); | ||
| 185 | const pendingApprovals = useMemo( | ||
| 186 | () => derivePendingApprovals(activeThread?.events ?? []), | ||
| 187 | [activeThread?.events], | ||
| 188 | ); | ||
| 189 | const assistantCompletionByItemId = useMemo(() => { | ||
| 190 | const map = new Map<string, string>(); | ||
| 191 | const ordered = [...(activeThread?.events ?? [])].toReversed(); | ||
| 192 | for (const event of ordered) { | ||
| 193 | if (event.method !== "item/completed") continue; | ||
| 194 | if (!event.itemId) continue; | ||
| 195 | map.set(event.itemId, event.createdAt); | ||
| 196 | } | ||
| 197 | return map; | ||
| 198 | }, [activeThread?.events]); | ||
| 199 | const timelineEntries = useMemo( | ||
| 200 | () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), | ||
| 201 | [activeThread?.messages, workLogEntries], | ||
| 202 | ); | ||
| 203 | const completionSummary = useMemo(() => { | ||
| 204 | if (!activeThread?.latestTurnStartedAt) return null; | ||
| 205 | if (!activeThread.latestTurnCompletedAt) return null; | ||
| 206 | if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { | ||
| 207 | return null; | ||
| 208 | } | ||
| 209 | |||
| 210 | if ( | ||
| 211 | typeof activeThread.latestTurnDurationMs === "number" && | ||
| 212 | Number.isFinite(activeThread.latestTurnDurationMs) && | ||
| 213 | activeThread.latestTurnDurationMs >= 0 | ||
| 214 | ) { | ||
| 215 | return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; | ||
| 216 | } | ||
| 217 | |||
| 218 | const elapsed = formatElapsed( | ||
| 219 | activeThread.latestTurnStartedAt, | ||
| 220 | activeThread.latestTurnCompletedAt, | ||
| 221 | ); | ||
| 222 | return elapsed ? `Worked for ${elapsed}` : null; | ||
| 223 | }, [ | ||
| 224 | activeThread?.latestTurnStartedAt, | ||
| 225 | activeThread?.latestTurnCompletedAt, | ||
| 226 | activeThread?.latestTurnDurationMs, | ||
| 227 | latestTurnWorkEntries, | ||
| 228 | ]); | ||
| 229 | const completionDividerBeforeEntryId = useMemo(() => { | ||
| 230 | if (!activeThread?.latestTurnStartedAt) return null; | ||
| 231 | if (!activeThread.latestTurnCompletedAt) return null; | ||
| 232 | if (!completionSummary) return null; | ||
| 233 | |||
| 234 | const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); | ||
| 235 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | ||
| 236 | if (Number.isNaN(turnStartedAt)) return null; | ||
| 237 | if (Number.isNaN(turnCompletedAt)) return null; | ||
| 238 | |||
| 239 | let inRangeMatch: string | null = null; | ||
| 240 | let fallbackMatch: string | null = null; | ||
| 241 | for (const timelineEntry of timelineEntries) { | ||
| 242 | if (timelineEntry.kind !== "message") continue; | ||
| 243 | if (timelineEntry.message.role !== "assistant") continue; | ||
| 244 | const messageAt = Date.parse(timelineEntry.message.createdAt); | ||
| 245 | if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; | ||
| 246 | fallbackMatch = timelineEntry.id; | ||
| 247 | if (messageAt <= turnCompletedAt) { | ||
| 248 | inRangeMatch = timelineEntry.id; | ||
| 249 | } | ||
| 250 | } | ||
| 251 | return inRangeMatch ?? fallbackMatch; | ||
| 252 | }, [ | ||
| 253 | activeThread?.latestTurnCompletedAt, | ||
| 254 | activeThread?.latestTurnStartedAt, | ||
| 255 | completionSummary, | ||
| 256 | timelineEntries, | ||
| 257 | ]); | ||
| 258 | const runtimeSessionConfig = | ||
| 259 | state.runtimeMode === "full-access" | ||
| 260 | ? ({ | ||
| 261 | approvalPolicy: "never", | ||
| 262 | sandboxMode: "danger-full-access", | ||
| 263 | } as const) | ||
| 264 | : ({ | ||
| 265 | approvalPolicy: "on-request", | ||
| 266 | sandboxMode: "workspace-write", | ||
| 267 | } as const); | ||
| 268 | |||
| 269 | const handleRuntimeModeChange = async ( | ||
| 270 | mode: "approval-required" | "full-access", | ||
| 271 | ) => { | ||
| 272 | if (mode === state.runtimeMode) return; | ||
| 273 | dispatch({ type: "SET_RUNTIME_MODE", mode }); | ||
| 274 | if (!api) return; | ||
| 275 | |||
| 276 | const sessionIds = state.threads | ||
| 277 | .map((t) => t.session) | ||
| 278 | .filter( | ||
| 279 | (s): s is NonNullable<typeof s> => s !== null && s.status !== "closed", | ||
| 280 | ) | ||
| 281 | .map((s) => s.sessionId); | ||
| 282 | |||
| 283 | if (sessionIds.length === 0) return; | ||
| 284 | |||
| 285 | setIsSwitchingRuntimeMode(true); | ||
| 286 | try { | ||
| 287 | await Promise.all( | ||
| 288 | sessionIds.map((id) => | ||
| 289 | api.providers.stopSession({ sessionId: id }).catch(() => undefined), | ||
| 290 | ), | ||
| 291 | ); | ||
| 292 | } finally { | ||
| 293 | setIsSwitchingRuntimeMode(false); | ||
| 294 | } | ||
| 295 | }; | ||
| 296 | |||
| 297 | // Auto-scroll on new messages | ||
| 298 | const messageCount = activeThread?.messages.length ?? 0; | ||
| 299 | const workLogCount = workLogEntries.length; | ||
| 300 | useEffect(() => { | ||
| 301 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | ||
| 302 | }, [messageCount]); | ||
| 303 | useEffect(() => { | ||
| 304 | if (phase !== "running") return; | ||
| 305 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | ||
| 306 | }, [phase, workLogCount]); | ||
| 307 | |||
| 308 | useEffect(() => { | ||
| 309 | setExpandedWorkGroups({}); | ||
| 310 | }, [activeThread?.id]); | ||
| 311 | |||
| 312 | // Auto-resize textarea | ||
| 313 | useEffect(() => { | ||
| 314 | const ta = textareaRef.current; | ||
| 315 | if (!ta) return; | ||
| 316 | ta.style.height = "auto"; | ||
| 317 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; | ||
| 318 | }, [prompt]); | ||
| 319 | |||
| 320 | useEffect(() => { | ||
| 321 | if (phase !== "running") return; | ||
| 322 | const timer = window.setInterval(() => { | ||
| 323 | setNowTick(Date.now()); | ||
| 324 | }, 250); | ||
| 325 | return () => { | ||
| 326 | window.clearInterval(timer); | ||
| 327 | }; | ||
| 328 | }, [phase]); | ||
| 329 | |||
| 330 | useEffect(() => { | ||
| 331 | if (!isModelMenuOpen) return; | ||
| 332 | |||
| 333 | const handleClickOutside = (event: MouseEvent) => { | ||
| 334 | if (!modelMenuRef.current) return; | ||
| 335 | if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) { | ||
| 336 | setIsModelMenuOpen(false); | ||
| 337 | } | ||
| 338 | }; | ||
| 339 | |||
| 340 | window.addEventListener("mousedown", handleClickOutside); | ||
| 341 | return () => { | ||
| 342 | window.removeEventListener("mousedown", handleClickOutside); | ||
| 343 | }; | ||
| 344 | }, [isModelMenuOpen]); | ||
| 345 | |||
| 346 | useEffect(() => { | ||
| 347 | if (!isEditorMenuOpen) return; | ||
| 348 | |||
| 349 | const handleClickOutside = (event: MouseEvent) => { | ||
| 350 | if (!editorMenuRef.current) return; | ||
| 351 | if ( | ||
| 352 | event.target instanceof Node && | ||
| 353 | !editorMenuRef.current.contains(event.target) | ||
| 354 | ) { | ||
| 355 | setIsEditorMenuOpen(false); | ||
| 356 | } | ||
| 357 | }; | ||
| 358 | |||
| 359 | window.addEventListener("mousedown", handleClickOutside); | ||
| 360 | return () => { | ||
| 361 | window.removeEventListener("mousedown", handleClickOutside); | ||
| 362 | }; | ||
| 363 | }, [isEditorMenuOpen]); | ||
| 364 | |||
| 365 | // Cmd+O / Ctrl+O to open in last-used editor | ||
| 366 | useEffect(() => { | ||
| 367 | const handler = (e: globalThis.KeyboardEvent) => { | ||
| 368 | if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { | ||
| 369 | if (api && activeProject) { | ||
| 370 | e.preventDefault(); | ||
| 371 | void api.shell.openInEditor(activeProject.cwd, lastEditor); | ||
| 372 | } | ||
| 373 | } | ||
| 374 | }; | ||
| 375 | window.addEventListener("keydown", handler); | ||
| 376 | return () => window.removeEventListener("keydown", handler); | ||
| 377 | }, [api, activeProject, lastEditor]); | ||
| 378 | |||
| 379 | const openInEditor = (editorId: EditorId) => { | ||
| 380 | if (!api || !activeProject) return; | ||
| 381 | void api.shell.openInEditor(activeProject.cwd, editorId); | ||
| 382 | setLastEditor(editorId); | ||
| 383 | localStorage.setItem(LAST_EDITOR_KEY, editorId); | ||
| 384 | setIsEditorMenuOpen(false); | ||
| 385 | }; | ||
| 386 | |||
| 387 | const ensureSession = async (): Promise<EnsuredSessionInfo | null> => { | ||
| 388 | if (!api || !activeThread || !activeProject) return null; | ||
| 389 | if (activeThread.session && activeThread.session.status !== "closed") { | ||
| 390 | const sessionThreadId = activeThread.session.threadId ?? null; | ||
| 391 | const continuityState: SessionContinuityState = | ||
| 392 | activeThread.codexThreadId === null | ||
| 393 | ? "new" | ||
| 394 | : sessionThreadId === activeThread.codexThreadId | ||
| 395 | ? "resumed" | ||
| 396 | : "fallback_new"; | ||
| 397 | return { | ||
| 398 | sessionId: activeThread.session.sessionId, | ||
| 399 | resolvedThreadId: sessionThreadId, | ||
| 400 | continuityState, | ||
| 401 | } satisfies EnsuredSessionInfo; | ||
| 402 | } | ||
| 403 | |||
| 404 | const priorCodexThreadId = activeThread.codexThreadId; | ||
| 405 | setIsConnecting(true); | ||
| 406 | try { | ||
| 407 | const session = await api.providers.startSession({ | ||
| 408 | provider: "codex", | ||
| 409 | cwd: activeProject.cwd || undefined, | ||
| 410 | model: selectedModel || undefined, | ||
| 411 | resumeThreadId: priorCodexThreadId ?? undefined, | ||
| 412 | approvalPolicy: runtimeSessionConfig.approvalPolicy, | ||
| 413 | sandboxMode: runtimeSessionConfig.sandboxMode, | ||
| 414 | }); | ||
| 415 | dispatch({ | ||
| 416 | type: "UPDATE_SESSION", | ||
| 417 | threadId: activeThread.id, | ||
| 418 | session, | ||
| 419 | }); | ||
| 420 | const resolvedThreadId = session.threadId ?? null; | ||
| 421 | const continuityState: SessionContinuityState = | ||
| 422 | priorCodexThreadId === null | ||
| 423 | ? "new" | ||
| 424 | : resolvedThreadId === priorCodexThreadId | ||
| 425 | ? "resumed" | ||
| 426 | : "fallback_new"; | ||
| 427 | return { | ||
| 428 | sessionId: session.sessionId, | ||
| 429 | resolvedThreadId, | ||
| 430 | continuityState, | ||
| 431 | }; | ||
| 432 | } catch (err) { | ||
| 433 | dispatch({ | ||
| 434 | type: "SET_ERROR", | ||
| 435 | threadId: activeThread.id, | ||
| 436 | error: err instanceof Error ? err.message : "Failed to connect.", | ||
| 437 | }); | ||
| 438 | return null; | ||
| 439 | } finally { | ||
| 440 | setIsConnecting(false); | ||
| 441 | } | ||
| 442 | }; | ||
| 443 | |||
| 444 | const onSend = async (e: FormEvent) => { | ||
| 445 | e.preventDefault(); | ||
| 446 | if (!api || !activeThread || isSending || isConnecting) return; | ||
| 447 | const trimmed = prompt.trim(); | ||
| 448 | if (!trimmed) return; | ||
| 449 | |||
| 450 | // Auto-title from first message | ||
| 451 | if (activeThread.messages.length === 0) { | ||
| 452 | const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed; | ||
| 453 | dispatch({ | ||
| 454 | type: "SET_THREAD_TITLE", | ||
| 455 | threadId: activeThread.id, | ||
| 456 | title, | ||
| 457 | }); | ||
| 458 | } | ||
| 459 | |||
| 460 | dispatch({ | ||
| 461 | type: "SET_ERROR", | ||
| 462 | threadId: activeThread.id, | ||
| 463 | error: null, | ||
| 464 | }); | ||
| 465 | dispatch({ | ||
| 466 | type: "PUSH_USER_MESSAGE", | ||
| 467 | threadId: activeThread.id, | ||
| 468 | id: crypto.randomUUID(), | ||
| 469 | text: trimmed, | ||
| 470 | }); | ||
| 471 | const previousMessages = activeThread.messages; | ||
| 472 | setPrompt(""); | ||
| 473 | |||
| 474 | const sessionInfo = await ensureSession(); | ||
| 475 | if (!sessionInfo) return; | ||
| 476 | |||
| 477 | setIsSending(true); | ||
| 478 | try { | ||
| 479 | const shouldBootstrap = | ||
| 480 | previousMessages.length > 0 && | ||
| 481 | (sessionInfo.continuityState === "new" || | ||
| 482 | sessionInfo.continuityState === "fallback_new"); | ||
| 483 | const input = shouldBootstrap | ||
| 484 | ? buildBootstrapInput( | ||
| 485 | previousMessages, | ||
| 486 | trimmed, | ||
| 487 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 488 | ).text | ||
| 489 | : trimmed; | ||
| 490 | await api.providers.sendTurn({ | ||
| 491 | sessionId: sessionInfo.sessionId, | ||
| 492 | input, | ||
| 493 | model: selectedModel || undefined, | ||
| 494 | effort: selectedEffort || undefined, | ||
| 495 | }); | ||
| 496 | } catch (err) { | ||
| 497 | dispatch({ | ||
| 498 | type: "SET_ERROR", | ||
| 499 | threadId: activeThread.id, | ||
| 500 | error: err instanceof Error ? err.message : "Failed to send message.", | ||
| 501 | }); | ||
| 502 | } finally { | ||
| 503 | setIsSending(false); | ||
| 504 | } | ||
| 505 | }; | ||
| 506 | |||
| 507 | const onInterrupt = async () => { | ||
| 508 | if (!api || !activeThread?.session) return; | ||
| 509 | await api.providers.interruptTurn({ | ||
| 510 | sessionId: activeThread.session.sessionId, | ||
| 511 | turnId: activeThread.session.activeTurnId, | ||
| 512 | }); | ||
| 513 | }; | ||
| 514 | |||
| 515 | const onRespondToApproval = async ( | ||
| 516 | requestId: string, | ||
| 517 | decision: ProviderApprovalDecision, | ||
| 518 | ) => { | ||
| 519 | if (!api || !activeThread?.session) return; | ||
| 520 | |||
| 521 | setRespondingRequestIds((existing) => | ||
| 522 | existing.includes(requestId) ? existing : [...existing, requestId], | ||
| 523 | ); | ||
| 524 | try { | ||
| 525 | await api.providers.respondToRequest({ | ||
| 526 | sessionId: activeThread.session.sessionId, | ||
| 527 | requestId, | ||
| 528 | decision, | ||
| 529 | }); | ||
| 530 | } catch (err) { | ||
| 531 | dispatch({ | ||
| 532 | type: "SET_ERROR", | ||
| 533 | threadId: activeThread.id, | ||
| 534 | error: | ||
| 535 | err instanceof Error | ||
| 536 | ? err.message | ||
| 537 | : "Failed to submit approval decision.", | ||
| 538 | }); | ||
| 539 | } finally { | ||
| 540 | setRespondingRequestIds((existing) => | ||
| 541 | existing.filter((id) => id !== requestId), | ||
| 542 | ); | ||
| 543 | } | ||
| 544 | }; | ||
| 545 | |||
| 546 | const onModelSelect = (model: string) => { | ||
| 547 | if (!activeThread) return; | ||
| 548 | dispatch({ | ||
| 549 | type: "SET_THREAD_MODEL", | ||
| 550 | threadId: activeThread.id, | ||
| 551 | model: resolveModelSlug(model), | ||
| 552 | }); | ||
| 553 | setIsModelMenuOpen(false); | ||
| 554 | }; | ||
| 555 | |||
| 556 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | ||
| 557 | if (e.key === "Enter" && !e.shiftKey) { | ||
| 558 | e.preventDefault(); | ||
| 559 | void onSend(e as unknown as FormEvent); | ||
| 560 | } | ||
| 561 | }; | ||
| 562 | |||
| 563 | // Empty state: no active thread | ||
| 564 | if (!activeThread) { | ||
| 565 | return ( | ||
| 566 | <div className="flex flex-1 flex-col bg-background text-muted-foreground/40"> | ||
| 567 | {isElectron && <div className="drag-region h-[52px] shrink-0" />} | ||
| 568 | <div className="flex flex-1 items-center justify-center"> | ||
| 569 | <div className="text-center"> | ||
| 570 | <p className="text-sm">Select a thread or create a new one to get started.</p> | ||
| 571 | </div> | ||
| 572 | </div> | ||
| 573 | </div> | ||
| 574 | ); | ||
| 575 | } | ||
| 576 | |||
| 577 | return ( | ||
| 578 | <div className="flex flex-1 flex-col bg-background"> | ||
| 579 | {/* Top bar */} | ||
| 580 | <header className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}> | ||
| 581 | <div className="flex items-center gap-3"> | ||
| 582 | <h2 className="text-sm font-medium text-foreground"> | ||
| 583 | {activeThread.title} | ||
| 584 | </h2> | ||
| 585 | </div> | ||
| 586 | <div className="flex items-center gap-3"> | ||
| 587 | {/* Open in editor */} | ||
| 588 | {activeProject && ( | ||
| 589 | <div className="relative" ref={editorMenuRef}> | ||
| 590 | <button | ||
| 591 | type="button" | ||
| 592 | className="rounded-md px-2 py-1 text-[10px] text-muted-foreground/40 transition-colors duration-150 hover:text-muted-foreground/60" | ||
| 593 | onClick={() => setIsEditorMenuOpen((v) => !v)} | ||
| 594 | > | ||
| 595 | Open in&hellip; | ||
| 596 | </button> | ||
| 597 | {isEditorMenuOpen && ( | ||
| 598 | <div className="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-md border border-border bg-popover py-1 shadow-xl"> | ||
| 599 | {EDITORS.map((editor) => ( | ||
| 600 | <button | ||
| 601 | key={editor.id} | ||
| 602 | type="button" | ||
| 603 | className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-foreground hover:bg-accent" | ||
| 604 | onClick={() => openInEditor(editor.id)} | ||
| 605 | > | ||
| 606 | {editorLabel(editor)} | ||
| 607 | {editor.id === lastEditor && ( | ||
| 608 | <kbd className="ml-auto text-[9px] text-muted-foreground/40"> | ||
| 609 | {navigator.platform.includes("Mac") | ||
| 610 | ? "\u2318O" | ||
| 611 | : "Ctrl+O"} | ||
| 612 | </kbd> | ||
| 613 | )} | ||
| 614 | </button> | ||
| 615 | ))} | ||
| 616 | </div> | ||
| 617 | )} | ||
| 618 | </div> | ||
| 619 | )} | ||
| 620 | {/* Diff toggle */} | ||
| 621 | <button | ||
| 622 | type="button" | ||
| 623 | className={`rounded-md px-2 py-1 text-[10px] transition-colors duration-150 ${ | ||
| 624 | state.diffOpen | ||
| 625 | ? "bg-accent text-foreground" | ||
| 626 | : "text-muted-foreground/40 hover:text-muted-foreground/60" | ||
| 627 | }`} | ||
| 628 | onClick={() => dispatch({ type: "TOGGLE_DIFF" })} | ||
| 629 | > | ||
| 630 | Diff | ||
| 631 | </button> | ||
| 632 | </div> | ||
| 633 | </header> | ||
| 634 | |||
| 635 | {/* Error banner */} | ||
| 636 | {activeThread.error && ( | ||
| 637 | <div className="mx-4 mt-3 rounded-lg border border-rose-400/20 bg-rose-900/20 px-3 py-2 text-xs text-rose-200"> | ||
| 638 | {activeThread.error} | ||
| 639 | </div> | ||
| 640 | )} | ||
| 641 | |||
| 642 | {pendingApprovals.length > 0 && ( | ||
| 643 | <div className="mx-4 mt-3 space-y-2"> | ||
| 644 | {pendingApprovals.map((approval) => { | ||
| 645 | const isResponding = respondingRequestIds.includes( | ||
| 646 | approval.requestId, | ||
| 647 | ); | ||
| 648 | return ( | ||
| 649 | <div | ||
| 650 | key={approval.requestId} | ||
| 651 | className="rounded-lg border border-amber-300/20 bg-amber-500/[0.07] px-3 py-2" | ||
| 652 | > | ||
| 653 | <p className="text-xs font-medium text-amber-100"> | ||
| 654 | {approval.requestKind === "command" | ||
| 655 | ? "Command approval requested" | ||
| 656 | : "File-change approval requested"} | ||
| 657 | </p> | ||
| 658 | {approval.detail && ( | ||
| 659 | <p | ||
| 660 | className="mt-1 truncate font-mono text-[11px] text-amber-100/75" | ||
| 661 | title={approval.detail} | ||
| 662 | > | ||
| 663 | {approval.detail} | ||
| 664 | </p> | ||
| 665 | )} | ||
| 666 | <div className="mt-2 flex flex-wrap gap-1.5"> | ||
| 667 | <button | ||
| 668 | type="button" | ||
| 669 | className="rounded-md border border-border bg-accent px-2 py-1 text-[11px] text-foreground transition-colors duration-150 hover:bg-accent/80 disabled:cursor-not-allowed disabled:opacity-50" | ||
| 670 | disabled={isResponding} | ||
| 671 | onClick={() => | ||
| 672 | void onRespondToApproval(approval.requestId, "accept") | ||
| 673 | } | ||
| 674 | > | ||
| 675 | Approve once | ||
| 676 | </button> | ||
| 677 | <button | ||
| 678 | type="button" | ||
| 679 | className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 680 | disabled={isResponding} | ||
| 681 | onClick={() => | ||
| 682 | void onRespondToApproval( | ||
| 683 | approval.requestId, | ||
| 684 | "acceptForSession", | ||
| 685 | ) | ||
| 686 | } | ||
| 687 | > | ||
| 688 | Always allow this session | ||
| 689 | </button> | ||
| 690 | <button | ||
| 691 | type="button" | ||
| 692 | className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground/90 transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50" | ||
| 693 | disabled={isResponding} | ||
| 694 | onClick={() => | ||
| 695 | void onRespondToApproval(approval.requestId, "decline") | ||
| 696 | } | ||
| 697 | > | ||
| 698 | Decline | ||
| 699 | </button> | ||
| 700 | <button | ||
| 701 | type="button" | ||
| 702 | className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50" | ||
| 703 | disabled={isResponding} | ||
| 704 | onClick={() => | ||
| 705 | void onRespondToApproval(approval.requestId, "cancel") | ||
| 706 | } | ||
| 707 | > | ||
| 708 | Cancel turn | ||
| 709 | </button> | ||
| 710 | </div> | ||
| 711 | </div> | ||
| 712 | ); | ||
| 713 | })} | ||
| 714 | </div> | ||
| 715 | )} | ||
| 716 | |||
| 717 | {/* Messages */} | ||
| 718 | <div className="flex-1 overflow-y-auto px-5 py-4"> | ||
| 719 | {activeThread.messages.length === 0 && !isWorking ? ( | ||
| 720 | <div className="flex h-full items-center justify-center"> | ||
| 721 | <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p> | ||
| 722 | </div> | ||
| 723 | ) : ( | ||
| 724 | <div className="mx-auto max-w-3xl space-y-4"> | ||
| 725 | {timelineEntries.map((timelineEntry, index) => { | ||
| 726 | if ( | ||
| 727 | timelineEntry.kind === "work" && | ||
| 728 | timelineEntries[index - 1]?.kind === "work" | ||
| 729 | ) { | ||
| 730 | return null; | ||
| 731 | } | ||
| 732 | |||
| 733 | const showCompletionDivider = | ||
| 734 | timelineEntry.kind === "message" && | ||
| 735 | timelineEntry.message.role === "assistant" && | ||
| 736 | completionDividerBeforeEntryId === timelineEntry.id; | ||
| 737 | |||
| 738 | if (timelineEntry.kind === "work") { | ||
| 739 | const groupedEntries = [timelineEntry.entry]; | ||
| 740 | let cursor = index + 1; | ||
| 741 | while (cursor < timelineEntries.length) { | ||
| 742 | const nextEntry = timelineEntries[cursor]; | ||
| 743 | if (!nextEntry || nextEntry.kind !== "work") break; | ||
| 744 | groupedEntries.push(nextEntry.entry); | ||
| 745 | cursor += 1; | ||
| 746 | } | ||
| 747 | |||
| 748 | const groupId = timelineEntry.id; | ||
| 749 | const isExpanded = expandedWorkGroups[groupId] ?? false; | ||
| 750 | const hasOverflow = | ||
| 751 | groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | ||
| 752 | const visibleEntries = | ||
| 753 | hasOverflow && !isExpanded | ||
| 754 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | ||
| 755 | : groupedEntries; | ||
| 756 | const hiddenCount = groupedEntries.length - visibleEntries.length; | ||
| 757 | const onlyToolEntries = groupedEntries.every( | ||
| 758 | (entry) => entry.tone === "tool", | ||
| 759 | ); | ||
| 760 | const groupLabel = onlyToolEntries | ||
| 761 | ? groupedEntries.length === 1 | ||
| 762 | ? "Tool call" | ||
| 763 | : `Tool calls (${groupedEntries.length})` | ||
| 764 | : groupedEntries.length === 1 | ||
| 765 | ? "Work event" | ||
| 766 | : `Work log (${groupedEntries.length})`; | ||
| 767 | |||
| 768 | return ( | ||
| 769 | <Fragment key={timelineEntry.id}> | ||
| 770 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | ||
| 771 | <div className="mb-1.5 flex items-center justify-between gap-3"> | ||
| 772 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | ||
| 773 | {groupLabel} | ||
| 774 | </p> | ||
| 775 | {hasOverflow && ( | ||
| 776 | <button | ||
| 777 | type="button" | ||
| 778 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | ||
| 779 | onClick={() => | ||
| 780 | setExpandedWorkGroups((existing) => ({ | ||
| 781 | ...existing, | ||
| 782 | [groupId]: !existing[groupId], | ||
| 783 | })) | ||
| 784 | } | ||
| 785 | > | ||
| 786 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | ||
| 787 | </button> | ||
| 788 | )} | ||
| 789 | </div> | ||
| 790 | <div className="space-y-1"> | ||
| 791 | {visibleEntries.map((workEntry) => ( | ||
| 792 | <div | ||
| 793 | key={`work-row:${workEntry.id}`} | ||
| 794 | className="flex items-start gap-2 py-0.5" | ||
| 795 | > | ||
| 796 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 797 | <p | ||
| 798 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | ||
| 799 | > | ||
| 800 | {workEntry.detail ? ( | ||
| 801 | <> | ||
| 802 | {workEntry.label} | ||
| 803 | <span | ||
| 804 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | ||
| 805 | title={workEntry.detail} | ||
| 806 | > | ||
| 807 | {workEntry.detail} | ||
| 808 | </span> | ||
| 809 | </> | ||
| 810 | ) : ( | ||
| 811 | workEntry.label | ||
| 812 | )} | ||
| 813 | </p> | ||
| 814 | </div> | ||
| 815 | ))} | ||
| 816 | </div> | ||
| 817 | </div> | ||
| 818 | </Fragment> | ||
| 819 | ); | ||
| 820 | } | ||
| 821 | |||
| 822 | if (timelineEntry.message.role === "user") { | ||
| 823 | return ( | ||
| 824 | <Fragment key={timelineEntry.id}> | ||
| 825 | <div className="flex justify-end"> | ||
| 826 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | ||
| 827 | <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground"> | ||
| 828 | {timelineEntry.message.text} | ||
| 829 | </pre> | ||
| 830 | <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30"> | ||
| 831 | {formatTimestamp(timelineEntry.message.createdAt)} | ||
| 832 | </p> | ||
| 833 | </div> | ||
| 834 | </div> | ||
| 835 | </Fragment> | ||
| 836 | ); | ||
| 837 | } | ||
| 838 | |||
| 839 | return ( | ||
| 840 | <Fragment key={timelineEntry.id}> | ||
| 841 | {showCompletionDivider && ( | ||
| 842 | <div className="my-3 flex items-center gap-3"> | ||
| 843 | <span className="h-px flex-1 bg-border" /> | ||
| 844 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | ||
| 845 | {completionSummary | ||
| 846 | ? `Response • ${completionSummary}` | ||
| 847 | : "Response"} | ||
| 848 | </span> | ||
| 849 | <span className="h-px flex-1 bg-border" /> | ||
| 850 | </div> | ||
| 851 | )} | ||
| 852 | <div className="px-1 py-0.5"> | ||
| 853 | <ChatMarkdown | ||
| 854 | text={ | ||
| 855 | timelineEntry.message.text || | ||
| 856 | (timelineEntry.message.streaming | ||
| 857 | ? "" | ||
| 858 | : "(empty response)") | ||
| 859 | } | ||
| 860 | /> | ||
| 861 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | ||
| 862 | {formatMessageMeta( | ||
| 863 | timelineEntry.message.createdAt, | ||
| 864 | timelineEntry.message.streaming | ||
| 865 | ? formatElapsed( | ||
| 866 | timelineEntry.message.createdAt, | ||
| 867 | nowIso, | ||
| 868 | ) | ||
| 869 | : formatElapsed( | ||
| 870 | timelineEntry.message.createdAt, | ||
| 871 | assistantCompletionByItemId.get( | ||
| 872 | timelineEntry.message.id, | ||
| 873 | ), | ||
| 874 | ), | ||
| 875 | )} | ||
| 876 | </p> | ||
| 877 | </div> | ||
| 878 | </Fragment> | ||
| 879 | ); | ||
| 880 | })} | ||
| 881 | {isWorking && ( | ||
| 882 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | ||
| 883 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 884 | <div className="flex items-center pt-1"> | ||
| 885 | <span className="inline-flex items-center gap-[3px]"> | ||
| 886 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | ||
| 887 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | ||
| 888 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | ||
| 889 | </span> | ||
| 890 | </div> | ||
| 891 | </div> | ||
| 892 | )} | ||
| 893 | <div ref={messagesEndRef} /> | ||
| 894 | </div> | ||
| 895 | )} | ||
| 896 | </div> | ||
| 897 | |||
| 898 | {/* Input bar */} | ||
| 899 | <div className="px-5 pb-4 pt-2"> | ||
| 900 | <form onSubmit={onSend} className="mx-auto max-w-3xl"> | ||
| 901 | <div className="group rounded-[20px] border border-border bg-card transition-colors duration-200 focus-within:border-ring"> | ||
| 902 | {/* Textarea area */} | ||
| 903 | <div className="px-4 pt-4 pb-2"> | ||
| 904 | <textarea | ||
| 905 | ref={textareaRef} | ||
| 906 | className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" | ||
| 907 | rows={2} | ||
| 908 | value={prompt} | ||
| 909 | onChange={(e) => setPrompt(e.target.value)} | ||
| 910 | onKeyDown={onKeyDown} | ||
| 911 | placeholder={ | ||
| 912 | phase === "disconnected" ? "Ask for follow-up changes" : "Ask anything..." | ||
| 913 | } | ||
| 914 | disabled={isSending || isConnecting} | ||
| 915 | /> | ||
| 916 | </div> | ||
| 917 | |||
| 918 | {/* Bottom toolbar */} | ||
| 919 | <div className="flex items-center justify-between px-3 pb-3"> | ||
| 920 | <div className="flex items-center gap-1"> | ||
| 921 | {/* Model picker */} | ||
| 922 | <div className="relative" ref={modelMenuRef}> | ||
| 923 | <button | ||
| 924 | type="button" | ||
| 925 | className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80" | ||
| 926 | onClick={() => setIsModelMenuOpen((open) => !open)} | ||
| 927 | > | ||
| 928 | <span className="max-w-[180px] truncate">{selectedModel}</span> | ||
| 929 | <svg | ||
| 930 | width="10" | ||
| 931 | height="10" | ||
| 932 | viewBox="0 0 10 10" | ||
| 933 | fill="none" | ||
| 934 | className="opacity-50" | ||
| 935 | aria-hidden="true" | ||
| 936 | > | ||
| 937 | <path | ||
| 938 | d="M2.5 4L5 6.5L7.5 4" | ||
| 939 | stroke="currentColor" | ||
| 940 | strokeWidth="1.2" | ||
| 941 | strokeLinecap="round" | ||
| 942 | strokeLinejoin="round" | ||
| 943 | /> | ||
| 944 | </svg> | ||
| 945 | </button> | ||
| 946 | {isModelMenuOpen && ( | ||
| 947 | <div className="absolute bottom-full left-0 z-20 mb-2 w-[320px] rounded-2xl border border-border bg-popover/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur"> | ||
| 948 | <p className="px-2 py-1 text-[11px] text-muted-foreground/70">Select model</p> | ||
| 949 | <div className="max-h-72 overflow-y-auto"> | ||
| 950 | {modelOptions.map((model) => { | ||
| 951 | const isSelected = model === selectedModel; | ||
| 952 | return ( | ||
| 953 | <button | ||
| 954 | key={model} | ||
| 955 | type="button" | ||
| 956 | className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-2 text-left font-mono text-sm transition-colors duration-150 ${ | ||
| 957 | isSelected | ||
| 958 | ? "bg-accent text-foreground" | ||
| 959 | : "text-foreground/90 hover:bg-accent" | ||
| 960 | }`} | ||
| 961 | onClick={() => onModelSelect(model)} | ||
| 962 | > | ||
| 963 | <span className="truncate">{model}</span> | ||
| 964 | <span | ||
| 965 | className={`pt-0.5 text-sm ${ | ||
| 966 | isSelected ? "text-foreground" : "text-transparent" | ||
| 967 | }`} | ||
| 968 | > | ||
| 969 | ✓ | ||
| 970 | </span> | ||
| 971 | </button> | ||
| 972 | ); | ||
| 973 | })} | ||
| 974 | </div> | ||
| 975 | </div> | ||
| 976 | )} | ||
| 977 | </div> | ||
| 978 | |||
| 979 | {/* Divider */} | ||
| 980 | <div className="mx-0.5 h-4 w-px bg-border" /> | ||
| 981 | |||
| 982 | {/* Reasoning effort */} | ||
| 983 | <label | ||
| 984 | className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80" | ||
| 985 | htmlFor="reasoning-effort" | ||
| 986 | > | ||
| 987 | <span>{selectedEffort.charAt(0).toUpperCase() + selectedEffort.slice(1)}</span> | ||
| 988 | <select | ||
| 989 | id="reasoning-effort" | ||
| 990 | className="absolute opacity-0 w-0 h-0" | ||
| 991 | value={selectedEffort} | ||
| 992 | onChange={(event) => setSelectedEffort(event.target.value)} | ||
| 993 | > | ||
| 994 | {REASONING_OPTIONS.map((effort) => ( | ||
| 995 | <option key={effort} value={effort} className="bg-popover"> | ||
| 996 | {effort} | ||
| 997 | {effort === DEFAULT_REASONING ? " (default)" : ""} | ||
| 998 | </option> | ||
| 999 | ))} | ||
| 1000 | </select> | ||
| 1001 | <svg | ||
| 1002 | width="10" | ||
| 1003 | height="10" | ||
| 1004 | viewBox="0 0 10 10" | ||
| 1005 | fill="none" | ||
| 1006 | className="opacity-50" | ||
| 1007 | aria-hidden="true" | ||
| 1008 | > | ||
| 1009 | <path | ||
| 1010 | d="M2.5 4L5 6.5L7.5 4" | ||
| 1011 | stroke="currentColor" | ||
| 1012 | strokeWidth="1.2" | ||
| 1013 | strokeLinecap="round" | ||
| 1014 | strokeLinejoin="round" | ||
| 1015 | /> | ||
| 1016 | </svg> | ||
| 1017 | </label> | ||
| 1018 | |||
| 1019 | {/* Divider */} | ||
| 1020 | <div className="mx-0.5 h-4 w-px bg-border" /> | ||
| 1021 | |||
| 1022 | {/* Runtime mode toggle */} | ||
| 1023 | <button | ||
| 1024 | type="button" | ||
| 1025 | className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80" | ||
| 1026 | disabled={isSwitchingRuntimeMode} | ||
| 1027 | onClick={() => | ||
| 1028 | void handleRuntimeModeChange( | ||
| 1029 | state.runtimeMode === "full-access" | ||
| 1030 | ? "approval-required" | ||
| 1031 | : "full-access", | ||
| 1032 | ) | ||
| 1033 | } | ||
| 1034 | title={ | ||
| 1035 | state.runtimeMode === "full-access" | ||
| 1036 | ? "Full access — click to require approvals" | ||
| 1037 | : "Approval required — click for full access" | ||
| 1038 | } | ||
| 1039 | > | ||
| 1040 | {state.runtimeMode === "full-access" ? ( | ||
| 1041 | <svg | ||
| 1042 | width="14" | ||
| 1043 | height="14" | ||
| 1044 | viewBox="0 0 14 14" | ||
| 1045 | fill="none" | ||
| 1046 | aria-hidden="true" | ||
| 1047 | > | ||
| 1048 | <rect | ||
| 1049 | x="2" | ||
| 1050 | y="5.5" | ||
| 1051 | width="10" | ||
| 1052 | height="7" | ||
| 1053 | rx="1.5" | ||
| 1054 | stroke="currentColor" | ||
| 1055 | strokeWidth="1.3" | ||
| 1056 | /> | ||
| 1057 | <path | ||
| 1058 | d="M9.5 5.5V4a2.5 2.5 0 0 0-5 0" | ||
| 1059 | stroke="currentColor" | ||
| 1060 | strokeWidth="1.3" | ||
| 1061 | strokeLinecap="round" | ||
| 1062 | /> | ||
| 1063 | </svg> | ||
| 1064 | ) : ( | ||
| 1065 | <svg | ||
| 1066 | width="14" | ||
| 1067 | height="14" | ||
| 1068 | viewBox="0 0 14 14" | ||
| 1069 | fill="none" | ||
| 1070 | aria-hidden="true" | ||
| 1071 | > | ||
| 1072 | <rect | ||
| 1073 | x="2" | ||
| 1074 | y="5.5" | ||
| 1075 | width="10" | ||
| 1076 | height="7" | ||
| 1077 | rx="1.5" | ||
| 1078 | stroke="currentColor" | ||
| 1079 | strokeWidth="1.3" | ||
| 1080 | /> | ||
| 1081 | <path | ||
| 1082 | d="M4.5 5.5V4a2.5 2.5 0 0 1 5 0v1.5" | ||
| 1083 | stroke="currentColor" | ||
| 1084 | strokeWidth="1.3" | ||
| 1085 | strokeLinecap="round" | ||
| 1086 | /> | ||
| 1087 | </svg> | ||
| 1088 | )} | ||
| 1089 | <span> | ||
| 1090 | {state.runtimeMode === "full-access" | ||
| 1091 | ? "Full access" | ||
| 1092 | : "Supervised"} | ||
| 1093 | </span> | ||
| 1094 | </button> | ||
| 1095 | </div> | ||
| 1096 | |||
| 1097 | {/* Right side: send / stop button */} | ||
| 1098 | <div className="flex items-center gap-2"> | ||
| 1099 | {phase === "running" ? ( | ||
| 1100 | <button | ||
| 1101 | type="button" | ||
| 1102 | className="flex h-8 w-8 items-center justify-center rounded-full bg-rose-500/90 text-white transition-all duration-150 hover:bg-rose-500 hover:scale-105" | ||
| 1103 | onClick={() => void onInterrupt()} | ||
| 1104 | aria-label="Stop generation" | ||
| 1105 | > | ||
| 1106 | <svg | ||
| 1107 | width="12" | ||
| 1108 | height="12" | ||
| 1109 | viewBox="0 0 12 12" | ||
| 1110 | fill="currentColor" | ||
| 1111 | aria-hidden="true" | ||
| 1112 | > | ||
| 1113 | <rect x="2" y="2" width="8" height="8" rx="1.5" /> | ||
| 1114 | </svg> | ||
| 1115 | </button> | ||
| 1116 | ) : ( | ||
| 1117 | <button | ||
| 1118 | type="submit" | ||
| 1119 | className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:opacity-30 disabled:hover:scale-100" | ||
| 1120 | disabled={isSending || isConnecting || !prompt.trim()} | ||
| 1121 | aria-label={ | ||
| 1122 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" | ||
| 1123 | } | ||
| 1124 | > | ||
| 1125 | {isConnecting || isSending ? ( | ||
| 1126 | <svg | ||
| 1127 | width="14" | ||
| 1128 | height="14" | ||
| 1129 | viewBox="0 0 14 14" | ||
| 1130 | fill="none" | ||
| 1131 | className="animate-spin" | ||
| 1132 | aria-hidden="true" | ||
| 1133 | > | ||
| 1134 | <circle | ||
| 1135 | cx="7" | ||
| 1136 | cy="7" | ||
| 1137 | r="5.5" | ||
| 1138 | stroke="currentColor" | ||
| 1139 | strokeWidth="1.5" | ||
| 1140 | strokeLinecap="round" | ||
| 1141 | strokeDasharray="20 12" | ||
| 1142 | /> | ||
| 1143 | </svg> | ||
| 1144 | ) : ( | ||
| 1145 | <svg | ||
| 1146 | width="14" | ||
| 1147 | height="14" | ||
| 1148 | viewBox="0 0 14 14" | ||
| 1149 | fill="none" | ||
| 1150 | aria-hidden="true" | ||
| 1151 | > | ||
| 1152 | <path | ||
| 1153 | d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5" | ||
| 1154 | stroke="currentColor" | ||
| 1155 | strokeWidth="1.8" | ||
| 1156 | strokeLinecap="round" | ||
| 1157 | strokeLinejoin="round" | ||
| 1158 | /> | ||
| 1159 | </svg> | ||
| 1160 | )} | ||
| 1161 | </button> | ||
| 1162 | )} | ||
| 1163 | </div> | ||
| 1164 | </div> | ||
| 1165 | </div> | ||
| 1166 | </form> | ||
| 1167 | </div> | ||
| 1168 | </div> | ||
| 1169 | ); | ||
| 1170 | } | ||
apps/web/src/components/DiffPanel.tsx created+53| ... | @@ -0,0 +1,53 @@ | ||
| 1 | import { isElectron } from "../env"; | ||
| 2 | import { useStore } from "../store"; | ||
| 3 | |||
| 4 | export default function DiffPanel() { | ||
| 5 | const { dispatch } = useStore(); | ||
| 6 | |||
| 7 | return ( | ||
| 8 | <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card"> | ||
| 9 | {/* Header */} | ||
| 10 | <div className={`flex items-center justify-between border-b border-border px-4 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}> | ||
| 11 | <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3> | ||
| 12 | <button | ||
| 13 | type="button" | ||
| 14 | className="text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground" | ||
| 15 | onClick={() => dispatch({ type: "TOGGLE_DIFF" })} | ||
| 16 | > | ||
| 17 | <span className="text-sm">&times;</span> | ||
| 18 | </button> | ||
| 19 | </div> | ||
| 20 | |||
| 21 | {/* Placeholder content */} | ||
| 22 | <div className="flex-1 overflow-y-auto p-4"> | ||
| 23 | <div className="space-y-3"> | ||
| 24 | <div className="rounded-lg border border-border/80 bg-muted p-3"> | ||
| 25 | <div className="mb-2 flex items-center gap-2"> | ||
| 26 | <span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[9px] font-medium text-emerald-400"> | ||
| 27 | M | ||
| 28 | </span> | ||
| 29 | <span className="font-mono text-xs text-muted-foreground/80">src/App.tsx</span> | ||
| 30 | </div> | ||
| 31 | <div className="space-y-0.5 font-mono text-[11px]"> | ||
| 32 | <div className="text-red-400/60">- const oldValue = "hello";</div> | ||
| 33 | <div className="text-emerald-400/60">+ const newValue = "world";</div> | ||
| 34 | </div> | ||
| 35 | </div> | ||
| 36 | |||
| 37 | <div className="rounded-lg border border-border/80 bg-muted p-3"> | ||
| 38 | <div className="flex items-center gap-2"> | ||
| 39 | <span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-medium text-blue-400"> | ||
| 40 | A | ||
| 41 | </span> | ||
| 42 | <span className="font-mono text-xs text-muted-foreground/80">src/utils.ts</span> | ||
| 43 | </div> | ||
| 44 | </div> | ||
| 45 | </div> | ||
| 46 | |||
| 47 | <p className="mt-6 text-center text-[10px] text-muted-foreground/30"> | ||
| 48 | Diff integration coming soon | ||
| 49 | </p> | ||
| 50 | </div> | ||
| 51 | </aside> | ||
| 52 | ); | ||
| 53 | } | ||
apps/web/src/components/Sidebar.tsx created+390| ... | @@ -0,0 +1,390 @@ | ||
| 1 | import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; | ||
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 3 | import { isElectron } from "../env"; | ||
| 4 | import { useTheme } from "../hooks/useTheme"; | ||
| 5 | import { DEFAULT_MODEL } from "../model-logic"; | ||
| 6 | import { readNativeApi } from "../session-logic"; | ||
| 7 | import { useStore } from "../store"; | ||
| 8 | import type { Project } from "../types"; | ||
| 9 | |||
| 10 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | ||
| 11 | function formatRelativeTime(iso: string): string { | ||
| 12 | const diff = Date.now() - new Date(iso).getTime(); | ||
| 13 | const minutes = Math.floor(diff / 60_000); | ||
| 14 | if (minutes < 1) return "just now"; | ||
| 15 | if (minutes < 60) return `${minutes}m ago`; | ||
| 16 | const hours = Math.floor(minutes / 60); | ||
| 17 | if (hours < 24) return `${hours}h ago`; | ||
| 18 | return `${Math.floor(hours / 24)}d ago`; | ||
| 19 | } | ||
| 20 | |||
| 21 | function inferProjectName(cwd: string): string { | ||
| 22 | const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); | ||
| 23 | const parts = normalized.split("/"); | ||
| 24 | const last = parts[parts.length - 1]; | ||
| 25 | return last && last.length > 0 ? last : "project"; | ||
| 26 | } | ||
| 27 | |||
| 28 | function threadStatusLabel( | ||
| 29 | status: "connecting" | "ready" | "running" | "error" | "closed" | undefined, | ||
| 30 | ): "Working" | "Connecting" | null { | ||
| 31 | if (status === "running") return "Working"; | ||
| 32 | if (status === "connecting") return "Connecting"; | ||
| 33 | return null; | ||
| 34 | } | ||
| 35 | |||
| 36 | export default function Sidebar() { | ||
| 37 | const { state, dispatch } = useStore(); | ||
| 38 | const api = useMemo(() => readNativeApi(), []); | ||
| 39 | const { theme, setTheme } = useTheme(); | ||
| 40 | const [addingProject, setAddingProject] = useState(false); | ||
| 41 | const [newCwd, setNewCwd] = useState(""); | ||
| 42 | const [isPickingFolder, setIsPickingFolder] = useState(false); | ||
| 43 | const [isAddingProject, setIsAddingProject] = useState(false); | ||
| 44 | |||
| 45 | const handleNewThread = useCallback( | ||
| 46 | (projectId: string) => { | ||
| 47 | dispatch({ | ||
| 48 | type: "ADD_THREAD", | ||
| 49 | thread: { | ||
| 50 | id: crypto.randomUUID(), | ||
| 51 | codexThreadId: null, | ||
| 52 | projectId, | ||
| 53 | title: "New thread", | ||
| 54 | model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, | ||
| 55 | session: null, | ||
| 56 | messages: [], | ||
| 57 | events: [], | ||
| 58 | error: null, | ||
| 59 | createdAt: new Date().toISOString(), | ||
| 60 | }, | ||
| 61 | }); | ||
| 62 | }, | ||
| 63 | [dispatch, state.projects], | ||
| 64 | ); | ||
| 65 | |||
| 66 | const focusMostRecentThreadForProject = useCallback( | ||
| 67 | (projectId: string) => { | ||
| 68 | const latestThread = state.threads | ||
| 69 | .filter((thread) => thread.projectId === projectId) | ||
| 70 | .toSorted((a, b) => { | ||
| 71 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | ||
| 72 | if (byDate !== 0) return byDate; | ||
| 73 | return b.id.localeCompare(a.id); | ||
| 74 | })[0]; | ||
| 75 | if (!latestThread) return; | ||
| 76 | |||
| 77 | dispatch({ | ||
| 78 | type: "SET_ACTIVE_THREAD", | ||
| 79 | threadId: latestThread.id, | ||
| 80 | }); | ||
| 81 | }, | ||
| 82 | [dispatch, state.threads], | ||
| 83 | ); | ||
| 84 | |||
| 85 | const addProjectFromPath = useCallback( | ||
| 86 | async (rawCwd: string) => { | ||
| 87 | const cwd = rawCwd.trim(); | ||
| 88 | if (!cwd || isAddingProject) return; | ||
| 89 | |||
| 90 | setIsAddingProject(true); | ||
| 91 | try { | ||
| 92 | if (isElectron && api) { | ||
| 93 | const result = await api.projects.add({ cwd }); | ||
| 94 | const project: Project = { | ||
| 95 | id: result.project.id, | ||
| 96 | name: result.project.name, | ||
| 97 | cwd: result.project.cwd, | ||
| 98 | model: DEFAULT_MODEL, | ||
| 99 | expanded: true, | ||
| 100 | }; | ||
| 101 | const existingById = state.projects.find((p) => p.id === project.id); | ||
| 102 | const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); | ||
| 103 | if (!existingById && !existingByCwd) { | ||
| 104 | dispatch({ type: "ADD_PROJECT", project }); | ||
| 105 | } | ||
| 106 | const resolvedProjectId = existingByCwd?.id ?? project.id; | ||
| 107 | |||
| 108 | if (result.created) { | ||
| 109 | handleNewThread(resolvedProjectId); | ||
| 110 | } else { | ||
| 111 | focusMostRecentThreadForProject(resolvedProjectId); | ||
| 112 | } | ||
| 113 | } else { | ||
| 114 | const existing = state.projects.find((project) => project.cwd === cwd); | ||
| 115 | if (existing) { | ||
| 116 | focusMostRecentThreadForProject(existing.id); | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | |||
| 120 | const name = inferProjectName(cwd); | ||
| 121 | const project: Project = { | ||
| 122 | id: crypto.randomUUID(), | ||
| 123 | name, | ||
| 124 | cwd, | ||
| 125 | model: DEFAULT_MODEL, | ||
| 126 | expanded: true, | ||
| 127 | }; | ||
| 128 | dispatch({ type: "ADD_PROJECT", project }); | ||
| 129 | handleNewThread(project.id); | ||
| 130 | } | ||
| 131 | } finally { | ||
| 132 | setIsAddingProject(false); | ||
| 133 | setNewCwd(""); | ||
| 134 | setAddingProject(false); | ||
| 135 | } | ||
| 136 | }, | ||
| 137 | [ | ||
| 138 | api, | ||
| 139 | dispatch, | ||
| 140 | focusMostRecentThreadForProject, | ||
| 141 | handleNewThread, | ||
| 142 | isAddingProject, | ||
| 143 | state.projects, | ||
| 144 | ], | ||
| 145 | ); | ||
| 146 | |||
| 147 | const handleAddProject = () => { | ||
| 148 | void addProjectFromPath(newCwd); | ||
| 149 | }; | ||
| 150 | |||
| 151 | const handlePickFolder = async () => { | ||
| 152 | if (!api || isPickingFolder) return; | ||
| 153 | setIsPickingFolder(true); | ||
| 154 | try { | ||
| 155 | const pickedPath = await api.dialogs.pickFolder(); | ||
| 156 | if (!pickedPath) return; | ||
| 157 | await addProjectFromPath(pickedPath); | ||
| 158 | } finally { | ||
| 159 | setIsPickingFolder(false); | ||
| 160 | } | ||
| 161 | }; | ||
| 162 | |||
| 163 | useEffect(() => { | ||
| 164 | const onWindowKeyDown = (event: KeyboardEvent) => { | ||
| 165 | const isNewThreadShortcut = | ||
| 166 | event.metaKey && | ||
| 167 | event.shiftKey && | ||
| 168 | !event.ctrlKey && | ||
| 169 | !event.altKey && | ||
| 170 | event.key.toLowerCase() === "o"; | ||
| 171 | if (!isNewThreadShortcut) return; | ||
| 172 | |||
| 173 | const target = event.target; | ||
| 174 | if (target instanceof HTMLElement) { | ||
| 175 | const tagName = target.tagName; | ||
| 176 | if ( | ||
| 177 | target.isContentEditable || | ||
| 178 | tagName === "INPUT" || | ||
| 179 | tagName === "TEXTAREA" || | ||
| 180 | tagName === "SELECT" | ||
| 181 | ) { | ||
| 182 | return; | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | ||
| 187 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | ||
| 188 | if (!projectId) return; | ||
| 189 | |||
| 190 | event.preventDefault(); | ||
| 191 | handleNewThread(projectId); | ||
| 192 | }; | ||
| 193 | |||
| 194 | window.addEventListener("keydown", onWindowKeyDown); | ||
| 195 | return () => { | ||
| 196 | window.removeEventListener("keydown", onWindowKeyDown); | ||
| 197 | }; | ||
| 198 | }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); | ||
| 199 | |||
| 200 | return ( | ||
| 201 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-border bg-card"> | ||
| 202 | {/* Drag region / traffic light space (Electron only) */} | ||
| 203 | {isElectron && <div className="drag-region h-[52px] shrink-0" />} | ||
| 204 | {/* Branding */} | ||
| 205 | <div className={`flex items-center gap-2.5 px-4 pb-4 ${isElectron ? "" : "pt-4"}`}> | ||
| 206 | <span className="flex-1 text-sm font-semibold tracking-tight text-foreground"> | ||
| 207 | T3 <span className="font-normal text-muted-foreground">Code</span> | ||
| 208 | </span> | ||
| 209 | <button | ||
| 210 | type="button" | ||
| 211 | className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground" | ||
| 212 | onClick={() => setTheme(THEME_CYCLE[theme])} | ||
| 213 | aria-label={`Theme: ${theme}`} | ||
| 214 | title={`Theme: ${theme}`} | ||
| 215 | > | ||
| 216 | {theme === "system" ? ( | ||
| 217 | <MonitorIcon className="size-3.5" /> | ||
| 218 | ) : theme === "light" ? ( | ||
| 219 | <SunIcon className="size-3.5" /> | ||
| 220 | ) : ( | ||
| 221 | <MoonIcon className="size-3.5" /> | ||
| 222 | )} | ||
| 223 | </button> | ||
| 224 | </div> | ||
| 225 | |||
| 226 | {/* New thread (global) */} | ||
| 227 | <div className="px-3 pb-3"> | ||
| 228 | <button | ||
| 229 | type="button" | ||
| 230 | className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent" | ||
| 231 | onClick={() => { | ||
| 232 | if (state.projects.length === 0) { | ||
| 233 | setAddingProject(true); | ||
| 234 | return; | ||
| 235 | } | ||
| 236 | const firstProject = state.projects[0]; | ||
| 237 | if (firstProject) handleNewThread(firstProject.id); | ||
| 238 | }} | ||
| 239 | > | ||
| 240 | <span className="text-foreground">+</span> | ||
| 241 | New thread | ||
| 242 | </button> | ||
| 243 | </div> | ||
| 244 | |||
| 245 | {/* Project list */} | ||
| 246 | <nav className="flex-1 overflow-y-auto px-2"> | ||
| 247 | {state.projects.map((project) => { | ||
| 248 | const threads = state.threads | ||
| 249 | .filter((t) => t.projectId === project.id) | ||
| 250 | .toSorted((a, b) => { | ||
| 251 | const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); | ||
| 252 | if (byDate !== 0) return byDate; | ||
| 253 | return b.id.localeCompare(a.id); | ||
| 254 | }); | ||
| 255 | return ( | ||
| 256 | <div key={project.id} className="mb-1"> | ||
| 257 | {/* Project header */} | ||
| 258 | <button | ||
| 259 | type="button" | ||
| 260 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-accent" | ||
| 261 | onClick={() => dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })} | ||
| 262 | > | ||
| 263 | <span className="text-[10px] text-muted-foreground/70"> | ||
| 264 | {project.expanded ? "â–¼" : "â–¶"} | ||
| 265 | </span> | ||
| 266 | <span className="flex-1 truncate text-xs font-medium text-foreground/90"> | ||
| 267 | {project.name} | ||
| 268 | </span> | ||
| 269 | <span className="text-[10px] text-muted-foreground/60">{threads.length}</span> | ||
| 270 | </button> | ||
| 271 | |||
| 272 | {/* Threads */} | ||
| 273 | {project.expanded && ( | ||
| 274 | <div className="ml-2 border-l border-border/80 pl-2"> | ||
| 275 | {threads.map((thread) => { | ||
| 276 | const isActive = state.activeThreadId === thread.id; | ||
| 277 | const threadStatus = threadStatusLabel(thread.session?.status); | ||
| 278 | return ( | ||
| 279 | <button | ||
| 280 | key={thread.id} | ||
| 281 | type="button" | ||
| 282 | className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 ${ | ||
| 283 | isActive | ||
| 284 | ? "bg-accent text-foreground" | ||
| 285 | : "text-muted-foreground hover:bg-secondary" | ||
| 286 | }`} | ||
| 287 | onClick={() => | ||
| 288 | dispatch({ | ||
| 289 | type: "SET_ACTIVE_THREAD", | ||
| 290 | threadId: thread.id, | ||
| 291 | }) | ||
| 292 | } | ||
| 293 | > | ||
| 294 | <div className="flex min-w-0 flex-1 items-center gap-1.5"> | ||
| 295 | {threadStatus && ( | ||
| 296 | <span className="inline-flex items-center gap-1 text-[10px] text-sky-600 dark:text-sky-300/80"> | ||
| 297 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-500 dark:bg-sky-300/80" /> | ||
| 298 | <span className="hidden xl:inline">{threadStatus}</span> | ||
| 299 | </span> | ||
| 300 | )} | ||
| 301 | <span className="min-w-0 flex-1 truncate text-xs">{thread.title}</span> | ||
| 302 | </div> | ||
| 303 | <span className="shrink-0 text-[10px] text-muted-foreground/40"> | ||
| 304 | {formatRelativeTime(thread.createdAt)} | ||
| 305 | </span> | ||
| 306 | </button> | ||
| 307 | ); | ||
| 308 | })} | ||
| 309 | |||
| 310 | {/* New thread within project */} | ||
| 311 | <button | ||
| 312 | type="button" | ||
| 313 | className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80" | ||
| 314 | onClick={() => handleNewThread(project.id)} | ||
| 315 | > | ||
| 316 | <span>+</span> New thread | ||
| 317 | </button> | ||
| 318 | </div> | ||
| 319 | )} | ||
| 320 | </div> | ||
| 321 | ); | ||
| 322 | })} | ||
| 323 | |||
| 324 | {state.projects.length === 0 && !addingProject && ( | ||
| 325 | <div className="px-2 pt-4 text-center text-xs text-muted-foreground/60"> | ||
| 326 | No projects yet. | ||
| 327 | <br /> | ||
| 328 | Add one to get started. | ||
| 329 | </div> | ||
| 330 | )} | ||
| 331 | </nav> | ||
| 332 | |||
| 333 | {/* Add project form */} | ||
| 334 | {addingProject ? ( | ||
| 335 | <div className="border-t border-border p-3"> | ||
| 336 | <p className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70"> | ||
| 337 | Add project | ||
| 338 | </p> | ||
| 339 | <input | ||
| 340 | className="mb-2 w-full rounded-md border border-border bg-secondary px-2 py-1.5 font-mono text-xs text-foreground placeholder:text-muted-foreground/40 focus:border-ring focus:outline-none" | ||
| 341 | placeholder="/path/to/project" | ||
| 342 | value={newCwd} | ||
| 343 | onChange={(e) => setNewCwd(e.target.value)} | ||
| 344 | onKeyDown={(e) => { | ||
| 345 | if (e.key === "Enter") handleAddProject(); | ||
| 346 | if (e.key === "Escape") setAddingProject(false); | ||
| 347 | }} | ||
| 348 | /> | ||
| 349 | {isElectron && api && ( | ||
| 350 | <button | ||
| 351 | type="button" | ||
| 352 | className="mb-2 flex w-full items-center justify-center rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground transition-colors duration-150 hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60" | ||
| 353 | onClick={() => void handlePickFolder()} | ||
| 354 | disabled={isPickingFolder || isAddingProject} | ||
| 355 | > | ||
| 356 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | ||
| 357 | </button> | ||
| 358 | )} | ||
| 359 | <div className="flex gap-2"> | ||
| 360 | <button | ||
| 361 | type="button" | ||
| 362 | className="flex-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors duration-150 hover:bg-primary/90" | ||
| 363 | onClick={handleAddProject} | ||
| 364 | disabled={isAddingProject} | ||
| 365 | > | ||
| 366 | {isAddingProject ? "Adding..." : "Add"} | ||
| 367 | </button> | ||
| 368 | <button | ||
| 369 | type="button" | ||
| 370 | className="flex-1 rounded-md border border-border px-2 py-1 text-xs text-muted-foreground/80 transition-colors duration-150 hover:bg-secondary" | ||
| 371 | onClick={() => setAddingProject(false)} | ||
| 372 | > | ||
| 373 | Cancel | ||
| 374 | </button> | ||
| 375 | </div> | ||
| 376 | </div> | ||
| 377 | ) : ( | ||
| 378 | <div className="border-t border-border p-3"> | ||
| 379 | <button | ||
| 380 | type="button" | ||
| 381 | className="flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-border py-2 text-xs text-muted-foreground/70 transition-colors duration-150 hover:border-ring hover:text-muted-foreground" | ||
| 382 | onClick={() => setAddingProject(true)} | ||
| 383 | > | ||
| 384 | + Add project | ||
| 385 | </button> | ||
| 386 | </div> | ||
| 387 | )} | ||
| 388 | </aside> | ||
| 389 | ); | ||
| 390 | } | ||
apps/web/src/env.ts created+8| ... | @@ -0,0 +1,8 @@ | ||
| 1 | /** | ||
| 2 | * True when running inside the Electron preload bridge, false in a regular browser. | ||
| 3 | * The preload script sets window.nativeApi via contextBridge before any web-app | ||
| 4 | * code executes, so this is reliable at module load time. | ||
| 5 | */ | ||
| 6 | export const isElectron = | ||
| 7 | typeof window !== "undefined" && | ||
| 8 | (window.desktopBridge !== undefined || window.nativeApi !== undefined); | ||
apps/web/src/historyBootstrap.test.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { buildBootstrapInput } from "./historyBootstrap"; | ||
| 4 | |||
| 5 | describe("buildBootstrapInput", () => { | ||
| 6 | it("includes full transcript when under budget", () => { | ||
| 7 | const result = buildBootstrapInput( | ||
| 8 | [ | ||
| 9 | { | ||
| 10 | id: "u-1", | ||
| 11 | role: "user", | ||
| 12 | text: "hello", | ||
| 13 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 14 | streaming: false, | ||
| 15 | }, | ||
| 16 | { | ||
| 17 | id: "a-1", | ||
| 18 | role: "assistant", | ||
| 19 | text: "world", | ||
| 20 | createdAt: "2026-02-09T00:00:01.000Z", | ||
| 21 | streaming: false, | ||
| 22 | }, | ||
| 23 | ], | ||
| 24 | "what's next?", | ||
| 25 | 1_500, | ||
| 26 | ); | ||
| 27 | |||
| 28 | expect(result.includedCount).toBe(2); | ||
| 29 | expect(result.omittedCount).toBe(0); | ||
| 30 | expect(result.truncated).toBe(false); | ||
| 31 | expect(result.text).toContain("USER:\nhello"); | ||
| 32 | expect(result.text).toContain("ASSISTANT:\nworld"); | ||
| 33 | expect(result.text).toContain("Latest user request (answer this now):"); | ||
| 34 | expect(result.text).toContain("what's next?"); | ||
| 35 | }); | ||
| 36 | |||
| 37 | it("truncates older transcript messages when over budget", () => { | ||
| 38 | const result = buildBootstrapInput( | ||
| 39 | [ | ||
| 40 | { | ||
| 41 | id: "u-1", | ||
| 42 | role: "user", | ||
| 43 | text: "first question with details", | ||
| 44 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 45 | streaming: false, | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | id: "a-1", | ||
| 49 | role: "assistant", | ||
| 50 | text: "first answer with details", | ||
| 51 | createdAt: "2026-02-09T00:00:01.000Z", | ||
| 52 | streaming: false, | ||
| 53 | }, | ||
| 54 | { | ||
| 55 | id: "u-2", | ||
| 56 | role: "user", | ||
| 57 | text: "second question with details", | ||
| 58 | createdAt: "2026-02-09T00:00:02.000Z", | ||
| 59 | streaming: false, | ||
| 60 | }, | ||
| 61 | ], | ||
| 62 | "final request", | ||
| 63 | 320, | ||
| 64 | ); | ||
| 65 | |||
| 66 | expect(result.truncated).toBe(true); | ||
| 67 | expect(result.omittedCount).toBeGreaterThan(0); | ||
| 68 | expect(result.includedCount).toBeLessThan(3); | ||
| 69 | expect(result.text).toContain("omitted to stay within input limits"); | ||
| 70 | expect(result.text.length).toBeLessThanOrEqual(320); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("preserves the latest prompt when prompt-only fallback is required", () => { | ||
| 74 | const latestPrompt = "Please keep this exact latest prompt."; | ||
| 75 | const result = buildBootstrapInput( | ||
| 76 | [ | ||
| 77 | { | ||
| 78 | id: "u-1", | ||
| 79 | role: "user", | ||
| 80 | text: "old context", | ||
| 81 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 82 | streaming: false, | ||
| 83 | }, | ||
| 84 | ], | ||
| 85 | latestPrompt, | ||
| 86 | latestPrompt.length + 3, | ||
| 87 | ); | ||
| 88 | |||
| 89 | expect(result.text).toBe(latestPrompt); | ||
| 90 | expect(result.includedCount).toBe(0); | ||
| 91 | expect(result.omittedCount).toBe(1); | ||
| 92 | expect(result.truncated).toBe(true); | ||
| 93 | }); | ||
| 94 | }); | ||
apps/web/src/historyBootstrap.ts created+120| ... | @@ -0,0 +1,120 @@ | ||
| 1 | import type { ChatMessage } from "./types"; | ||
| 2 | |||
| 3 | export interface BootstrapInputResult { | ||
| 4 | text: string; | ||
| 5 | includedCount: number; | ||
| 6 | omittedCount: number; | ||
| 7 | truncated: boolean; | ||
| 8 | } | ||
| 9 | |||
| 10 | const BOOTSTRAP_PREAMBLE = | ||
| 11 | "Continue this conversation using the transcript context below. The final section is the latest user request to answer now."; | ||
| 12 | const TRANSCRIPT_HEADER = "Transcript context:"; | ||
| 13 | const LATEST_PROMPT_HEADER = "Latest user request (answer this now):"; | ||
| 14 | const OMITTED_SUMMARY = (count: number) => | ||
| 15 | `[${count} earlier message(s) omitted to stay within input limits.]`; | ||
| 16 | |||
| 17 | function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { | ||
| 18 | return message.role === "assistant" ? "ASSISTANT" : "USER"; | ||
| 19 | } | ||
| 20 | |||
| 21 | function buildMessageBlock(message: ChatMessage): string { | ||
| 22 | return `${messageRoleLabel(message)}:\n${message.text}`; | ||
| 23 | } | ||
| 24 | |||
| 25 | function finalizeWithPrompt( | ||
| 26 | transcriptBody: string, | ||
| 27 | latestPrompt: string, | ||
| 28 | maxChars: number, | ||
| 29 | ): string | null { | ||
| 30 | const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`; | ||
| 31 | return text.length <= maxChars ? text : null; | ||
| 32 | } | ||
| 33 | |||
| 34 | export function buildBootstrapInput( | ||
| 35 | previousMessages: ChatMessage[], | ||
| 36 | latestPrompt: string, | ||
| 37 | maxChars: number, | ||
| 38 | ): BootstrapInputResult { | ||
| 39 | const budget = Number.isFinite(maxChars) | ||
| 40 | ? Math.max(1, Math.floor(maxChars)) | ||
| 41 | : 1; | ||
| 42 | const promptOnly = | ||
| 43 | latestPrompt.length <= budget | ||
| 44 | ? latestPrompt | ||
| 45 | : latestPrompt.slice(0, budget); | ||
| 46 | |||
| 47 | if (previousMessages.length === 0) { | ||
| 48 | return { | ||
| 49 | text: promptOnly, | ||
| 50 | includedCount: 0, | ||
| 51 | omittedCount: 0, | ||
| 52 | truncated: promptOnly.length !== latestPrompt.length, | ||
| 53 | }; | ||
| 54 | } | ||
| 55 | |||
| 56 | const newestFirstBlocks: string[] = []; | ||
| 57 | for (let index = previousMessages.length - 1; index >= 0; index -= 1) { | ||
| 58 | const message = previousMessages[index]; | ||
| 59 | if (!message) continue; | ||
| 60 | newestFirstBlocks.push(buildMessageBlock(message)); | ||
| 61 | } | ||
| 62 | |||
| 63 | if (newestFirstBlocks.length === 0) { | ||
| 64 | return { | ||
| 65 | text: promptOnly, | ||
| 66 | includedCount: 0, | ||
| 67 | omittedCount: previousMessages.length, | ||
| 68 | truncated: true, | ||
| 69 | }; | ||
| 70 | } | ||
| 71 | |||
| 72 | // Include a contiguous suffix from newest to oldest, then reverse to chronological. | ||
| 73 | let includedNewestFirst: string[] = []; | ||
| 74 | for (const block of newestFirstBlocks) { | ||
| 75 | const nextNewestFirst = [...includedNewestFirst, block]; | ||
| 76 | const nextChronological = [...nextNewestFirst].reverse(); | ||
| 77 | const omittedCount = newestFirstBlocks.length - nextChronological.length; | ||
| 78 | const transcriptBody = | ||
| 79 | omittedCount > 0 | ||
| 80 | ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}` | ||
| 81 | : nextChronological.join("\n\n"); | ||
| 82 | if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) { | ||
| 83 | break; | ||
| 84 | } | ||
| 85 | includedNewestFirst = nextNewestFirst; | ||
| 86 | } | ||
| 87 | |||
| 88 | let includedChronological = [...includedNewestFirst].reverse(); | ||
| 89 | while (true) { | ||
| 90 | const omittedCount = | ||
| 91 | newestFirstBlocks.length - includedChronological.length; | ||
| 92 | const transcriptBody = | ||
| 93 | omittedCount > 0 | ||
| 94 | ? includedChronological.length > 0 | ||
| 95 | ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}` | ||
| 96 | : OMITTED_SUMMARY(omittedCount) | ||
| 97 | : includedChronological.join("\n\n"); | ||
| 98 | const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget); | ||
| 99 | if (finalized) { | ||
| 100 | return { | ||
| 101 | text: finalized, | ||
| 102 | includedCount: includedChronological.length, | ||
| 103 | omittedCount, | ||
| 104 | truncated: | ||
| 105 | omittedCount > 0 || latestPrompt.length !== promptOnly.length, | ||
| 106 | }; | ||
| 107 | } | ||
| 108 | |||
| 109 | if (includedChronological.length === 0) { | ||
| 110 | return { | ||
| 111 | text: promptOnly, | ||
| 112 | includedCount: 0, | ||
| 113 | omittedCount: previousMessages.length, | ||
| 114 | truncated: true, | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | includedChronological = includedChronological.slice(1); | ||
| 119 | } | ||
| 120 | } | ||
apps/web/src/hooks/useTheme.ts created+90| ... | @@ -0,0 +1,90 @@ | ||
| 1 | import { useCallback, useEffect, useSyncExternalStore } from "react"; | ||
| 2 | |||
| 3 | type Theme = "light" | "dark" | "system"; | ||
| 4 | |||
| 5 | const STORAGE_KEY = "t3code:theme"; | ||
| 6 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; | ||
| 7 | |||
| 8 | let listeners: Array<() => void> = []; | ||
| 9 | function emitChange() { | ||
| 10 | for (const listener of listeners) listener(); | ||
| 11 | } | ||
| 12 | |||
| 13 | function getSystemDark(): boolean { | ||
| 14 | return window.matchMedia(MEDIA_QUERY).matches; | ||
| 15 | } | ||
| 16 | |||
| 17 | function getStored(): Theme { | ||
| 18 | const raw = localStorage.getItem(STORAGE_KEY); | ||
| 19 | if (raw === "light" || raw === "dark" || raw === "system") return raw; | ||
| 20 | return "system"; | ||
| 21 | } | ||
| 22 | |||
| 23 | function applyTheme(theme: Theme, suppressTransitions = false) { | ||
| 24 | if (suppressTransitions) { | ||
| 25 | document.documentElement.classList.add("no-transitions"); | ||
| 26 | } | ||
| 27 | const isDark = theme === "dark" || (theme === "system" && getSystemDark()); | ||
| 28 | document.documentElement.classList.toggle("dark", isDark); | ||
| 29 | if (suppressTransitions) { | ||
| 30 | // Force a reflow so the no-transitions class takes effect before removal | ||
| 31 | document.documentElement.offsetHeight; | ||
| 32 | requestAnimationFrame(() => { | ||
| 33 | document.documentElement.classList.remove("no-transitions"); | ||
| 34 | }); | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | // Apply immediately on module load to prevent flash | ||
| 39 | applyTheme(getStored()); | ||
| 40 | |||
| 41 | function getSnapshot(): Theme { | ||
| 42 | return getStored(); | ||
| 43 | } | ||
| 44 | |||
| 45 | function subscribe(listener: () => void): () => void { | ||
| 46 | listeners.push(listener); | ||
| 47 | |||
| 48 | // Listen for system preference changes | ||
| 49 | const mq = window.matchMedia(MEDIA_QUERY); | ||
| 50 | const handleChange = () => { | ||
| 51 | if (getStored() === "system") applyTheme("system", true); | ||
| 52 | emitChange(); | ||
| 53 | }; | ||
| 54 | mq.addEventListener("change", handleChange); | ||
| 55 | |||
| 56 | // Listen for storage changes from other tabs | ||
| 57 | const handleStorage = (e: StorageEvent) => { | ||
| 58 | if (e.key === STORAGE_KEY) { | ||
| 59 | applyTheme(getStored(), true); | ||
| 60 | emitChange(); | ||
| 61 | } | ||
| 62 | }; | ||
| 63 | window.addEventListener("storage", handleStorage); | ||
| 64 | |||
| 65 | return () => { | ||
| 66 | listeners = listeners.filter((l) => l !== listener); | ||
| 67 | mq.removeEventListener("change", handleChange); | ||
| 68 | window.removeEventListener("storage", handleStorage); | ||
| 69 | }; | ||
| 70 | } | ||
| 71 | |||
| 72 | export function useTheme() { | ||
| 73 | const theme = useSyncExternalStore(subscribe, getSnapshot); | ||
| 74 | |||
| 75 | const resolvedTheme: "light" | "dark" = | ||
| 76 | theme === "system" ? (getSystemDark() ? "dark" : "light") : theme; | ||
| 77 | |||
| 78 | const setTheme = useCallback((next: Theme) => { | ||
| 79 | localStorage.setItem(STORAGE_KEY, next); | ||
| 80 | applyTheme(next, true); | ||
| 81 | emitChange(); | ||
| 82 | }, []); | ||
| 83 | |||
| 84 | // Keep DOM in sync on mount/change | ||
| 85 | useEffect(() => { | ||
| 86 | applyTheme(theme); | ||
| 87 | }, [theme]); | ||
| 88 | |||
| 89 | return { theme, setTheme, resolvedTheme } as const; | ||
| 90 | } | ||
apps/web/src/index.css created+340| ... | @@ -0,0 +1,340 @@ | ||
| 1 | @import "tailwindcss"; | ||
| 2 | |||
| 3 | @custom-variant dark (&:is(.dark, .dark *)); | ||
| 4 | |||
| 5 | @theme inline { | ||
| 6 | --animate-skeleton: skeleton 2s -1s infinite linear; | ||
| 7 | --color-warning-foreground: var(--warning-foreground); | ||
| 8 | --color-warning: var(--warning); | ||
| 9 | --color-success-foreground: var(--success-foreground); | ||
| 10 | --color-success: var(--success); | ||
| 11 | --color-info-foreground: var(--info-foreground); | ||
| 12 | --color-info: var(--info); | ||
| 13 | --color-destructive-foreground: var(--destructive-foreground); | ||
| 14 | --color-sidebar-ring: var(--sidebar-ring); | ||
| 15 | --color-sidebar-border: var(--sidebar-border); | ||
| 16 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); | ||
| 17 | --color-sidebar-accent: var(--sidebar-accent); | ||
| 18 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); | ||
| 19 | --color-sidebar-primary: var(--sidebar-primary); | ||
| 20 | --color-sidebar-foreground: var(--sidebar-foreground); | ||
| 21 | --color-sidebar: var(--sidebar); | ||
| 22 | --color-chart-5: var(--chart-5); | ||
| 23 | --color-chart-4: var(--chart-4); | ||
| 24 | --color-chart-3: var(--chart-3); | ||
| 25 | --color-chart-2: var(--chart-2); | ||
| 26 | --color-chart-1: var(--chart-1); | ||
| 27 | --color-ring: var(--ring); | ||
| 28 | --color-input: var(--input); | ||
| 29 | --color-border: var(--border); | ||
| 30 | --color-destructive: var(--destructive); | ||
| 31 | --color-accent-foreground: var(--accent-foreground); | ||
| 32 | --color-accent: var(--accent); | ||
| 33 | --color-muted-foreground: var(--muted-foreground); | ||
| 34 | --color-muted: var(--muted); | ||
| 35 | --color-secondary-foreground: var(--secondary-foreground); | ||
| 36 | --color-secondary: var(--secondary); | ||
| 37 | --color-primary-foreground: var(--primary-foreground); | ||
| 38 | --color-primary: var(--primary); | ||
| 39 | --color-popover-foreground: var(--popover-foreground); | ||
| 40 | --color-popover: var(--popover); | ||
| 41 | --color-card-foreground: var(--card-foreground); | ||
| 42 | --color-card: var(--card); | ||
| 43 | --color-foreground: var(--foreground); | ||
| 44 | --color-background: var(--background); | ||
| 45 | --radius-sm: calc(var(--radius) - 4px); | ||
| 46 | --radius-md: calc(var(--radius) - 2px); | ||
| 47 | --radius-lg: var(--radius); | ||
| 48 | --radius-xl: calc(var(--radius) + 4px); | ||
| 49 | --radius-2xl: calc(var(--radius) + 8px); | ||
| 50 | --radius-3xl: calc(var(--radius) + 12px); | ||
| 51 | --radius-4xl: calc(var(--radius) + 16px); | ||
| 52 | @keyframes skeleton { | ||
| 53 | to { | ||
| 54 | background-position: -200% 0; | ||
| 55 | } | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | @layer base { | ||
| 60 | * { | ||
| 61 | @apply border-border outline-ring/50; | ||
| 62 | } | ||
| 63 | body { | ||
| 64 | @apply bg-background text-foreground; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | /* Suppress all transitions during theme changes */ | ||
| 69 | .no-transitions, | ||
| 70 | .no-transitions *, | ||
| 71 | .no-transitions *::before, | ||
| 72 | .no-transitions *::after { | ||
| 73 | transition-duration: 0s !important; | ||
| 74 | animation-duration: 0s !important; | ||
| 75 | } | ||
| 76 | |||
| 77 | :root { | ||
| 78 | color-scheme: light; | ||
| 79 | --radius: 0.625rem; | ||
| 80 | --background: var(--color-white); | ||
| 81 | --foreground: var(--color-neutral-800); | ||
| 82 | --card: var(--color-white); | ||
| 83 | --card-foreground: var(--color-neutral-800); | ||
| 84 | --popover: var(--color-white); | ||
| 85 | --popover-foreground: var(--color-neutral-800); | ||
| 86 | --primary: oklch(0.488 0.217 264); | ||
| 87 | --primary-foreground: var(--color-white); | ||
| 88 | --secondary: --alpha(var(--color-black) / 4%); | ||
| 89 | --secondary-foreground: var(--color-neutral-800); | ||
| 90 | --muted: --alpha(var(--color-black) / 4%); | ||
| 91 | --muted-foreground: color-mix( | ||
| 92 | in srgb, | ||
| 93 | var(--color-neutral-500) 90%, | ||
| 94 | var(--color-black) | ||
| 95 | ); | ||
| 96 | --accent: --alpha(var(--color-black) / 5%); | ||
| 97 | --accent-foreground: var(--color-neutral-800); | ||
| 98 | --destructive: var(--color-red-500); | ||
| 99 | --border: --alpha(var(--color-black) / 8%); | ||
| 100 | --input: --alpha(var(--color-black) / 10%); | ||
| 101 | --ring: oklch(0.488 0.217 264); | ||
| 102 | --chart-1: oklch(0.646 0.222 41.116); | ||
| 103 | --chart-2: oklch(0.6 0.118 184.704); | ||
| 104 | --chart-3: oklch(0.398 0.07 227.392); | ||
| 105 | --chart-4: oklch(0.828 0.189 84.429); | ||
| 106 | --chart-5: oklch(0.769 0.188 70.08); | ||
| 107 | --sidebar: oklch(0.985 0 0); | ||
| 108 | --sidebar-foreground: oklch(0.141 0.005 285.823); | ||
| 109 | --sidebar-primary: oklch(0.21 0.006 285.885); | ||
| 110 | --sidebar-primary-foreground: oklch(0.985 0 0); | ||
| 111 | --sidebar-accent: oklch(0.967 0.001 286.375); | ||
| 112 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885); | ||
| 113 | --sidebar-border: oklch(0.92 0.004 286.32); | ||
| 114 | --sidebar-ring: oklch(0.705 0.015 286.067); | ||
| 115 | --destructive-foreground: var(--color-red-700); | ||
| 116 | --info: var(--color-blue-500); | ||
| 117 | --info-foreground: var(--color-blue-700); | ||
| 118 | --success: var(--color-emerald-500); | ||
| 119 | --success-foreground: var(--color-emerald-700); | ||
| 120 | --warning: var(--color-amber-500); | ||
| 121 | --warning-foreground: var(--color-amber-700); | ||
| 122 | |||
| 123 | @variant dark { | ||
| 124 | color-scheme: dark; | ||
| 125 | --background: color-mix( | ||
| 126 | in srgb, | ||
| 127 | var(--color-neutral-950) 95%, | ||
| 128 | var(--color-white) | ||
| 129 | ); | ||
| 130 | --foreground: var(--color-neutral-100); | ||
| 131 | --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); | ||
| 132 | --card-foreground: var(--color-neutral-100); | ||
| 133 | --popover: color-mix(in srgb, var(--background) 98%, var(--color-white)); | ||
| 134 | --popover-foreground: var(--color-neutral-100); | ||
| 135 | --primary: oklch(0.588 0.217 264); | ||
| 136 | --primary-foreground: var(--color-white); | ||
| 137 | --secondary: --alpha(var(--color-white) / 4%); | ||
| 138 | --secondary-foreground: var(--color-neutral-100); | ||
| 139 | --muted: --alpha(var(--color-white) / 4%); | ||
| 140 | --muted-foreground: color-mix( | ||
| 141 | in srgb, | ||
| 142 | var(--color-neutral-500) 90%, | ||
| 143 | var(--color-white) | ||
| 144 | ); | ||
| 145 | --accent: --alpha(var(--color-white) / 4%); | ||
| 146 | --accent-foreground: var(--color-neutral-100); | ||
| 147 | --destructive: color-mix( | ||
| 148 | in srgb, | ||
| 149 | var(--color-red-500) 90%, | ||
| 150 | var(--color-white) | ||
| 151 | ); | ||
| 152 | --border: --alpha(var(--color-white) / 6%); | ||
| 153 | --input: --alpha(var(--color-white) / 8%); | ||
| 154 | --ring: oklch(0.588 0.217 264); | ||
| 155 | --chart-1: oklch(0.488 0.243 264.376); | ||
| 156 | --chart-2: oklch(0.696 0.17 162.48); | ||
| 157 | --chart-3: oklch(0.769 0.188 70.08); | ||
| 158 | --chart-4: oklch(0.627 0.265 303.9); | ||
| 159 | --chart-5: oklch(0.645 0.246 16.439); | ||
| 160 | --sidebar: oklch(0.21 0.006 285.885); | ||
| 161 | --sidebar-foreground: oklch(0.985 0 0); | ||
| 162 | --sidebar-primary: oklch(0.488 0.243 264.376); | ||
| 163 | --sidebar-primary-foreground: oklch(0.985 0 0); | ||
| 164 | --sidebar-accent: oklch(0.274 0.006 286.033); | ||
| 165 | --sidebar-accent-foreground: oklch(0.985 0 0); | ||
| 166 | --sidebar-border: oklch(1 0 0 / 10%); | ||
| 167 | --sidebar-ring: oklch(0.552 0.016 285.938); | ||
| 168 | --destructive-foreground: var(--color-red-400); | ||
| 169 | --info: var(--color-blue-500); | ||
| 170 | --info-foreground: var(--color-blue-400); | ||
| 171 | --success: var(--color-emerald-500); | ||
| 172 | --success-foreground: var(--color-emerald-400); | ||
| 173 | --warning: var(--color-amber-500); | ||
| 174 | --warning-foreground: var(--color-amber-400); | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | body { | ||
| 179 | font-family: | ||
| 180 | "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; | ||
| 181 | margin: 0; | ||
| 182 | padding: 0; | ||
| 183 | min-height: 100vh; | ||
| 184 | } | ||
| 185 | |||
| 186 | body::after { | ||
| 187 | content: ""; | ||
| 188 | position: fixed; | ||
| 189 | inset: 0; | ||
| 190 | pointer-events: none; | ||
| 191 | opacity: 0.035; | ||
| 192 | background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); | ||
| 193 | background-repeat: repeat; | ||
| 194 | background-size: 256px 256px; | ||
| 195 | } | ||
| 196 | |||
| 197 | pre, | ||
| 198 | code, | ||
| 199 | textarea, | ||
| 200 | input { | ||
| 201 | font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; | ||
| 202 | } | ||
| 203 | |||
| 204 | /* Window drag region (frameless titlebar) */ | ||
| 205 | .drag-region { | ||
| 206 | -webkit-app-region: drag; | ||
| 207 | } | ||
| 208 | |||
| 209 | .drag-region button, | ||
| 210 | .drag-region input, | ||
| 211 | .drag-region textarea, | ||
| 212 | .drag-region select, | ||
| 213 | .drag-region a { | ||
| 214 | -webkit-app-region: no-drag; | ||
| 215 | } | ||
| 216 | |||
| 217 | /* Scrollbar styling */ | ||
| 218 | ::-webkit-scrollbar { | ||
| 219 | width: 6px; | ||
| 220 | } | ||
| 221 | |||
| 222 | ::-webkit-scrollbar-track { | ||
| 223 | background: transparent; | ||
| 224 | } | ||
| 225 | |||
| 226 | ::-webkit-scrollbar-thumb { | ||
| 227 | background: rgba(0, 0, 0, 0.15); | ||
| 228 | border-radius: 3px; | ||
| 229 | } | ||
| 230 | |||
| 231 | ::-webkit-scrollbar-thumb:hover { | ||
| 232 | background: rgba(0, 0, 0, 0.25); | ||
| 233 | } | ||
| 234 | |||
| 235 | .dark ::-webkit-scrollbar-thumb { | ||
| 236 | background: rgba(255, 255, 255, 0.1); | ||
| 237 | } | ||
| 238 | |||
| 239 | .dark ::-webkit-scrollbar-thumb:hover { | ||
| 240 | background: rgba(255, 255, 255, 0.18); | ||
| 241 | } | ||
| 242 | |||
| 243 | /* Reasoning select -- clickable label surface */ | ||
| 244 | label:has(> select#reasoning-effort) { | ||
| 245 | position: relative; | ||
| 246 | } | ||
| 247 | label:has(> select#reasoning-effort) select { | ||
| 248 | position: absolute; | ||
| 249 | inset: 0; | ||
| 250 | opacity: 0; | ||
| 251 | cursor: pointer; | ||
| 252 | width: 100%; | ||
| 253 | height: 100%; | ||
| 254 | } | ||
| 255 | |||
| 256 | /* Chat markdown rendering */ | ||
| 257 | .chat-markdown { | ||
| 258 | overflow-wrap: anywhere; | ||
| 259 | } | ||
| 260 | |||
| 261 | .chat-markdown > :first-child { | ||
| 262 | margin-top: 0; | ||
| 263 | } | ||
| 264 | |||
| 265 | .chat-markdown > :last-child { | ||
| 266 | margin-bottom: 0; | ||
| 267 | } | ||
| 268 | |||
| 269 | .chat-markdown p, | ||
| 270 | .chat-markdown ul, | ||
| 271 | .chat-markdown ol, | ||
| 272 | .chat-markdown blockquote, | ||
| 273 | .chat-markdown pre, | ||
| 274 | .chat-markdown table { | ||
| 275 | margin: 0.65rem 0; | ||
| 276 | } | ||
| 277 | |||
| 278 | .chat-markdown ul, | ||
| 279 | .chat-markdown ol { | ||
| 280 | padding-left: 1.25rem; | ||
| 281 | } | ||
| 282 | |||
| 283 | .chat-markdown li + li { | ||
| 284 | margin-top: 0.25rem; | ||
| 285 | } | ||
| 286 | |||
| 287 | .chat-markdown a { | ||
| 288 | color: var(--info-foreground); | ||
| 289 | text-decoration: underline; | ||
| 290 | text-decoration-color: color-mix( | ||
| 291 | in srgb, | ||
| 292 | var(--info-foreground) 40%, | ||
| 293 | transparent | ||
| 294 | ); | ||
| 295 | } | ||
| 296 | |||
| 297 | .chat-markdown a:hover { | ||
| 298 | opacity: 0.8; | ||
| 299 | } | ||
| 300 | |||
| 301 | .chat-markdown blockquote { | ||
| 302 | border-left: 2px solid var(--border); | ||
| 303 | padding-left: 0.8rem; | ||
| 304 | color: var(--muted-foreground); | ||
| 305 | } | ||
| 306 | |||
| 307 | .chat-markdown :not(pre) > code { | ||
| 308 | border: 1px solid var(--border); | ||
| 309 | border-radius: 0.375rem; | ||
| 310 | background: var(--muted); | ||
| 311 | padding: 0.1rem 0.35rem; | ||
| 312 | color: var(--foreground); | ||
| 313 | } | ||
| 314 | |||
| 315 | .chat-markdown pre { | ||
| 316 | overflow-x: auto; | ||
| 317 | border: 1px solid var(--border); | ||
| 318 | border-radius: 0.75rem; | ||
| 319 | background: var(--muted); | ||
| 320 | padding: 0.8rem 0.9rem; | ||
| 321 | } | ||
| 322 | |||
| 323 | .chat-markdown pre code { | ||
| 324 | border: none; | ||
| 325 | background: transparent; | ||
| 326 | padding: 0; | ||
| 327 | line-height: 1.5; | ||
| 328 | } | ||
| 329 | |||
| 330 | .chat-markdown table { | ||
| 331 | width: 100%; | ||
| 332 | border-collapse: collapse; | ||
| 333 | } | ||
| 334 | |||
| 335 | .chat-markdown th, | ||
| 336 | .chat-markdown td { | ||
| 337 | border: 1px solid var(--border); | ||
| 338 | padding: 0.35rem 0.45rem; | ||
| 339 | text-align: left; | ||
| 340 | } | ||
apps/web/src/main.tsx created+12| ... | @@ -0,0 +1,12 @@ | ||
| 1 | import React from "react"; | ||
| 2 | import ReactDOM from "react-dom/client"; | ||
| 3 | import "highlight.js/styles/github-dark.css"; | ||
| 4 | |||
| 5 | import App from "./App"; | ||
| 6 | import "./index.css"; | ||
| 7 | |||
| 8 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( | ||
| 9 | <React.StrictMode> | ||
| 10 | <App /> | ||
| 11 | </React.StrictMode>, | ||
| 12 | ); | ||
apps/web/src/model-logic.test.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; | ||
| 3 | |||
| 4 | describe("normalizeModelSlug", () => { | ||
| 5 | it("maps 5.3 aliases to canonical codex slug", () => { | ||
| 6 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | ||
| 7 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("preserves non-aliased slugs", () => { | ||
| 11 | expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 12 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("returns null for empty input", () => { | ||
| 16 | expect(normalizeModelSlug("")).toBeNull(); | ||
| 17 | expect(normalizeModelSlug(" ")).toBeNull(); | ||
| 18 | expect(normalizeModelSlug(null)).toBeNull(); | ||
| 19 | expect(normalizeModelSlug(undefined)).toBeNull(); | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | describe("resolveModelSlug", () => { | ||
| 24 | it("falls back to default for unknown models", () => { | ||
| 25 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("resolves supported hard-coded models", () => { | ||
| 29 | expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 30 | expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 31 | expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2"); | ||
| 32 | }); | ||
| 33 | }); | ||
apps/web/src/model-logic.ts created+9| ... | @@ -0,0 +1,9 @@ | ||
| 1 | export { | ||
| 2 | DEFAULT_MODEL, | ||
| 3 | MODEL_OPTIONS, | ||
| 4 | normalizeModelSlug, | ||
| 5 | resolveModelSlug, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | |||
| 8 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | ||
| 9 | export const DEFAULT_REASONING = "high"; | ||
apps/web/src/persistenceSchema.test.ts created+172| ... | @@ -0,0 +1,172 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { DEFAULT_MODEL } from "./model-logic"; | ||
| 4 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | ||
| 5 | import type { Thread } from "./types"; | ||
| 6 | |||
| 7 | describe("hydratePersistedState", () => { | ||
| 8 | it("returns null for invalid payloads", () => { | ||
| 9 | expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull(); | ||
| 10 | expect(hydratePersistedState("not-json", false)).toBeNull(); | ||
| 11 | }); | ||
| 12 | |||
| 13 | it("migrates the legacy default model to the current default", () => { | ||
| 14 | const payload = JSON.stringify({ | ||
| 15 | projects: [ | ||
| 16 | { | ||
| 17 | id: "p-1", | ||
| 18 | name: "Project", | ||
| 19 | cwd: "/tmp/project", | ||
| 20 | model: "gpt-5.2-codex", | ||
| 21 | expanded: true, | ||
| 22 | }, | ||
| 23 | ], | ||
| 24 | threads: [ | ||
| 25 | { | ||
| 26 | id: "t-1", | ||
| 27 | projectId: "p-1", | ||
| 28 | title: "Thread", | ||
| 29 | model: "gpt-5.2-codex", | ||
| 30 | messages: [ | ||
| 31 | { | ||
| 32 | id: "m-1", | ||
| 33 | role: "assistant", | ||
| 34 | text: "Hello", | ||
| 35 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 36 | streaming: true, | ||
| 37 | }, | ||
| 38 | ], | ||
| 39 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 40 | }, | ||
| 41 | ], | ||
| 42 | activeThreadId: "t-1", | ||
| 43 | }); | ||
| 44 | |||
| 45 | const hydrated = hydratePersistedState(payload, true); | ||
| 46 | expect(hydrated).not.toBeNull(); | ||
| 47 | expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); | ||
| 48 | expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); | ||
| 49 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); | ||
| 50 | expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); | ||
| 51 | expect(hydrated?.runtimeMode).toBe("full-access"); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("filters unknown project references and repairs active thread", () => { | ||
| 55 | const payload = JSON.stringify({ | ||
| 56 | projects: [ | ||
| 57 | { | ||
| 58 | id: "p-1", | ||
| 59 | name: "Project", | ||
| 60 | cwd: "/tmp/project", | ||
| 61 | model: "gpt-5.3-codex", | ||
| 62 | expanded: false, | ||
| 63 | }, | ||
| 64 | ], | ||
| 65 | threads: [ | ||
| 66 | { | ||
| 67 | id: "t-1", | ||
| 68 | projectId: "p-1", | ||
| 69 | title: "Valid thread", | ||
| 70 | model: "gpt-5.3-codex", | ||
| 71 | messages: [], | ||
| 72 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 73 | }, | ||
| 74 | { | ||
| 75 | id: "t-2", | ||
| 76 | projectId: "p-missing", | ||
| 77 | title: "Dangling thread", | ||
| 78 | model: "gpt-5.3-codex", | ||
| 79 | messages: [], | ||
| 80 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | activeThreadId: "t-2", | ||
| 84 | }); | ||
| 85 | |||
| 86 | const hydrated = hydratePersistedState(payload, false); | ||
| 87 | expect(hydrated).not.toBeNull(); | ||
| 88 | expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); | ||
| 89 | expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); | ||
| 90 | expect(hydrated?.activeThreadId).toBe("t-1"); | ||
| 91 | expect(hydrated?.runtimeMode).toBe("full-access"); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("hydrates runtime mode from v3 payload", () => { | ||
| 95 | const payload = JSON.stringify({ | ||
| 96 | version: 3, | ||
| 97 | runtimeMode: "approval-required", | ||
| 98 | projects: [ | ||
| 99 | { | ||
| 100 | id: "p-1", | ||
| 101 | name: "Project", | ||
| 102 | cwd: "/tmp/project", | ||
| 103 | model: "gpt-5.3-codex", | ||
| 104 | expanded: false, | ||
| 105 | }, | ||
| 106 | ], | ||
| 107 | threads: [], | ||
| 108 | activeThreadId: null, | ||
| 109 | }); | ||
| 110 | |||
| 111 | const hydrated = hydratePersistedState(payload, false); | ||
| 112 | expect(hydrated?.runtimeMode).toBe("approval-required"); | ||
| 113 | }); | ||
| 114 | }); | ||
| 115 | |||
| 116 | describe("toPersistedState", () => { | ||
| 117 | it("writes v4 payload and strips non-persisted thread fields", () => { | ||
| 118 | const thread: Thread = { | ||
| 119 | id: "t-1", | ||
| 120 | codexThreadId: "thr_1", | ||
| 121 | projectId: "p-1", | ||
| 122 | title: "Thread", | ||
| 123 | model: "gpt-5.3-codex", | ||
| 124 | session: null, | ||
| 125 | messages: [ | ||
| 126 | { | ||
| 127 | id: "m-1", | ||
| 128 | role: "user", | ||
| 129 | text: "Hi", | ||
| 130 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 131 | streaming: false, | ||
| 132 | }, | ||
| 133 | ], | ||
| 134 | events: [], | ||
| 135 | error: "boom", | ||
| 136 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 137 | }; | ||
| 138 | |||
| 139 | const persisted = toPersistedState({ | ||
| 140 | projects: [ | ||
| 141 | { | ||
| 142 | id: "p-1", | ||
| 143 | name: "Project", | ||
| 144 | cwd: "/tmp/project", | ||
| 145 | model: "gpt-5.3-codex", | ||
| 146 | expanded: true, | ||
| 147 | }, | ||
| 148 | ], | ||
| 149 | threads: [thread], | ||
| 150 | activeThreadId: "t-1", | ||
| 151 | runtimeMode: "full-access", | ||
| 152 | }); | ||
| 153 | |||
| 154 | expect(persisted.version).toBe(4); | ||
| 155 | expect(persisted.runtimeMode).toBe("full-access"); | ||
| 156 | expect(persisted.threads[0]).toEqual({ | ||
| 157 | id: "t-1", | ||
| 158 | codexThreadId: "thr_1", | ||
| 159 | projectId: "p-1", | ||
| 160 | title: "Thread", | ||
| 161 | model: "gpt-5.3-codex", | ||
| 162 | messages: thread.messages, | ||
| 163 | createdAt: thread.createdAt, | ||
| 164 | }); | ||
| 165 | const persistedThread = persisted.threads[0]; | ||
| 166 | expect(persistedThread).toBeDefined(); | ||
| 167 | if (!persistedThread) return; | ||
| 168 | |||
| 169 | expect("error" in persistedThread).toBe(false); | ||
| 170 | expect("session" in persistedThread).toBe(false); | ||
| 171 | }); | ||
| 172 | }); | ||
apps/web/src/persistenceSchema.ts created+177| ... | @@ -0,0 +1,177 @@ | ||
| 1 | import { z } from "zod"; | ||
| 2 | |||
| 3 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | ||
| 4 | import { | ||
| 5 | DEFAULT_RUNTIME_MODE, | ||
| 6 | type Project, | ||
| 7 | type RuntimeMode, | ||
| 8 | type Thread, | ||
| 9 | } from "./types"; | ||
| 10 | |||
| 11 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | ||
| 12 | |||
| 13 | const persistedProjectSchema = z.object({ | ||
| 14 | id: z.string().min(1), | ||
| 15 | name: z.string().min(1), | ||
| 16 | cwd: z.string().min(1), | ||
| 17 | model: z.string().min(1), | ||
| 18 | expanded: z.boolean(), | ||
| 19 | }); | ||
| 20 | |||
| 21 | const persistedMessageSchema = z.object({ | ||
| 22 | id: z.string().min(1), | ||
| 23 | role: z.enum(["user", "assistant"]), | ||
| 24 | text: z.string(), | ||
| 25 | createdAt: z.string().min(1), | ||
| 26 | streaming: z.boolean(), | ||
| 27 | }); | ||
| 28 | |||
| 29 | const persistedThreadSchema = z.object({ | ||
| 30 | id: z.string().min(1), | ||
| 31 | codexThreadId: z.string().min(1).nullable().default(null), | ||
| 32 | projectId: z.string().min(1), | ||
| 33 | title: z.string().min(1), | ||
| 34 | model: z.string().min(1), | ||
| 35 | messages: z.array(persistedMessageSchema), | ||
| 36 | createdAt: z.string().min(1), | ||
| 37 | }); | ||
| 38 | |||
| 39 | const persistedStateBodySchema = z.object({ | ||
| 40 | projects: z.array(persistedProjectSchema), | ||
| 41 | threads: z.array(persistedThreadSchema), | ||
| 42 | activeThreadId: z.string().min(1).nullable(), | ||
| 43 | }); | ||
| 44 | |||
| 45 | const runtimeModeSchema = z.enum(["approval-required", "full-access"]); | ||
| 46 | |||
| 47 | export const persistedStateV1Schema = persistedStateBodySchema.extend({ | ||
| 48 | version: z.literal(1).optional(), | ||
| 49 | }); | ||
| 50 | |||
| 51 | export const persistedStateV2Schema = persistedStateBodySchema.extend({ | ||
| 52 | version: z.literal(2).optional(), | ||
| 53 | }); | ||
| 54 | |||
| 55 | export const persistedStateV3Schema = persistedStateBodySchema.extend({ | ||
| 56 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 57 | version: z.literal(3).optional(), | ||
| 58 | }); | ||
| 59 | |||
| 60 | export const persistedStateV4Schema = persistedStateBodySchema.extend({ | ||
| 61 | runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), | ||
| 62 | version: z.literal(4).optional(), | ||
| 63 | }); | ||
| 64 | |||
| 65 | const persistedStateSchema = z.union([ | ||
| 66 | persistedStateV4Schema, | ||
| 67 | persistedStateV3Schema, | ||
| 68 | persistedStateV2Schema, | ||
| 69 | persistedStateV1Schema, | ||
| 70 | ]); | ||
| 71 | |||
| 72 | export interface PersistedStoreSnapshot { | ||
| 73 | projects: Project[]; | ||
| 74 | threads: Thread[]; | ||
| 75 | activeThreadId: string | null; | ||
| 76 | runtimeMode: RuntimeMode; | ||
| 77 | } | ||
| 78 | |||
| 79 | function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string { | ||
| 80 | if (!isLegacyPayload) { | ||
| 81 | return model; | ||
| 82 | } | ||
| 83 | |||
| 84 | return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; | ||
| 85 | } | ||
| 86 | |||
| 87 | function hydrateProject( | ||
| 88 | project: z.infer<typeof persistedProjectSchema>, | ||
| 89 | isLegacyPayload: boolean, | ||
| 90 | ): Project { | ||
| 91 | return { | ||
| 92 | ...project, | ||
| 93 | model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 | function hydrateThread( | ||
| 98 | thread: z.infer<typeof persistedThreadSchema>, | ||
| 99 | isLegacyPayload: boolean, | ||
| 100 | ): Thread { | ||
| 101 | return { | ||
| 102 | id: thread.id, | ||
| 103 | codexThreadId: thread.codexThreadId, | ||
| 104 | projectId: thread.projectId, | ||
| 105 | title: thread.title, | ||
| 106 | model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), | ||
| 107 | session: null, | ||
| 108 | messages: thread.messages.map((message) => ({ | ||
| 109 | ...message, | ||
| 110 | streaming: false, | ||
| 111 | })), | ||
| 112 | events: [], | ||
| 113 | error: null, | ||
| 114 | createdAt: thread.createdAt, | ||
| 115 | }; | ||
| 116 | } | ||
| 117 | |||
| 118 | export function hydratePersistedState( | ||
| 119 | raw: string, | ||
| 120 | isLegacyPayload: boolean, | ||
| 121 | ): PersistedStoreSnapshot | null { | ||
| 122 | let parsedJson: unknown; | ||
| 123 | try { | ||
| 124 | parsedJson = JSON.parse(raw); | ||
| 125 | } catch { | ||
| 126 | return null; | ||
| 127 | } | ||
| 128 | |||
| 129 | const parsedState = persistedStateSchema.safeParse(parsedJson); | ||
| 130 | if (!parsedState.success) { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | |||
| 134 | const projects = parsedState.data.projects.map((project) => | ||
| 135 | hydrateProject(project, isLegacyPayload), | ||
| 136 | ); | ||
| 137 | const projectIds = new Set(projects.map((project) => project.id)); | ||
| 138 | const threads = parsedState.data.threads | ||
| 139 | .map((thread) => hydrateThread(thread, isLegacyPayload)) | ||
| 140 | .filter((thread) => projectIds.has(thread.projectId)); | ||
| 141 | const hasActiveThread = Boolean( | ||
| 142 | parsedState.data.activeThreadId && | ||
| 143 | threads.some((thread) => thread.id === parsedState.data.activeThreadId), | ||
| 144 | ); | ||
| 145 | |||
| 146 | return { | ||
| 147 | projects, | ||
| 148 | threads, | ||
| 149 | activeThreadId: hasActiveThread | ||
| 150 | ? parsedState.data.activeThreadId | ||
| 151 | : (threads[0]?.id ?? null), | ||
| 152 | runtimeMode: | ||
| 153 | "runtimeMode" in parsedState.data | ||
| 154 | ? parsedState.data.runtimeMode | ||
| 155 | : DEFAULT_RUNTIME_MODE, | ||
| 156 | }; | ||
| 157 | } | ||
| 158 | |||
| 159 | export function toPersistedState( | ||
| 160 | state: PersistedStoreSnapshot, | ||
| 161 | ): z.infer<typeof persistedStateV4Schema> { | ||
| 162 | return { | ||
| 163 | version: 4, | ||
| 164 | projects: state.projects, | ||
| 165 | threads: state.threads.map((thread) => ({ | ||
| 166 | id: thread.id, | ||
| 167 | codexThreadId: thread.codexThreadId, | ||
| 168 | projectId: thread.projectId, | ||
| 169 | title: thread.title, | ||
| 170 | model: thread.model, | ||
| 171 | messages: thread.messages, | ||
| 172 | createdAt: thread.createdAt, | ||
| 173 | })), | ||
| 174 | activeThreadId: state.activeThreadId, | ||
| 175 | runtimeMode: state.runtimeMode, | ||
| 176 | }; | ||
| 177 | } | ||
apps/web/src/session-logic.test.ts created+632| ... | @@ -0,0 +1,632 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 4 | import { | ||
| 5 | type WorkLogEntry, | ||
| 6 | applyEventToMessages, | ||
| 7 | deriveTimelineEntries, | ||
| 8 | deriveWorkLogEntries, | ||
| 9 | evolveSession, | ||
| 10 | } from "./session-logic"; | ||
| 11 | import type { ChatMessage } from "./types"; | ||
| 12 | |||
| 13 | function makeEvent(overrides: Partial<ProviderEvent>): ProviderEvent { | ||
| 14 | return { | ||
| 15 | id: "evt-1", | ||
| 16 | kind: "notification", | ||
| 17 | provider: "codex", | ||
| 18 | sessionId: "sess-1", | ||
| 19 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 20 | method: "noop", | ||
| 21 | ...overrides, | ||
| 22 | }; | ||
| 23 | } | ||
| 24 | |||
| 25 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { | ||
| 26 | return { | ||
| 27 | sessionId: "sess-1", | ||
| 28 | provider: "codex", | ||
| 29 | status: "ready", | ||
| 30 | createdAt: "2026-02-08T09:59:00.000Z", | ||
| 31 | updatedAt: "2026-02-08T09:59:00.000Z", | ||
| 32 | ...overrides, | ||
| 33 | }; | ||
| 34 | } | ||
| 35 | |||
| 36 | describe("deriveTimelineEntries", () => { | ||
| 37 | it("interleaves messages and work entries by timestamp", () => { | ||
| 38 | const messages: ChatMessage[] = [ | ||
| 39 | { | ||
| 40 | id: "m-user", | ||
| 41 | role: "user", | ||
| 42 | text: "Hi", | ||
| 43 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 44 | streaming: false, | ||
| 45 | }, | ||
| 46 | { | ||
| 47 | id: "m-assistant", | ||
| 48 | role: "assistant", | ||
| 49 | text: "Hello", | ||
| 50 | createdAt: "2026-02-08T10:05:00.000Z", | ||
| 51 | streaming: false, | ||
| 52 | }, | ||
| 53 | ]; | ||
| 54 | const workEntries: WorkLogEntry[] = [ | ||
| 55 | { | ||
| 56 | id: "w-1", | ||
| 57 | label: "Tool call", | ||
| 58 | createdAt: "2026-02-08T10:02:00.000Z", | ||
| 59 | tone: "tool", | ||
| 60 | }, | ||
| 61 | { | ||
| 62 | id: "w-2", | ||
| 63 | label: "Preamble", | ||
| 64 | createdAt: "2026-02-08T10:03:00.000Z", | ||
| 65 | tone: "thinking", | ||
| 66 | }, | ||
| 67 | ]; | ||
| 68 | |||
| 69 | const timeline = deriveTimelineEntries(messages, workEntries); | ||
| 70 | |||
| 71 | expect(timeline.map((entry) => entry.id)).toEqual([ | ||
| 72 | "message:m-user", | ||
| 73 | "work:w-1", | ||
| 74 | "work:w-2", | ||
| 75 | "message:m-assistant", | ||
| 76 | ]); | ||
| 77 | }); | ||
| 78 | |||
| 79 | it("prefers work entries when timestamps are equal", () => { | ||
| 80 | const messages: ChatMessage[] = [ | ||
| 81 | { | ||
| 82 | id: "m-1", | ||
| 83 | role: "assistant", | ||
| 84 | text: "Done", | ||
| 85 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 86 | streaming: false, | ||
| 87 | }, | ||
| 88 | ]; | ||
| 89 | const workEntries: WorkLogEntry[] = [ | ||
| 90 | { | ||
| 91 | id: "w-1", | ||
| 92 | label: "Tool call", | ||
| 93 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 94 | tone: "tool", | ||
| 95 | }, | ||
| 96 | ]; | ||
| 97 | |||
| 98 | const timeline = deriveTimelineEntries(messages, workEntries); | ||
| 99 | |||
| 100 | expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]); | ||
| 101 | }); | ||
| 102 | }); | ||
| 103 | |||
| 104 | describe("deriveWorkLogEntries", () => { | ||
| 105 | it("drops preamble/work events from the visible work log", () => { | ||
| 106 | const entries = deriveWorkLogEntries( | ||
| 107 | [ | ||
| 108 | makeEvent({ | ||
| 109 | id: "evt-1", | ||
| 110 | method: "item/started", | ||
| 111 | turnId: "turn-1", | ||
| 112 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 113 | payload: { item: { type: "preamble", text: "thinking" } }, | ||
| 114 | }), | ||
| 115 | makeEvent({ | ||
| 116 | id: "evt-2", | ||
| 117 | method: "item/started", | ||
| 118 | turnId: "turn-1", | ||
| 119 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 120 | payload: { item: { type: "work", text: "planning" } }, | ||
| 121 | }), | ||
| 122 | makeEvent({ | ||
| 123 | id: "evt-3", | ||
| 124 | method: "item/started", | ||
| 125 | turnId: "turn-1", | ||
| 126 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 127 | payload: { item: { type: "tool_call", command: "ls -la" } }, | ||
| 128 | }), | ||
| 129 | ], | ||
| 130 | "turn-1", | ||
| 131 | ); | ||
| 132 | |||
| 133 | expect(entries).toHaveLength(1); | ||
| 134 | expect(entries[0]?.label).toBe("Tool call"); | ||
| 135 | }); | ||
| 136 | |||
| 137 | it("does not surface successful turn completion as a work-log row", () => { | ||
| 138 | const entries = deriveWorkLogEntries( | ||
| 139 | [ | ||
| 140 | makeEvent({ | ||
| 141 | id: "evt-start", | ||
| 142 | method: "turn/started", | ||
| 143 | turnId: "turn-1", | ||
| 144 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 145 | }), | ||
| 146 | makeEvent({ | ||
| 147 | id: "evt-complete", | ||
| 148 | method: "turn/completed", | ||
| 149 | turnId: "turn-1", | ||
| 150 | createdAt: "2026-02-08T10:00:10.000Z", | ||
| 151 | payload: { turn: { id: "turn-1", status: "completed" } }, | ||
| 152 | }), | ||
| 153 | ], | ||
| 154 | "turn-1", | ||
| 155 | ); | ||
| 156 | |||
| 157 | expect(entries).toHaveLength(0); | ||
| 158 | }); | ||
| 159 | |||
| 160 | it("shows failed turn completion in the work log", () => { | ||
| 161 | const entries = deriveWorkLogEntries( | ||
| 162 | [ | ||
| 163 | makeEvent({ | ||
| 164 | id: "evt-start", | ||
| 165 | method: "turn/started", | ||
| 166 | turnId: "turn-1", | ||
| 167 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 168 | }), | ||
| 169 | makeEvent({ | ||
| 170 | id: "evt-complete", | ||
| 171 | method: "turn/completed", | ||
| 172 | turnId: "turn-1", | ||
| 173 | createdAt: "2026-02-08T10:00:10.000Z", | ||
| 174 | payload: { | ||
| 175 | turn: { | ||
| 176 | id: "turn-1", | ||
| 177 | status: "failed", | ||
| 178 | error: { message: "sandbox denied" }, | ||
| 179 | }, | ||
| 180 | }, | ||
| 181 | }), | ||
| 182 | ], | ||
| 183 | "turn-1", | ||
| 184 | ); | ||
| 185 | |||
| 186 | expect(entries).toHaveLength(1); | ||
| 187 | expect(entries[0]?.label).toBe("Turn failed"); | ||
| 188 | expect(entries[0]?.detail).toBe("sandbox denied"); | ||
| 189 | }); | ||
| 190 | |||
| 191 | it("hides reasoning and agent-message noise from the visible work log", () => { | ||
| 192 | const entries = deriveWorkLogEntries( | ||
| 193 | [ | ||
| 194 | makeEvent({ | ||
| 195 | id: "evt-turn-start", | ||
| 196 | method: "turn/started", | ||
| 197 | turnId: "turn-1", | ||
| 198 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 199 | }), | ||
| 200 | makeEvent({ | ||
| 201 | id: "evt-summary-part", | ||
| 202 | method: "item/reasoning/summaryPartAdded", | ||
| 203 | turnId: "turn-1", | ||
| 204 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 205 | }), | ||
| 206 | makeEvent({ | ||
| 207 | id: "evt-summary-delta", | ||
| 208 | method: "item/reasoning/summaryTextDelta", | ||
| 209 | turnId: "turn-1", | ||
| 210 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 211 | }), | ||
| 212 | makeEvent({ | ||
| 213 | id: "evt-agent-start", | ||
| 214 | method: "item/started", | ||
| 215 | turnId: "turn-1", | ||
| 216 | createdAt: "2026-02-08T10:00:03.000Z", | ||
| 217 | payload: { | ||
| 218 | item: { id: "item-msg", type: "agentMessage", text: "Working..." }, | ||
| 219 | }, | ||
| 220 | }), | ||
| 221 | makeEvent({ | ||
| 222 | id: "evt-agent-complete", | ||
| 223 | method: "item/completed", | ||
| 224 | turnId: "turn-1", | ||
| 225 | createdAt: "2026-02-08T10:00:04.000Z", | ||
| 226 | payload: { | ||
| 227 | item: { | ||
| 228 | id: "item-msg", | ||
| 229 | type: "agentMessage", | ||
| 230 | text: "Done response", | ||
| 231 | }, | ||
| 232 | }, | ||
| 233 | }), | ||
| 234 | makeEvent({ | ||
| 235 | id: "evt-tool-start", | ||
| 236 | method: "item/started", | ||
| 237 | turnId: "turn-1", | ||
| 238 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 239 | payload: { | ||
| 240 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | ||
| 241 | }, | ||
| 242 | }), | ||
| 243 | makeEvent({ | ||
| 244 | id: "evt-tool-complete", | ||
| 245 | method: "item/completed", | ||
| 246 | turnId: "turn-1", | ||
| 247 | createdAt: "2026-02-08T10:00:06.000Z", | ||
| 248 | payload: { | ||
| 249 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | ||
| 250 | }, | ||
| 251 | }), | ||
| 252 | makeEvent({ | ||
| 253 | id: "evt-turn-complete", | ||
| 254 | method: "turn/completed", | ||
| 255 | turnId: "turn-1", | ||
| 256 | createdAt: "2026-02-08T10:00:08.000Z", | ||
| 257 | payload: { turn: { id: "turn-1", status: "completed" } }, | ||
| 258 | }), | ||
| 259 | ], | ||
| 260 | "turn-1", | ||
| 261 | ); | ||
| 262 | |||
| 263 | expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]); | ||
| 264 | }); | ||
| 265 | |||
| 266 | it("coalesces command start/completed lifecycle into one entry", () => { | ||
| 267 | const entries = deriveWorkLogEntries( | ||
| 268 | [ | ||
| 269 | makeEvent({ | ||
| 270 | id: "evt-command-start", | ||
| 271 | method: "item/started", | ||
| 272 | turnId: "turn-1", | ||
| 273 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 274 | payload: { | ||
| 275 | item: { | ||
| 276 | id: "item-command", | ||
| 277 | type: "command_execution", | ||
| 278 | command: "git status --short", | ||
| 279 | }, | ||
| 280 | }, | ||
| 281 | }), | ||
| 282 | makeEvent({ | ||
| 283 | id: "evt-command-complete", | ||
| 284 | method: "item/completed", | ||
| 285 | turnId: "turn-1", | ||
| 286 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 287 | payload: { | ||
| 288 | item: { | ||
| 289 | id: "item-command", | ||
| 290 | type: "command_execution", | ||
| 291 | command: "git status --short", | ||
| 292 | }, | ||
| 293 | }, | ||
| 294 | }), | ||
| 295 | ], | ||
| 296 | "turn-1", | ||
| 297 | ); | ||
| 298 | |||
| 299 | expect(entries).toHaveLength(1); | ||
| 300 | expect(entries[0]?.label).toBe("Command run"); | ||
| 301 | expect(entries[0]?.detail).toBe("git status --short"); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("preserves full tool-call detail text without data truncation", () => { | ||
| 305 | const longCommand = | ||
| 306 | 'node ./scripts/sync.js --project ct-round-5 --mode dry-run --include "very long argument with lots of detail and metadata to verify there is no hard truncation in session logic"'; | ||
| 307 | const entries = deriveWorkLogEntries( | ||
| 308 | [ | ||
| 309 | makeEvent({ | ||
| 310 | id: "evt-tool-start", | ||
| 311 | method: "item/started", | ||
| 312 | turnId: "turn-1", | ||
| 313 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 314 | payload: { | ||
| 315 | item: { id: "item-tool", type: "tool_call", command: longCommand }, | ||
| 316 | }, | ||
| 317 | }), | ||
| 318 | ], | ||
| 319 | "turn-1", | ||
| 320 | ); | ||
| 321 | |||
| 322 | expect(entries[0]?.detail).toBe(longCommand); | ||
| 323 | }); | ||
| 324 | |||
| 325 | it("hides generic tool-call rows that have no identifying detail", () => { | ||
| 326 | const entries = deriveWorkLogEntries( | ||
| 327 | [ | ||
| 328 | makeEvent({ | ||
| 329 | id: "evt-tool-start", | ||
| 330 | method: "item/started", | ||
| 331 | turnId: "turn-1", | ||
| 332 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 333 | payload: { | ||
| 334 | item: { id: "item-tool", type: "tool_call" }, | ||
| 335 | }, | ||
| 336 | }), | ||
| 337 | ], | ||
| 338 | "turn-1", | ||
| 339 | ); | ||
| 340 | |||
| 341 | expect(entries).toHaveLength(0); | ||
| 342 | }); | ||
| 343 | |||
| 344 | it("includes collab tool-call rows with tool name detail", () => { | ||
| 345 | const entries = deriveWorkLogEntries( | ||
| 346 | [ | ||
| 347 | makeEvent({ | ||
| 348 | id: "evt-collab-start", | ||
| 349 | method: "item/started", | ||
| 350 | turnId: "turn-1", | ||
| 351 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 352 | payload: { | ||
| 353 | item: { | ||
| 354 | id: "item-collab", | ||
| 355 | type: "collabAgentToolCall", | ||
| 356 | tool: "spawnAgent", | ||
| 357 | }, | ||
| 358 | }, | ||
| 359 | }), | ||
| 360 | ], | ||
| 361 | "turn-1", | ||
| 362 | ); | ||
| 363 | |||
| 364 | expect(entries).toHaveLength(1); | ||
| 365 | expect(entries[0]?.label).toBe("Tool call"); | ||
| 366 | expect(entries[0]?.detail).toBe("spawnAgent"); | ||
| 367 | }); | ||
| 368 | |||
| 369 | it("can derive work-log entries across all turns when not scoped", () => { | ||
| 370 | const entries = deriveWorkLogEntries( | ||
| 371 | [ | ||
| 372 | makeEvent({ | ||
| 373 | id: "evt-turn-1-tool", | ||
| 374 | method: "item/started", | ||
| 375 | turnId: "turn-1", | ||
| 376 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 377 | payload: { item: { type: "tool_call", command: "ls -la" } }, | ||
| 378 | }), | ||
| 379 | makeEvent({ | ||
| 380 | id: "evt-turn-2-tool", | ||
| 381 | method: "item/started", | ||
| 382 | turnId: "turn-2", | ||
| 383 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 384 | payload: { item: { type: "tool_call", command: "pwd" } }, | ||
| 385 | }), | ||
| 386 | ], | ||
| 387 | undefined, | ||
| 388 | ); | ||
| 389 | |||
| 390 | expect(entries).toHaveLength(2); | ||
| 391 | expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]); | ||
| 392 | }); | ||
| 393 | }); | ||
| 394 | |||
| 395 | describe("evolveSession", () => { | ||
| 396 | it("updates thread id when thread starts", () => { | ||
| 397 | const previous = makeSession(); | ||
| 398 | const next = evolveSession( | ||
| 399 | previous, | ||
| 400 | makeEvent({ | ||
| 401 | method: "thread/started", | ||
| 402 | createdAt: "2026-02-08T10:01:00.000Z", | ||
| 403 | payload: { thread: { id: "thread-1" } }, | ||
| 404 | }), | ||
| 405 | ); | ||
| 406 | |||
| 407 | expect(next.threadId).toBe("thread-1"); | ||
| 408 | expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z"); | ||
| 409 | }); | ||
| 410 | |||
| 411 | it("moves to running and records active turn on turn start", () => { | ||
| 412 | const previous = makeSession(); | ||
| 413 | const next = evolveSession( | ||
| 414 | previous, | ||
| 415 | makeEvent({ | ||
| 416 | method: "turn/started", | ||
| 417 | turnId: "turn-1", | ||
| 418 | createdAt: "2026-02-08T10:02:00.000Z", | ||
| 419 | }), | ||
| 420 | ); | ||
| 421 | |||
| 422 | expect(next.status).toBe("running"); | ||
| 423 | expect(next.activeTurnId).toBe("turn-1"); | ||
| 424 | expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z"); | ||
| 425 | }); | ||
| 426 | |||
| 427 | it("returns to ready and clears active turn on successful completion", () => { | ||
| 428 | const previous = makeSession({ | ||
| 429 | status: "running", | ||
| 430 | activeTurnId: "turn-1", | ||
| 431 | lastError: "older error", | ||
| 432 | }); | ||
| 433 | const next = evolveSession( | ||
| 434 | previous, | ||
| 435 | makeEvent({ | ||
| 436 | method: "turn/completed", | ||
| 437 | createdAt: "2026-02-08T10:03:00.000Z", | ||
| 438 | payload: { turn: { id: "turn-1", status: "completed" } }, | ||
| 439 | }), | ||
| 440 | ); | ||
| 441 | |||
| 442 | expect(next.status).toBe("ready"); | ||
| 443 | expect(next.activeTurnId).toBeUndefined(); | ||
| 444 | expect(next.lastError).toBe("older error"); | ||
| 445 | }); | ||
| 446 | |||
| 447 | it("marks session as error when turn fails", () => { | ||
| 448 | const previous = makeSession({ | ||
| 449 | status: "running", | ||
| 450 | activeTurnId: "turn-1", | ||
| 451 | }); | ||
| 452 | const next = evolveSession( | ||
| 453 | previous, | ||
| 454 | makeEvent({ | ||
| 455 | method: "turn/completed", | ||
| 456 | createdAt: "2026-02-08T10:03:00.000Z", | ||
| 457 | payload: { | ||
| 458 | turn: { id: "turn-1", status: "failed", error: { message: "boom" } }, | ||
| 459 | }, | ||
| 460 | }), | ||
| 461 | ); | ||
| 462 | |||
| 463 | expect(next.status).toBe("error"); | ||
| 464 | expect(next.activeTurnId).toBeUndefined(); | ||
| 465 | expect(next.lastError).toBe("boom"); | ||
| 466 | }); | ||
| 467 | |||
| 468 | it("moves to error on runtime error events", () => { | ||
| 469 | const previous = makeSession(); | ||
| 470 | const next = evolveSession( | ||
| 471 | previous, | ||
| 472 | makeEvent({ | ||
| 473 | kind: "error", | ||
| 474 | method: "runtime/error", | ||
| 475 | createdAt: "2026-02-08T10:04:00.000Z", | ||
| 476 | message: "runtime failure", | ||
| 477 | }), | ||
| 478 | ); | ||
| 479 | |||
| 480 | expect(next.status).toBe("error"); | ||
| 481 | expect(next.lastError).toBe("runtime failure"); | ||
| 482 | }); | ||
| 483 | |||
| 484 | it("closes session on close lifecycle events", () => { | ||
| 485 | const previous = makeSession({ | ||
| 486 | status: "running", | ||
| 487 | activeTurnId: "turn-1", | ||
| 488 | }); | ||
| 489 | const next = evolveSession( | ||
| 490 | previous, | ||
| 491 | makeEvent({ | ||
| 492 | method: "session/closed", | ||
| 493 | createdAt: "2026-02-08T10:05:00.000Z", | ||
| 494 | message: "closed", | ||
| 495 | }), | ||
| 496 | ); | ||
| 497 | |||
| 498 | expect(next.status).toBe("closed"); | ||
| 499 | expect(next.activeTurnId).toBeUndefined(); | ||
| 500 | expect(next.lastError).toBe("closed"); | ||
| 501 | }); | ||
| 502 | }); | ||
| 503 | |||
| 504 | describe("applyEventToMessages", () => { | ||
| 505 | it("handles start/delta/completed flow for assistant messages", () => { | ||
| 506 | const activeAssistantItemRef = { current: null as string | null }; | ||
| 507 | const started = applyEventToMessages( | ||
| 508 | [], | ||
| 509 | makeEvent({ | ||
| 510 | method: "item/started", | ||
| 511 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 512 | payload: { | ||
| 513 | item: { | ||
| 514 | id: "item-1", | ||
| 515 | type: "agentMessage", | ||
| 516 | text: "Hello", | ||
| 517 | }, | ||
| 518 | }, | ||
| 519 | }), | ||
| 520 | activeAssistantItemRef, | ||
| 521 | ); | ||
| 522 | |||
| 523 | expect(started).toEqual([ | ||
| 524 | { | ||
| 525 | id: "item-1", | ||
| 526 | role: "assistant", | ||
| 527 | text: "Hello", | ||
| 528 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 529 | streaming: true, | ||
| 530 | }, | ||
| 531 | ]); | ||
| 532 | expect(activeAssistantItemRef.current).toBe("item-1"); | ||
| 533 | |||
| 534 | const withDelta = applyEventToMessages( | ||
| 535 | started, | ||
| 536 | makeEvent({ | ||
| 537 | method: "item/agentMessage/delta", | ||
| 538 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 539 | itemId: "item-1", | ||
| 540 | textDelta: " world", | ||
| 541 | }), | ||
| 542 | activeAssistantItemRef, | ||
| 543 | ); | ||
| 544 | expect(withDelta[0]?.text).toBe("Hello world"); | ||
| 545 | expect(withDelta[0]?.streaming).toBe(true); | ||
| 546 | |||
| 547 | const completed = applyEventToMessages( | ||
| 548 | withDelta, | ||
| 549 | makeEvent({ | ||
| 550 | method: "item/completed", | ||
| 551 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 552 | payload: { | ||
| 553 | item: { | ||
| 554 | id: "item-1", | ||
| 555 | type: "agentMessage", | ||
| 556 | text: "Hello world!", | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | }), | ||
| 560 | activeAssistantItemRef, | ||
| 561 | ); | ||
| 562 | expect(completed[0]?.text).toBe("Hello world!"); | ||
| 563 | expect(completed[0]?.streaming).toBe(false); | ||
| 564 | expect(activeAssistantItemRef.current).toBeNull(); | ||
| 565 | }); | ||
| 566 | |||
| 567 | it("supports out-of-order delta before started/completed", () => { | ||
| 568 | const activeAssistantItemRef = { current: null as string | null }; | ||
| 569 | const withDelta = applyEventToMessages( | ||
| 570 | [], | ||
| 571 | makeEvent({ | ||
| 572 | method: "item/agentMessage/delta", | ||
| 573 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 574 | itemId: "item-2", | ||
| 575 | textDelta: "Partial", | ||
| 576 | }), | ||
| 577 | activeAssistantItemRef, | ||
| 578 | ); | ||
| 579 | expect(withDelta[0]?.id).toBe("item-2"); | ||
| 580 | expect(withDelta[0]?.text).toBe("Partial"); | ||
| 581 | expect(withDelta[0]?.streaming).toBe(true); | ||
| 582 | expect(activeAssistantItemRef.current).toBe("item-2"); | ||
| 583 | |||
| 584 | const completed = applyEventToMessages( | ||
| 585 | withDelta, | ||
| 586 | makeEvent({ | ||
| 587 | method: "item/completed", | ||
| 588 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 589 | payload: { | ||
| 590 | item: { | ||
| 591 | id: "item-2", | ||
| 592 | type: "agentMessage", | ||
| 593 | text: "Partial + final", | ||
| 594 | }, | ||
| 595 | }, | ||
| 596 | }), | ||
| 597 | activeAssistantItemRef, | ||
| 598 | ); | ||
| 599 | |||
| 600 | expect(completed[0]?.text).toBe("Partial + final"); | ||
| 601 | expect(completed[0]?.streaming).toBe(false); | ||
| 602 | }); | ||
| 603 | |||
| 604 | it("clears all streaming flags when turn completes", () => { | ||
| 605 | const previous: ChatMessage[] = [ | ||
| 606 | { | ||
| 607 | id: "m-user", | ||
| 608 | role: "user", | ||
| 609 | text: "Hi", | ||
| 610 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 611 | streaming: false, | ||
| 612 | }, | ||
| 613 | { | ||
| 614 | id: "m-assistant", | ||
| 615 | role: "assistant", | ||
| 616 | text: "Typing", | ||
| 617 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 618 | streaming: true, | ||
| 619 | }, | ||
| 620 | ]; | ||
| 621 | |||
| 622 | const next = applyEventToMessages( | ||
| 623 | previous, | ||
| 624 | makeEvent({ | ||
| 625 | method: "turn/completed", | ||
| 626 | }), | ||
| 627 | { current: null }, | ||
| 628 | ); | ||
| 629 | |||
| 630 | expect(next.every((entry) => entry.streaming === false)).toBe(true); | ||
| 631 | }); | ||
| 632 | }); | ||
apps/web/src/session-logic.ts created+636| ... | @@ -0,0 +1,636 @@ | ||
| 1 | import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; | ||
| 2 | import type { ChatMessage, SessionPhase } from "./types"; | ||
| 3 | import { createWsNativeApi } from "./wsNativeApi"; | ||
| 4 | |||
| 5 | export const PROVIDER_OPTIONS: Array<{ | ||
| 6 | value: ProviderKind; | ||
| 7 | label: string; | ||
| 8 | available: boolean; | ||
| 9 | }> = [ | ||
| 10 | { value: "codex", label: "Codex", available: true }, | ||
| 11 | { value: "claudeCode", label: "Claude Code (soon)", available: false }, | ||
| 12 | ]; | ||
| 13 | |||
| 14 | let cachedApi: NativeApi | undefined; | ||
| 15 | |||
| 16 | export function readNativeApi(): NativeApi | undefined { | ||
| 17 | if (typeof window === "undefined") return undefined; | ||
| 18 | if (cachedApi) return cachedApi; | ||
| 19 | |||
| 20 | // Prefer Electron preload bridge if available | ||
| 21 | if (window.nativeApi) { | ||
| 22 | cachedApi = window.nativeApi; | ||
| 23 | return cachedApi; | ||
| 24 | } | ||
| 25 | |||
| 26 | // Fall back to WebSocket transport | ||
| 27 | cachedApi = createWsNativeApi(); | ||
| 28 | return cachedApi; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function asObject(value: unknown): Record<string, unknown> | undefined { | ||
| 32 | if (!value || typeof value !== "object") return undefined; | ||
| 33 | return value as Record<string, unknown>; | ||
| 34 | } | ||
| 35 | |||
| 36 | export function asString(value: unknown): string | undefined { | ||
| 37 | return typeof value === "string" ? value : undefined; | ||
| 38 | } | ||
| 39 | |||
| 40 | export function formatTimestamp(isoDate: string): string { | ||
| 41 | return new Intl.DateTimeFormat(undefined, { | ||
| 42 | hour: "numeric", | ||
| 43 | minute: "2-digit", | ||
| 44 | second: "2-digit", | ||
| 45 | }).format(new Date(isoDate)); | ||
| 46 | } | ||
| 47 | |||
| 48 | export function formatDuration(durationMs: number): string { | ||
| 49 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; | ||
| 50 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; | ||
| 51 | if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; | ||
| 52 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; | ||
| 53 | |||
| 54 | const minutes = Math.floor(durationMs / 60_000); | ||
| 55 | const seconds = Math.round((durationMs % 60_000) / 1_000); | ||
| 56 | if (seconds === 0) return `${minutes}m`; | ||
| 57 | if (seconds === 60) return `${minutes + 1}m`; | ||
| 58 | return `${minutes}m ${seconds}s`; | ||
| 59 | } | ||
| 60 | |||
| 61 | export function formatElapsed(startIso: string, endIso: string | undefined): string | null { | ||
| 62 | if (!endIso) return null; | ||
| 63 | |||
| 64 | const startedAt = Date.parse(startIso); | ||
| 65 | const endedAt = Date.parse(endIso); | ||
| 66 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { | ||
| 67 | return null; | ||
| 68 | } | ||
| 69 | |||
| 70 | return formatDuration(endedAt - startedAt); | ||
| 71 | } | ||
| 72 | |||
| 73 | export interface WorkLogEntry { | ||
| 74 | id: string; | ||
| 75 | createdAt: string; | ||
| 76 | label: string; | ||
| 77 | detail?: string; | ||
| 78 | tone: "thinking" | "tool" | "info" | "error"; | ||
| 79 | } | ||
| 80 | |||
| 81 | export type TimelineEntry = | ||
| 82 | | { | ||
| 83 | id: string; | ||
| 84 | kind: "message"; | ||
| 85 | createdAt: string; | ||
| 86 | message: ChatMessage; | ||
| 87 | } | ||
| 88 | | { | ||
| 89 | id: string; | ||
| 90 | kind: "work"; | ||
| 91 | createdAt: string; | ||
| 92 | entry: WorkLogEntry; | ||
| 93 | }; | ||
| 94 | |||
| 95 | function normalizeDetail(value: string | undefined): string | undefined { | ||
| 96 | if (!value) return undefined; | ||
| 97 | const trimmed = value.trim(); | ||
| 98 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 99 | } | ||
| 100 | |||
| 101 | function normalizeItemType(raw: string | undefined): string { | ||
| 102 | if (!raw) return "item"; | ||
| 103 | return raw | ||
| 104 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | ||
| 105 | .replace(/[._/-]/g, " ") | ||
| 106 | .replace(/\s+/g, " ") | ||
| 107 | .trim() | ||
| 108 | .toLowerCase(); | ||
| 109 | } | ||
| 110 | |||
| 111 | function shouldDropItemType(type: string): boolean { | ||
| 112 | if ( | ||
| 113 | type.includes("preamble") || | ||
| 114 | type.includes("reasoning") || | ||
| 115 | type.includes("thought") | ||
| 116 | ) { | ||
| 117 | return true; | ||
| 118 | } | ||
| 119 | |||
| 120 | return type === "work" || type.startsWith("work "); | ||
| 121 | } | ||
| 122 | |||
| 123 | function shouldShowItemLifecycle(type: string): boolean { | ||
| 124 | return ( | ||
| 125 | type.includes("tool") || | ||
| 126 | type.includes("command") || | ||
| 127 | type.includes("file change") | ||
| 128 | ); | ||
| 129 | } | ||
| 130 | |||
| 131 | function shouldDropMethod(method: string): boolean { | ||
| 132 | return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); | ||
| 133 | } | ||
| 134 | |||
| 135 | function itemTypeMeta(type: string): { | ||
| 136 | label: string; | ||
| 137 | tone: WorkLogEntry["tone"]; | ||
| 138 | } { | ||
| 139 | if (type.includes("command")) { | ||
| 140 | return { label: "Command run", tone: "tool" }; | ||
| 141 | } | ||
| 142 | if (type.includes("file change")) { | ||
| 143 | return { label: "File change", tone: "tool" }; | ||
| 144 | } | ||
| 145 | if (type.includes("tool")) { | ||
| 146 | return { label: "Tool call", tone: "tool" }; | ||
| 147 | } | ||
| 148 | return { label: "Work item", tone: "info" }; | ||
| 149 | } | ||
| 150 | |||
| 151 | interface ItemLifecycleCandidate { | ||
| 152 | id: string; | ||
| 153 | itemId?: string; | ||
| 154 | createdAt: string; | ||
| 155 | label: string; | ||
| 156 | detail?: string; | ||
| 157 | tone: WorkLogEntry["tone"]; | ||
| 158 | phase: "started" | "completed"; | ||
| 159 | } | ||
| 160 | |||
| 161 | function extractDetail( | ||
| 162 | payload: Record<string, unknown> | undefined, | ||
| 163 | item: Record<string, unknown> | undefined, | ||
| 164 | ): string | undefined { | ||
| 165 | const candidates = [ | ||
| 166 | asString(item?.command), | ||
| 167 | asString(item?.tool), | ||
| 168 | asString(item?.name), | ||
| 169 | asString(item?.title), | ||
| 170 | asString(item?.summary), | ||
| 171 | asString(item?.text), | ||
| 172 | asString(item?.prompt), | ||
| 173 | asString(payload?.message), | ||
| 174 | asString(payload?.prompt), | ||
| 175 | asString(payload?.command), | ||
| 176 | ]; | ||
| 177 | |||
| 178 | for (const candidate of candidates) { | ||
| 179 | const detail = normalizeDetail(candidate); | ||
| 180 | if (detail) return detail; | ||
| 181 | } | ||
| 182 | return undefined; | ||
| 183 | } | ||
| 184 | |||
| 185 | function lifecycleCandidateFromItemEvent( | ||
| 186 | event: ProviderEvent, | ||
| 187 | ): ItemLifecycleCandidate | null { | ||
| 188 | const payload = asObject(event.payload); | ||
| 189 | const item = asObject(payload?.item); | ||
| 190 | const normalizedType = normalizeItemType(asString(item?.type)); | ||
| 191 | if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { | ||
| 192 | return null; | ||
| 193 | } | ||
| 194 | if (!shouldShowItemLifecycle(normalizedType)) { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | |||
| 198 | const meta = itemTypeMeta(normalizedType); | ||
| 199 | const isStarted = event.method === "item/started"; | ||
| 200 | const isCompleted = event.method === "item/completed"; | ||
| 201 | if (!isStarted && !isCompleted) { | ||
| 202 | return null; | ||
| 203 | } | ||
| 204 | |||
| 205 | const detail = extractDetail(payload, item); | ||
| 206 | const itemId = event.itemId ?? asString(item?.id); | ||
| 207 | return { | ||
| 208 | id: event.id, | ||
| 209 | ...(itemId ? { itemId } : {}), | ||
| 210 | createdAt: event.createdAt, | ||
| 211 | label: meta.label, | ||
| 212 | ...(detail ? { detail } : {}), | ||
| 213 | tone: meta.tone, | ||
| 214 | phase: isCompleted ? "completed" : "started", | ||
| 215 | }; | ||
| 216 | } | ||
| 217 | |||
| 218 | function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { | ||
| 219 | if (event.kind !== "request") return null; | ||
| 220 | if (shouldDropMethod(event.method)) return null; | ||
| 221 | |||
| 222 | if (event.method.includes("commandExecution")) { | ||
| 223 | return { | ||
| 224 | id: event.id, | ||
| 225 | createdAt: event.createdAt, | ||
| 226 | label: "Command approval requested", | ||
| 227 | tone: "tool", | ||
| 228 | }; | ||
| 229 | } | ||
| 230 | |||
| 231 | if (event.method.includes("fileChange")) { | ||
| 232 | return { | ||
| 233 | id: event.id, | ||
| 234 | createdAt: event.createdAt, | ||
| 235 | label: "File-change approval requested", | ||
| 236 | tone: "tool", | ||
| 237 | }; | ||
| 238 | } | ||
| 239 | |||
| 240 | if (event.method.includes("requestUserInput")) { | ||
| 241 | return { | ||
| 242 | id: event.id, | ||
| 243 | createdAt: event.createdAt, | ||
| 244 | label: "Tool requested user input", | ||
| 245 | tone: "tool", | ||
| 246 | }; | ||
| 247 | } | ||
| 248 | |||
| 249 | return { | ||
| 250 | id: event.id, | ||
| 251 | createdAt: event.createdAt, | ||
| 252 | label: `Request: ${event.method}`, | ||
| 253 | tone: "info", | ||
| 254 | }; | ||
| 255 | } | ||
| 256 | |||
| 257 | function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { | ||
| 258 | if (event.kind !== "notification") return null; | ||
| 259 | if (shouldDropMethod(event.method)) return null; | ||
| 260 | if (event.method === "item/agentMessage/delta") return null; | ||
| 261 | if (event.method === "turn/started") { | ||
| 262 | return null; | ||
| 263 | } | ||
| 264 | if (event.method === "thread/started") return null; | ||
| 265 | |||
| 266 | if (event.method === "turn/completed") { | ||
| 267 | const payload = asObject(event.payload); | ||
| 268 | const turn = asObject(payload?.turn); | ||
| 269 | const status = asString(turn?.status); | ||
| 270 | if (status !== "failed") { | ||
| 271 | return null; | ||
| 272 | } | ||
| 273 | const turnError = asObject(turn?.error); | ||
| 274 | const turnErrorMessage = asString(turnError?.message); | ||
| 275 | const turnErrorDetail = normalizeDetail(turnErrorMessage); | ||
| 276 | |||
| 277 | return { | ||
| 278 | id: event.id, | ||
| 279 | createdAt: event.createdAt, | ||
| 280 | label: "Turn failed", | ||
| 281 | ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), | ||
| 282 | tone: "error", | ||
| 283 | }; | ||
| 284 | } | ||
| 285 | |||
| 286 | if (event.method.startsWith("item/")) return null; | ||
| 287 | |||
| 288 | return null; | ||
| 289 | } | ||
| 290 | |||
| 291 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { | ||
| 292 | if (event.kind !== "error") return null; | ||
| 293 | if (shouldDropMethod(event.method)) return null; | ||
| 294 | const detail = normalizeDetail(event.message); | ||
| 295 | |||
| 296 | return { | ||
| 297 | id: event.id, | ||
| 298 | createdAt: event.createdAt, | ||
| 299 | label: "Runtime error", | ||
| 300 | ...(detail ? { detail } : {}), | ||
| 301 | tone: "error", | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | function eventTurnId(event: ProviderEvent): string | undefined { | ||
| 306 | const payload = asObject(event.payload); | ||
| 307 | const turn = asObject(payload?.turn); | ||
| 308 | return event.turnId ?? asString(turn?.id); | ||
| 309 | } | ||
| 310 | |||
| 311 | export function deriveWorkLogEntries( | ||
| 312 | events: ProviderEvent[], | ||
| 313 | turnId: string | undefined, | ||
| 314 | ): WorkLogEntry[] { | ||
| 315 | const ordered = [...events].toReversed(); | ||
| 316 | const entries: WorkLogEntry[] = []; | ||
| 317 | const turnStartedAtIso = turnId | ||
| 318 | ? ordered.find((event) => { | ||
| 319 | if (event.method !== "turn/started") return false; | ||
| 320 | return eventTurnId(event) === turnId; | ||
| 321 | })?.createdAt | ||
| 322 | : undefined; | ||
| 323 | const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; | ||
| 324 | |||
| 325 | const shouldIncludeEvent = (event: ProviderEvent): boolean => { | ||
| 326 | if (!turnId) return true; | ||
| 327 | const scopedTurnId = eventTurnId(event); | ||
| 328 | if (scopedTurnId && scopedTurnId !== turnId) { | ||
| 329 | return false; | ||
| 330 | } | ||
| 331 | |||
| 332 | if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { | ||
| 333 | const eventAt = Date.parse(event.createdAt); | ||
| 334 | if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { | ||
| 335 | return false; | ||
| 336 | } | ||
| 337 | } | ||
| 338 | |||
| 339 | return true; | ||
| 340 | }; | ||
| 341 | |||
| 342 | const completedLifecycleItemIds = new Set<string>(); | ||
| 343 | for (const event of ordered) { | ||
| 344 | if (!shouldIncludeEvent(event)) continue; | ||
| 345 | const candidate = lifecycleCandidateFromItemEvent(event); | ||
| 346 | if (candidate?.phase === "completed" && candidate.itemId) { | ||
| 347 | completedLifecycleItemIds.add(candidate.itemId); | ||
| 348 | } | ||
| 349 | } | ||
| 350 | |||
| 351 | for (const event of ordered) { | ||
| 352 | if (!shouldIncludeEvent(event)) continue; | ||
| 353 | |||
| 354 | const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); | ||
| 355 | if (lifecycleCandidate) { | ||
| 356 | if ( | ||
| 357 | lifecycleCandidate.phase === "started" && | ||
| 358 | lifecycleCandidate.itemId && | ||
| 359 | completedLifecycleItemIds.has(lifecycleCandidate.itemId) | ||
| 360 | ) { | ||
| 361 | continue; | ||
| 362 | } | ||
| 363 | if ( | ||
| 364 | lifecycleCandidate.label === "Tool call" && | ||
| 365 | !lifecycleCandidate.detail | ||
| 366 | ) { | ||
| 367 | continue; | ||
| 368 | } | ||
| 369 | |||
| 370 | entries.push({ | ||
| 371 | id: lifecycleCandidate.id, | ||
| 372 | createdAt: lifecycleCandidate.createdAt, | ||
| 373 | label: lifecycleCandidate.label, | ||
| 374 | ...(lifecycleCandidate.detail | ||
| 375 | ? { detail: lifecycleCandidate.detail } | ||
| 376 | : {}), | ||
| 377 | tone: lifecycleCandidate.tone, | ||
| 378 | }); | ||
| 379 | continue; | ||
| 380 | } | ||
| 381 | |||
| 382 | const fromRequest = entryFromRequest(event); | ||
| 383 | if (fromRequest) { | ||
| 384 | entries.push(fromRequest); | ||
| 385 | continue; | ||
| 386 | } | ||
| 387 | |||
| 388 | const fromNotification = entryFromNotification(event); | ||
| 389 | if (fromNotification) { | ||
| 390 | entries.push(fromNotification); | ||
| 391 | continue; | ||
| 392 | } | ||
| 393 | |||
| 394 | const fromError = entryFromError(event); | ||
| 395 | if (fromError) { | ||
| 396 | entries.push(fromError); | ||
| 397 | } | ||
| 398 | } | ||
| 399 | |||
| 400 | return entries; | ||
| 401 | } | ||
| 402 | |||
| 403 | function toTimestamp(isoDate: string): number { | ||
| 404 | const parsed = Date.parse(isoDate); | ||
| 405 | return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; | ||
| 406 | } | ||
| 407 | |||
| 408 | export function deriveTimelineEntries( | ||
| 409 | messages: ChatMessage[], | ||
| 410 | workEntries: WorkLogEntry[], | ||
| 411 | ): TimelineEntry[] { | ||
| 412 | const timeline: TimelineEntry[] = []; | ||
| 413 | let messageIndex = 0; | ||
| 414 | let workIndex = 0; | ||
| 415 | |||
| 416 | while (messageIndex < messages.length || workIndex < workEntries.length) { | ||
| 417 | const message = messages[messageIndex]; | ||
| 418 | const workEntry = workEntries[workIndex]; | ||
| 419 | |||
| 420 | if (!message && workEntry) { | ||
| 421 | timeline.push({ | ||
| 422 | id: `work:${workEntry.id}`, | ||
| 423 | kind: "work", | ||
| 424 | createdAt: workEntry.createdAt, | ||
| 425 | entry: workEntry, | ||
| 426 | }); | ||
| 427 | workIndex += 1; | ||
| 428 | continue; | ||
| 429 | } | ||
| 430 | |||
| 431 | if (!workEntry && message) { | ||
| 432 | timeline.push({ | ||
| 433 | id: `message:${message.id}`, | ||
| 434 | kind: "message", | ||
| 435 | createdAt: message.createdAt, | ||
| 436 | message, | ||
| 437 | }); | ||
| 438 | messageIndex += 1; | ||
| 439 | continue; | ||
| 440 | } | ||
| 441 | |||
| 442 | if (!message || !workEntry) { | ||
| 443 | break; | ||
| 444 | } | ||
| 445 | |||
| 446 | const messageAt = toTimestamp(message.createdAt); | ||
| 447 | const workAt = toTimestamp(workEntry.createdAt); | ||
| 448 | |||
| 449 | if (workAt <= messageAt) { | ||
| 450 | timeline.push({ | ||
| 451 | id: `work:${workEntry.id}`, | ||
| 452 | kind: "work", | ||
| 453 | createdAt: workEntry.createdAt, | ||
| 454 | entry: workEntry, | ||
| 455 | }); | ||
| 456 | workIndex += 1; | ||
| 457 | continue; | ||
| 458 | } | ||
| 459 | |||
| 460 | timeline.push({ | ||
| 461 | id: `message:${message.id}`, | ||
| 462 | kind: "message", | ||
| 463 | createdAt: message.createdAt, | ||
| 464 | message, | ||
| 465 | }); | ||
| 466 | messageIndex += 1; | ||
| 467 | } | ||
| 468 | |||
| 469 | return timeline; | ||
| 470 | } | ||
| 471 | |||
| 472 | export function derivePhase(session: ProviderSession | null): SessionPhase { | ||
| 473 | if (!session || session.status === "closed") return "disconnected"; | ||
| 474 | if (session.status === "connecting") return "connecting"; | ||
| 475 | if (session.status === "running") return "running"; | ||
| 476 | return "ready"; | ||
| 477 | } | ||
| 478 | |||
| 479 | export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession { | ||
| 480 | const payload = asObject(event.payload); | ||
| 481 | |||
| 482 | if (event.method === "thread/started") { | ||
| 483 | const thread = asObject(payload?.thread); | ||
| 484 | return { | ||
| 485 | ...previous, | ||
| 486 | threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, | ||
| 487 | updatedAt: event.createdAt, | ||
| 488 | }; | ||
| 489 | } | ||
| 490 | |||
| 491 | if (event.method === "turn/started") { | ||
| 492 | const turn = asObject(payload?.turn); | ||
| 493 | return { | ||
| 494 | ...previous, | ||
| 495 | status: "running", | ||
| 496 | activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, | ||
| 497 | updatedAt: event.createdAt, | ||
| 498 | }; | ||
| 499 | } | ||
| 500 | |||
| 501 | if (event.method === "turn/completed") { | ||
| 502 | const turn = asObject(payload?.turn); | ||
| 503 | const status = asString(turn?.status); | ||
| 504 | const turnError = asObject(turn?.error); | ||
| 505 | return { | ||
| 506 | ...previous, | ||
| 507 | status: status === "failed" ? "error" : "ready", | ||
| 508 | activeTurnId: undefined, | ||
| 509 | lastError: asString(turnError?.message) ?? previous.lastError, | ||
| 510 | updatedAt: event.createdAt, | ||
| 511 | }; | ||
| 512 | } | ||
| 513 | |||
| 514 | if (event.kind === "error") { | ||
| 515 | return { | ||
| 516 | ...previous, | ||
| 517 | status: "error", | ||
| 518 | lastError: event.message ?? previous.lastError, | ||
| 519 | updatedAt: event.createdAt, | ||
| 520 | }; | ||
| 521 | } | ||
| 522 | |||
| 523 | if (event.method === "session/closed" || event.method === "session/exited") { | ||
| 524 | return { | ||
| 525 | ...previous, | ||
| 526 | status: "closed", | ||
| 527 | activeTurnId: undefined, | ||
| 528 | lastError: event.message ?? previous.lastError, | ||
| 529 | updatedAt: event.createdAt, | ||
| 530 | }; | ||
| 531 | } | ||
| 532 | |||
| 533 | return { ...previous, updatedAt: event.createdAt }; | ||
| 534 | } | ||
| 535 | |||
| 536 | export function applyEventToMessages( | ||
| 537 | previous: ChatMessage[], | ||
| 538 | event: ProviderEvent, | ||
| 539 | activeAssistantItemRef: { current: string | null }, | ||
| 540 | ): ChatMessage[] { | ||
| 541 | const payload = asObject(event.payload); | ||
| 542 | |||
| 543 | if (event.method === "item/started") { | ||
| 544 | const item = asObject(payload?.item); | ||
| 545 | if (asString(item?.type) !== "agentMessage") return previous; | ||
| 546 | const itemId = asString(item?.id); | ||
| 547 | if (!itemId) return previous; | ||
| 548 | |||
| 549 | activeAssistantItemRef.current = itemId; | ||
| 550 | const seedText = asString(item?.text) ?? ""; | ||
| 551 | const filtered = previous.filter((entry) => entry.id !== itemId); | ||
| 552 | return [ | ||
| 553 | ...filtered, | ||
| 554 | { | ||
| 555 | id: itemId, | ||
| 556 | role: "assistant", | ||
| 557 | text: seedText, | ||
| 558 | createdAt: event.createdAt, | ||
| 559 | streaming: true, | ||
| 560 | }, | ||
| 561 | ]; | ||
| 562 | } | ||
| 563 | |||
| 564 | if (event.method === "item/agentMessage/delta") { | ||
| 565 | const itemId = event.itemId ?? asString(payload?.itemId); | ||
| 566 | const delta = event.textDelta ?? asString(payload?.delta) ?? ""; | ||
| 567 | if (!itemId || !delta) return previous; | ||
| 568 | |||
| 569 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | ||
| 570 | if (existingIndex === -1) { | ||
| 571 | activeAssistantItemRef.current = itemId; | ||
| 572 | return [ | ||
| 573 | ...previous, | ||
| 574 | { | ||
| 575 | id: itemId, | ||
| 576 | role: "assistant", | ||
| 577 | text: delta, | ||
| 578 | createdAt: event.createdAt, | ||
| 579 | streaming: true, | ||
| 580 | }, | ||
| 581 | ]; | ||
| 582 | } | ||
| 583 | |||
| 584 | const updated = [...previous]; | ||
| 585 | const existing = updated[existingIndex]; | ||
| 586 | if (!existing) return previous; | ||
| 587 | updated[existingIndex] = { | ||
| 588 | ...existing, | ||
| 589 | text: `${existing.text}${delta}`, | ||
| 590 | streaming: true, | ||
| 591 | }; | ||
| 592 | return updated; | ||
| 593 | } | ||
| 594 | |||
| 595 | if (event.method === "item/completed") { | ||
| 596 | const item = asObject(payload?.item); | ||
| 597 | if (asString(item?.type) !== "agentMessage") return previous; | ||
| 598 | const itemId = asString(item?.id); | ||
| 599 | if (!itemId) return previous; | ||
| 600 | |||
| 601 | const fullText = asString(item?.text); | ||
| 602 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | ||
| 603 | if (existingIndex === -1) { | ||
| 604 | return [ | ||
| 605 | ...previous, | ||
| 606 | { | ||
| 607 | id: itemId, | ||
| 608 | role: "assistant", | ||
| 609 | text: fullText ?? "", | ||
| 610 | createdAt: event.createdAt, | ||
| 611 | streaming: false, | ||
| 612 | }, | ||
| 613 | ]; | ||
| 614 | } | ||
| 615 | |||
| 616 | const updated = [...previous]; | ||
| 617 | const existing = updated[existingIndex]; | ||
| 618 | if (!existing) return previous; | ||
| 619 | updated[existingIndex] = { | ||
| 620 | ...existing, | ||
| 621 | text: fullText ?? existing.text, | ||
| 622 | streaming: false, | ||
| 623 | }; | ||
| 624 | |||
| 625 | if (activeAssistantItemRef.current === itemId) { | ||
| 626 | activeAssistantItemRef.current = null; | ||
| 627 | } | ||
| 628 | return updated; | ||
| 629 | } | ||
| 630 | |||
| 631 | if (event.method === "turn/completed") { | ||
| 632 | return previous.map((entry) => ({ ...entry, streaming: false })); | ||
| 633 | } | ||
| 634 | |||
| 635 | return previous; | ||
| 636 | } | ||
apps/web/src/store.test.ts created+188| ... | @@ -0,0 +1,188 @@ | ||
| 1 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vitest"; | ||
| 3 | |||
| 4 | import { type AppState, reducer } from "./store"; | ||
| 5 | import type { Thread } from "./types"; | ||
| 6 | |||
| 7 | function makeSession(overrides: Partial<ProviderSession> = {}): ProviderSession { | ||
| 8 | return { | ||
| 9 | sessionId: "sess-1", | ||
| 10 | provider: "codex", | ||
| 11 | status: "ready", | ||
| 12 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 13 | updatedAt: "2026-02-09T00:00:00.000Z", | ||
| 14 | ...overrides, | ||
| 15 | }; | ||
| 16 | } | ||
| 17 | |||
| 18 | function makeEvent(overrides: Partial<ProviderEvent> = {}): ProviderEvent { | ||
| 19 | return { | ||
| 20 | id: "evt-1", | ||
| 21 | kind: "notification", | ||
| 22 | provider: "codex", | ||
| 23 | sessionId: "sess-1", | ||
| 24 | createdAt: "2026-02-09T00:00:01.000Z", | ||
| 25 | method: "thread/started", | ||
| 26 | ...overrides, | ||
| 27 | }; | ||
| 28 | } | ||
| 29 | |||
| 30 | function makeThread(overrides: Partial<Thread> = {}): Thread { | ||
| 31 | return { | ||
| 32 | id: "thread-local-1", | ||
| 33 | codexThreadId: null, | ||
| 34 | projectId: "project-1", | ||
| 35 | title: "Thread", | ||
| 36 | model: "gpt-5.3-codex", | ||
| 37 | session: makeSession(), | ||
| 38 | messages: [], | ||
| 39 | events: [], | ||
| 40 | error: null, | ||
| 41 | createdAt: "2026-02-09T00:00:00.000Z", | ||
| 42 | ...overrides, | ||
| 43 | }; | ||
| 44 | } | ||
| 45 | |||
| 46 | function makeState(thread: Thread): AppState { | ||
| 47 | return { | ||
| 48 | projects: [ | ||
| 49 | { | ||
| 50 | id: "project-1", | ||
| 51 | name: "Project", | ||
| 52 | cwd: "/tmp/project", | ||
| 53 | model: "gpt-5.3-codex", | ||
| 54 | expanded: true, | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | threads: [thread], | ||
| 58 | activeThreadId: thread.id, | ||
| 59 | runtimeMode: "full-access", | ||
| 60 | diffOpen: false, | ||
| 61 | }; | ||
| 62 | } | ||
| 63 | |||
| 64 | describe("store reducer thread continuity", () => { | ||
| 65 | it("stores codexThreadId from UPDATE_SESSION", () => { | ||
| 66 | const state = makeState( | ||
| 67 | makeThread({ | ||
| 68 | session: null, | ||
| 69 | }), | ||
| 70 | ); | ||
| 71 | const next = reducer(state, { | ||
| 72 | type: "UPDATE_SESSION", | ||
| 73 | threadId: "thread-local-1", | ||
| 74 | session: makeSession({ threadId: "thr_123" }), | ||
| 75 | }); | ||
| 76 | |||
| 77 | expect(next.threads[0]?.codexThreadId).toBe("thr_123"); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("backfills codexThreadId from routed provider events", () => { | ||
| 81 | const state = makeState(makeThread({ codexThreadId: null })); | ||
| 82 | const next = reducer(state, { | ||
| 83 | type: "APPLY_EVENT", | ||
| 84 | event: makeEvent({ | ||
| 85 | method: "thread/started", | ||
| 86 | payload: { thread: { id: "thr_backfilled" } }, | ||
| 87 | }), | ||
| 88 | activeAssistantItemRef: { current: null }, | ||
| 89 | }); | ||
| 90 | |||
| 91 | expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("ignores events from a foreign thread within the same session", () => { | ||
| 95 | const state = makeState(makeThread({ codexThreadId: "thr_expected" })); | ||
| 96 | const next = reducer(state, { | ||
| 97 | type: "APPLY_EVENT", | ||
| 98 | event: makeEvent({ | ||
| 99 | method: "turn/started", | ||
| 100 | threadId: "thr_unexpected", | ||
| 101 | payload: { turn: { id: "turn-1" } }, | ||
| 102 | }), | ||
| 103 | activeAssistantItemRef: { current: null }, | ||
| 104 | }); | ||
| 105 | |||
| 106 | expect(next).toBe(state); | ||
| 107 | }); | ||
| 108 | |||
| 109 | it("rebases thread identity on thread/started during connect", () => { | ||
| 110 | const state = makeState( | ||
| 111 | makeThread({ | ||
| 112 | codexThreadId: "thr_old", | ||
| 113 | session: makeSession({ | ||
| 114 | status: "connecting", | ||
| 115 | threadId: "thr_old", | ||
| 116 | }), | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | const next = reducer(state, { | ||
| 120 | type: "APPLY_EVENT", | ||
| 121 | event: makeEvent({ | ||
| 122 | method: "thread/started", | ||
| 123 | threadId: "thr_new", | ||
| 124 | payload: { thread: { id: "thr_new" } }, | ||
| 125 | }), | ||
| 126 | activeAssistantItemRef: { current: null }, | ||
| 127 | }); | ||
| 128 | |||
| 129 | expect(next.threads[0]?.codexThreadId).toBe("thr_new"); | ||
| 130 | expect(next.threads[0]?.session?.threadId).toBe("thr_new"); | ||
| 131 | }); | ||
| 132 | |||
| 133 | it("reconciles project ids by cwd when syncing backend projects", () => { | ||
| 134 | const state: AppState = { | ||
| 135 | projects: [ | ||
| 136 | { | ||
| 137 | id: "project-old-a", | ||
| 138 | name: "A", | ||
| 139 | cwd: "/tmp/a", | ||
| 140 | model: "gpt-5.3-codex", | ||
| 141 | expanded: false, | ||
| 142 | }, | ||
| 143 | { | ||
| 144 | id: "project-old-b", | ||
| 145 | name: "B", | ||
| 146 | cwd: "/tmp/b", | ||
| 147 | model: "gpt-5.3-codex", | ||
| 148 | expanded: true, | ||
| 149 | }, | ||
| 150 | ], | ||
| 151 | threads: [ | ||
| 152 | makeThread({ | ||
| 153 | id: "thread-a", | ||
| 154 | projectId: "project-old-a", | ||
| 155 | }), | ||
| 156 | makeThread({ | ||
| 157 | id: "thread-b", | ||
| 158 | projectId: "project-old-b", | ||
| 159 | }), | ||
| 160 | ], | ||
| 161 | activeThreadId: "thread-b", | ||
| 162 | runtimeMode: "full-access", | ||
| 163 | diffOpen: false, | ||
| 164 | }; | ||
| 165 | |||
| 166 | const next = reducer(state, { | ||
| 167 | type: "SYNC_PROJECTS", | ||
| 168 | projects: [ | ||
| 169 | { | ||
| 170 | id: "project-new-a", | ||
| 171 | name: "A", | ||
| 172 | cwd: "/tmp/a", | ||
| 173 | model: "gpt-5.3-codex", | ||
| 174 | expanded: true, | ||
| 175 | }, | ||
| 176 | ], | ||
| 177 | }); | ||
| 178 | |||
| 179 | expect(next.projects).toHaveLength(1); | ||
| 180 | expect(next.projects[0]?.id).toBe("project-new-a"); | ||
| 181 | // Preserve existing project UI preferences by cwd | ||
| 182 | expect(next.projects[0]?.expanded).toBe(false); | ||
| 183 | expect(next.threads).toHaveLength(1); | ||
| 184 | expect(next.threads[0]?.id).toBe("thread-a"); | ||
| 185 | expect(next.threads[0]?.projectId).toBe("project-new-a"); | ||
| 186 | expect(next.activeThreadId).toBe("thread-a"); | ||
| 187 | }); | ||
| 188 | }); | ||
apps/web/src/store.ts created+402| ... | @@ -0,0 +1,402 @@ | ||
| 1 | import { | ||
| 2 | type Dispatch, | ||
| 3 | type ReactNode, | ||
| 4 | createContext, | ||
| 5 | createElement, | ||
| 6 | useContext, | ||
| 7 | useEffect, | ||
| 8 | useReducer, | ||
| 9 | } from "react"; | ||
| 10 | |||
| 11 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 12 | import { resolveModelSlug } from "./model-logic"; | ||
| 13 | import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; | ||
| 14 | import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic"; | ||
| 15 | import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types"; | ||
| 16 | |||
| 17 | // ── Actions ────────────────────────────────────────────────────────── | ||
| 18 | |||
| 19 | type Action = | ||
| 20 | | { type: "ADD_PROJECT"; project: Project } | ||
| 21 | | { type: "SYNC_PROJECTS"; projects: Project[] } | ||
| 22 | | { type: "TOGGLE_PROJECT"; projectId: string } | ||
| 23 | | { type: "ADD_THREAD"; thread: Thread } | ||
| 24 | | { type: "SET_ACTIVE_THREAD"; threadId: string } | ||
| 25 | | { type: "TOGGLE_DIFF" } | ||
| 26 | | { | ||
| 27 | type: "APPLY_EVENT"; | ||
| 28 | event: ProviderEvent; | ||
| 29 | activeAssistantItemRef: { current: string | null }; | ||
| 30 | } | ||
| 31 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | ||
| 32 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } | ||
| 33 | | { type: "SET_ERROR"; threadId: string; error: string | null } | ||
| 34 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } | ||
| 35 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string } | ||
| 36 | | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; | ||
| 37 | |||
| 38 | // ── State ──────────────────────────────────────────────────────────── | ||
| 39 | |||
| 40 | export interface AppState { | ||
| 41 | projects: Project[]; | ||
| 42 | threads: Thread[]; | ||
| 43 | activeThreadId: string | null; | ||
| 44 | runtimeMode: RuntimeMode; | ||
| 45 | diffOpen: boolean; | ||
| 46 | } | ||
| 47 | |||
| 48 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v4"; | ||
| 49 | const LEGACY_PERSISTED_STATE_KEYS = [ | ||
| 50 | "t3code:renderer-state:v3", | ||
| 51 | "codething:renderer-state:v4", | ||
| 52 | "codething:renderer-state:v3", | ||
| 53 | "codething:renderer-state:v2", | ||
| 54 | "codething:renderer-state:v1", | ||
| 55 | ] as const; | ||
| 56 | |||
| 57 | const initialState: AppState = { | ||
| 58 | projects: [], | ||
| 59 | threads: [], | ||
| 60 | activeThreadId: null, | ||
| 61 | runtimeMode: DEFAULT_RUNTIME_MODE, | ||
| 62 | diffOpen: false, | ||
| 63 | }; | ||
| 64 | |||
| 65 | // ── Helpers ────────────────────────────────────────────────────────── | ||
| 66 | |||
| 67 | function readPersistedState(): AppState { | ||
| 68 | if (typeof window === "undefined") return initialState; | ||
| 69 | |||
| 70 | try { | ||
| 71 | const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); | ||
| 72 | const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS; | ||
| 73 | const rawLegacyV3 = window.localStorage.getItem(legacyV3Key); | ||
| 74 | const rawLegacyV2 = window.localStorage.getItem(legacyV2Key); | ||
| 75 | const rawLegacyV1 = window.localStorage.getItem(legacyV1Key); | ||
| 76 | const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1; | ||
| 77 | if (!raw) return initialState; | ||
| 78 | const hydrated = hydratePersistedState( | ||
| 79 | raw, | ||
| 80 | !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1), | ||
| 81 | ); | ||
| 82 | if (!hydrated) return initialState; | ||
| 83 | |||
| 84 | return { ...hydrated, diffOpen: false }; | ||
| 85 | } catch { | ||
| 86 | return initialState; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | function persistState(state: AppState): void { | ||
| 91 | if (typeof window === "undefined") return; | ||
| 92 | |||
| 93 | try { | ||
| 94 | window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); | ||
| 95 | for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { | ||
| 96 | window.localStorage.removeItem(legacyKey); | ||
| 97 | } | ||
| 98 | } catch { | ||
| 99 | // Ignore quota/storage errors to avoid breaking chat UX. | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | function updateThread( | ||
| 104 | threads: Thread[], | ||
| 105 | threadId: string, | ||
| 106 | updater: (t: Thread) => Thread, | ||
| 107 | ): Thread[] { | ||
| 108 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); | ||
| 109 | } | ||
| 110 | |||
| 111 | function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { | ||
| 112 | return threads.find((t) => t.session?.sessionId === sessionId); | ||
| 113 | } | ||
| 114 | |||
| 115 | function getEventTurnId(event: ProviderEvent): string | undefined { | ||
| 116 | if (event.turnId) return event.turnId; | ||
| 117 | const payload = asObject(event.payload); | ||
| 118 | const turn = asObject(payload?.turn); | ||
| 119 | return asString(turn?.id); | ||
| 120 | } | ||
| 121 | |||
| 122 | function getEventThreadId(event: ProviderEvent): string | undefined { | ||
| 123 | if (event.threadId) return event.threadId; | ||
| 124 | const payload = asObject(event.payload); | ||
| 125 | const payloadThread = asObject(payload?.thread); | ||
| 126 | const payloadMessage = asObject(payload?.msg); | ||
| 127 | return ( | ||
| 128 | asString(payload?.threadId) ?? | ||
| 129 | asString(payloadThread?.id) ?? | ||
| 130 | asString(payload?.conversationId) ?? | ||
| 131 | asString(payload?.thread_id) ?? | ||
| 132 | asString(payloadMessage?.thread_id) | ||
| 133 | ); | ||
| 134 | } | ||
| 135 | |||
| 136 | function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { | ||
| 137 | const eventThreadId = getEventThreadId(event); | ||
| 138 | if (!eventThreadId) { | ||
| 139 | return false; | ||
| 140 | } | ||
| 141 | |||
| 142 | const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; | ||
| 143 | if (!expectedThreadId || eventThreadId === expectedThreadId) { | ||
| 144 | return false; | ||
| 145 | } | ||
| 146 | |||
| 147 | // During connect, accept a thread/started notification as an identity rebind. | ||
| 148 | if (event.method === "thread/started" && thread.session?.status === "connecting") { | ||
| 149 | return false; | ||
| 150 | } | ||
| 151 | |||
| 152 | return true; | ||
| 153 | } | ||
| 154 | |||
| 155 | function durationMs(startIso: string, endIso: string): number | undefined { | ||
| 156 | const start = Date.parse(startIso); | ||
| 157 | const end = Date.parse(endIso); | ||
| 158 | if (Number.isNaN(start) || Number.isNaN(end) || end < start) { | ||
| 159 | return undefined; | ||
| 160 | } | ||
| 161 | |||
| 162 | return end - start; | ||
| 163 | } | ||
| 164 | |||
| 165 | function updateTurnFields(thread: Thread, event: ProviderEvent): Partial<Thread> { | ||
| 166 | if (event.method === "turn/started") { | ||
| 167 | return { | ||
| 168 | latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, | ||
| 169 | latestTurnStartedAt: event.createdAt, | ||
| 170 | latestTurnCompletedAt: undefined, | ||
| 171 | latestTurnDurationMs: undefined, | ||
| 172 | }; | ||
| 173 | } | ||
| 174 | |||
| 175 | if (event.method === "turn/completed") { | ||
| 176 | const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; | ||
| 177 | const startedAt = | ||
| 178 | completedTurnId && completedTurnId === thread.latestTurnId | ||
| 179 | ? thread.latestTurnStartedAt | ||
| 180 | : undefined; | ||
| 181 | const elapsed = | ||
| 182 | startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; | ||
| 183 | |||
| 184 | return { | ||
| 185 | latestTurnId: completedTurnId ?? thread.latestTurnId, | ||
| 186 | latestTurnCompletedAt: event.createdAt, | ||
| 187 | latestTurnDurationMs: elapsed, | ||
| 188 | }; | ||
| 189 | } | ||
| 190 | |||
| 191 | return {}; | ||
| 192 | } | ||
| 193 | |||
| 194 | // ── Reducer ────────────────────────────────────────────────────────── | ||
| 195 | |||
| 196 | export function reducer(state: AppState, action: Action): AppState { | ||
| 197 | switch (action.type) { | ||
| 198 | case "ADD_PROJECT": | ||
| 199 | if (state.projects.some((project) => project.cwd === action.project.cwd)) { | ||
| 200 | return state; | ||
| 201 | } | ||
| 202 | return { | ||
| 203 | ...state, | ||
| 204 | projects: [ | ||
| 205 | ...state.projects, | ||
| 206 | { | ||
| 207 | ...action.project, | ||
| 208 | model: resolveModelSlug(action.project.model), | ||
| 209 | }, | ||
| 210 | ], | ||
| 211 | }; | ||
| 212 | |||
| 213 | case "SYNC_PROJECTS": { | ||
| 214 | const previousByCwd = new Map( | ||
| 215 | state.projects.map((project) => [project.cwd, project] as const), | ||
| 216 | ); | ||
| 217 | const nextProjects = action.projects.map((project) => { | ||
| 218 | const previous = previousByCwd.get(project.cwd); | ||
| 219 | return { | ||
| 220 | ...project, | ||
| 221 | model: resolveModelSlug(previous?.model ?? project.model), | ||
| 222 | expanded: previous?.expanded ?? project.expanded, | ||
| 223 | }; | ||
| 224 | }); | ||
| 225 | const previousProjectById = new Map( | ||
| 226 | state.projects.map((project) => [project.id, project] as const), | ||
| 227 | ); | ||
| 228 | const nextProjectIdByCwd = new Map( | ||
| 229 | nextProjects.map((project) => [project.cwd, project.id] as const), | ||
| 230 | ); | ||
| 231 | const nextThreads = state.threads | ||
| 232 | .map((thread) => { | ||
| 233 | const previousProject = previousProjectById.get(thread.projectId); | ||
| 234 | if (!previousProject) return null; | ||
| 235 | const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); | ||
| 236 | if (!mappedProjectId) return null; | ||
| 237 | return { | ||
| 238 | ...thread, | ||
| 239 | projectId: mappedProjectId, | ||
| 240 | }; | ||
| 241 | }) | ||
| 242 | .filter((thread): thread is Thread => thread !== null); | ||
| 243 | const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId) | ||
| 244 | ? state.activeThreadId | ||
| 245 | : (nextThreads[0]?.id ?? null); | ||
| 246 | |||
| 247 | return { | ||
| 248 | ...state, | ||
| 249 | projects: nextProjects, | ||
| 250 | threads: nextThreads, | ||
| 251 | activeThreadId, | ||
| 252 | }; | ||
| 253 | } | ||
| 254 | |||
| 255 | case "TOGGLE_PROJECT": | ||
| 256 | return { | ||
| 257 | ...state, | ||
| 258 | projects: state.projects.map((p) => | ||
| 259 | p.id === action.projectId ? { ...p, expanded: !p.expanded } : p, | ||
| 260 | ), | ||
| 261 | }; | ||
| 262 | |||
| 263 | case "ADD_THREAD": | ||
| 264 | return { | ||
| 265 | ...state, | ||
| 266 | threads: [ | ||
| 267 | ...state.threads, | ||
| 268 | { | ||
| 269 | ...action.thread, | ||
| 270 | model: resolveModelSlug(action.thread.model), | ||
| 271 | }, | ||
| 272 | ], | ||
| 273 | activeThreadId: action.thread.id, | ||
| 274 | }; | ||
| 275 | |||
| 276 | case "SET_ACTIVE_THREAD": | ||
| 277 | return { ...state, activeThreadId: action.threadId }; | ||
| 278 | |||
| 279 | case "TOGGLE_DIFF": | ||
| 280 | return { ...state, diffOpen: !state.diffOpen }; | ||
| 281 | |||
| 282 | case "APPLY_EVENT": { | ||
| 283 | const { event, activeAssistantItemRef } = action; | ||
| 284 | const target = findThreadBySessionId(state.threads, event.sessionId); | ||
| 285 | if (!target) return state; | ||
| 286 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; | ||
| 287 | |||
| 288 | return { | ||
| 289 | ...state, | ||
| 290 | threads: updateThread(state.threads, target.id, (t) => ({ | ||
| 291 | ...t, | ||
| 292 | ...(() => { | ||
| 293 | const eventThreadId = getEventThreadId(event); | ||
| 294 | const shouldRebindIdentity = | ||
| 295 | event.method === "thread/started" && t.session?.status === "connecting"; | ||
| 296 | return { | ||
| 297 | codexThreadId: shouldRebindIdentity | ||
| 298 | ? (eventThreadId ?? t.codexThreadId) | ||
| 299 | : (t.codexThreadId ?? eventThreadId ?? null), | ||
| 300 | error: event.kind === "error" && event.message ? event.message : t.error, | ||
| 301 | }; | ||
| 302 | })(), | ||
| 303 | session: t.session ? evolveSession(t.session, event) : t.session, | ||
| 304 | messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), | ||
| 305 | events: [event, ...t.events], | ||
| 306 | ...updateTurnFields(t, event), | ||
| 307 | })), | ||
| 308 | }; | ||
| 309 | } | ||
| 310 | |||
| 311 | case "UPDATE_SESSION": | ||
| 312 | return { | ||
| 313 | ...state, | ||
| 314 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 315 | ...t, | ||
| 316 | session: action.session, | ||
| 317 | codexThreadId: action.session.threadId ?? t.codexThreadId, | ||
| 318 | events: [], | ||
| 319 | error: null, | ||
| 320 | latestTurnId: undefined, | ||
| 321 | latestTurnStartedAt: undefined, | ||
| 322 | latestTurnCompletedAt: undefined, | ||
| 323 | latestTurnDurationMs: undefined, | ||
| 324 | })), | ||
| 325 | }; | ||
| 326 | |||
| 327 | case "PUSH_USER_MESSAGE": | ||
| 328 | return { | ||
| 329 | ...state, | ||
| 330 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 331 | ...t, | ||
| 332 | messages: [ | ||
| 333 | ...t.messages, | ||
| 334 | { | ||
| 335 | id: action.id, | ||
| 336 | role: "user" as const, | ||
| 337 | text: action.text, | ||
| 338 | createdAt: new Date().toISOString(), | ||
| 339 | streaming: false, | ||
| 340 | }, | ||
| 341 | ], | ||
| 342 | })), | ||
| 343 | }; | ||
| 344 | |||
| 345 | case "SET_ERROR": | ||
| 346 | return { | ||
| 347 | ...state, | ||
| 348 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 349 | ...t, | ||
| 350 | error: action.error, | ||
| 351 | })), | ||
| 352 | }; | ||
| 353 | |||
| 354 | case "SET_THREAD_TITLE": | ||
| 355 | return { | ||
| 356 | ...state, | ||
| 357 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 358 | ...t, | ||
| 359 | title: action.title, | ||
| 360 | })), | ||
| 361 | }; | ||
| 362 | |||
| 363 | case "SET_THREAD_MODEL": | ||
| 364 | return { | ||
| 365 | ...state, | ||
| 366 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 367 | ...t, | ||
| 368 | model: resolveModelSlug(action.model), | ||
| 369 | })), | ||
| 370 | }; | ||
| 371 | |||
| 372 | case "SET_RUNTIME_MODE": | ||
| 373 | return { | ||
| 374 | ...state, | ||
| 375 | runtimeMode: action.mode, | ||
| 376 | }; | ||
| 377 | |||
| 378 | default: | ||
| 379 | return state; | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | // ── Context ────────────────────────────────────────────────────────── | ||
| 384 | |||
| 385 | const StoreContext = createContext<{ | ||
| 386 | state: AppState; | ||
| 387 | dispatch: Dispatch<Action>; | ||
| 388 | }>({ state: initialState, dispatch: () => {} }); | ||
| 389 | |||
| 390 | export function StoreProvider({ children }: { children: ReactNode }) { | ||
| 391 | const [state, dispatch] = useReducer(reducer, undefined, readPersistedState); | ||
| 392 | |||
| 393 | useEffect(() => { | ||
| 394 | persistState(state); | ||
| 395 | }, [state]); | ||
| 396 | |||
| 397 | return createElement(StoreContext.Provider, { value: { state, dispatch } }, children); | ||
| 398 | } | ||
| 399 | |||
| 400 | export function useStore() { | ||
| 401 | return useContext(StoreContext); | ||
| 402 | } | ||
apps/web/src/types.ts created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | ||
| 4 | export type RuntimeMode = "approval-required" | "full-access"; | ||
| 5 | export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | ||
| 6 | |||
| 7 | export interface ChatMessage { | ||
| 8 | id: string; | ||
| 9 | role: "user" | "assistant"; | ||
| 10 | text: string; | ||
| 11 | createdAt: string; | ||
| 12 | streaming: boolean; | ||
| 13 | } | ||
| 14 | |||
| 15 | export interface Project { | ||
| 16 | id: string; | ||
| 17 | name: string; | ||
| 18 | cwd: string; | ||
| 19 | model: string; | ||
| 20 | expanded: boolean; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface Thread { | ||
| 24 | id: string; | ||
| 25 | codexThreadId: string | null; | ||
| 26 | projectId: string; | ||
| 27 | title: string; | ||
| 28 | model: string; | ||
| 29 | session: ProviderSession | null; | ||
| 30 | messages: ChatMessage[]; | ||
| 31 | events: ProviderEvent[]; | ||
| 32 | error: string | null; | ||
| 33 | createdAt: string; | ||
| 34 | latestTurnId?: string | undefined; | ||
| 35 | latestTurnStartedAt?: string | undefined; | ||
| 36 | latestTurnCompletedAt?: string | undefined; | ||
| 37 | latestTurnDurationMs?: number | undefined; | ||
| 38 | } | ||
apps/web/src/vite-env.d.ts created+15| ... | @@ -0,0 +1,15 @@ | ||
| 1 | /// <reference types="vite/client" /> | ||
| 2 | |||
| 3 | import type { NativeApi } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | interface DesktopBridge { | ||
| 6 | getWsUrl: () => string | null; | ||
| 7 | pickFolder: () => Promise<string | null>; | ||
| 8 | } | ||
| 9 | |||
| 10 | declare global { | ||
| 11 | interface Window { | ||
| 12 | nativeApi?: NativeApi; | ||
| 13 | desktopBridge?: DesktopBridge; | ||
| 14 | } | ||
| 15 | } | ||
apps/web/src/wsNativeApi.ts created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { WsTransport } from "./wsTransport"; | ||
| 4 | |||
| 5 | let instance: { api: NativeApi; transport: WsTransport } | null = null; | ||
| 6 | const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); | ||
| 7 | let lastWelcome: WsWelcomePayload | null = null; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Subscribe to the server welcome message. If a welcome was already received | ||
| 11 | * before this call, the listener fires synchronously with the cached payload. | ||
| 12 | * This avoids the race between WebSocket connect and React effect registration. | ||
| 13 | */ | ||
| 14 | export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void { | ||
| 15 | welcomeListeners.add(listener); | ||
| 16 | |||
| 17 | // Replay cached welcome for late subscribers | ||
| 18 | if (lastWelcome) { | ||
| 19 | try { | ||
| 20 | listener(lastWelcome); | ||
| 21 | } catch { | ||
| 22 | // Swallow listener errors | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | return () => { | ||
| 27 | welcomeListeners.delete(listener); | ||
| 28 | }; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function createWsNativeApi(): NativeApi { | ||
| 32 | if (instance) return instance.api; | ||
| 33 | |||
| 34 | const transport = new WsTransport(); | ||
| 35 | |||
| 36 | // Listen for server welcome and forward to registered listeners. | ||
| 37 | // Also cache it so late subscribers (React effects) get it immediately. | ||
| 38 | transport.subscribe(WS_CHANNELS.serverWelcome, (data) => { | ||
| 39 | const payload = data as WsWelcomePayload; | ||
| 40 | lastWelcome = payload; | ||
| 41 | for (const listener of welcomeListeners) { | ||
| 42 | try { | ||
| 43 | listener(payload); | ||
| 44 | } catch { | ||
| 45 | // Swallow listener errors | ||
| 46 | } | ||
| 47 | } | ||
| 48 | }); | ||
| 49 | |||
| 50 | const api: NativeApi = { | ||
| 51 | todos: { | ||
| 52 | list: async () => [], | ||
| 53 | add: async () => [], | ||
| 54 | toggle: async () => [], | ||
| 55 | remove: async () => [], | ||
| 56 | }, | ||
| 57 | dialogs: { | ||
| 58 | pickFolder: async () => { | ||
| 59 | if (!window.desktopBridge) return null; | ||
| 60 | return window.desktopBridge.pickFolder(); | ||
| 61 | }, | ||
| 62 | }, | ||
| 63 | terminal: { | ||
| 64 | run: async () => ({ | ||
| 65 | stdout: "", | ||
| 66 | stderr: "Terminal not available in web mode", | ||
| 67 | code: 1, | ||
| 68 | signal: null, | ||
| 69 | timedOut: false, | ||
| 70 | }), | ||
| 71 | }, | ||
| 72 | agent: { | ||
| 73 | spawn: async () => "", | ||
| 74 | kill: async () => {}, | ||
| 75 | write: async () => {}, | ||
| 76 | onOutput: () => () => {}, | ||
| 77 | onExit: () => () => {}, | ||
| 78 | }, | ||
| 79 | providers: { | ||
| 80 | startSession: (input) => transport.request(WS_METHODS.providersStartSession, input), | ||
| 81 | sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input), | ||
| 82 | interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), | ||
| 83 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), | ||
| 84 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), | ||
| 85 | listSessions: () => transport.request(WS_METHODS.providersListSessions), | ||
| 86 | onEvent: (callback) => | ||
| 87 | transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), | ||
| 88 | }, | ||
| 89 | projects: { | ||
| 90 | list: () => transport.request(WS_METHODS.projectsList), | ||
| 91 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), | ||
| 92 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), | ||
| 93 | }, | ||
| 94 | shell: { | ||
| 95 | openInEditor: (cwd, editor) => | ||
| 96 | transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), | ||
| 97 | }, | ||
| 98 | }; | ||
| 99 | |||
| 100 | instance = { api, transport }; | ||
| 101 | return api; | ||
| 102 | } | ||
apps/web/src/wsTransport.ts created+197| ... | @@ -0,0 +1,197 @@ | ||
| 1 | import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | type PushListener = (data: unknown) => void; | ||
| 4 | |||
| 5 | interface PendingRequest { | ||
| 6 | resolve: (result: unknown) => void; | ||
| 7 | reject: (error: Error) => void; | ||
| 8 | timeout: ReturnType<typeof setTimeout>; | ||
| 9 | } | ||
| 10 | |||
| 11 | const REQUEST_TIMEOUT_MS = 60_000; | ||
| 12 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; | ||
| 13 | |||
| 14 | export class WsTransport { | ||
| 15 | private ws: WebSocket | null = null; | ||
| 16 | private nextId = 1; | ||
| 17 | private readonly pending = new Map<string, PendingRequest>(); | ||
| 18 | private readonly listeners = new Map<string, Set<PushListener>>(); | ||
| 19 | private reconnectAttempt = 0; | ||
| 20 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | ||
| 21 | private disposed = false; | ||
| 22 | private readonly url: string; | ||
| 23 | |||
| 24 | constructor(url?: string) { | ||
| 25 | const bridgeUrl = window.desktopBridge?.getWsUrl(); | ||
| 26 | // In dev mode, VITE_WS_URL points to the server's WebSocket endpoint. | ||
| 27 | // In production, the page is served by the WS server on the same host:port. | ||
| 28 | const envUrl = import.meta.env.VITE_WS_URL as string | undefined; | ||
| 29 | this.url = | ||
| 30 | url ?? | ||
| 31 | (bridgeUrl && bridgeUrl.length > 0 | ||
| 32 | ? bridgeUrl | ||
| 33 | : envUrl && envUrl.length > 0 | ||
| 34 | ? envUrl | ||
| 35 | : `ws://${window.location.hostname}:${window.location.port}`); | ||
| 36 | this.connect(); | ||
| 37 | } | ||
| 38 | |||
| 39 | async request<T = unknown>(method: string, params?: unknown): Promise<T> { | ||
| 40 | const id = String(this.nextId++); | ||
| 41 | const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) }; | ||
| 42 | |||
| 43 | return new Promise<T>((resolve, reject) => { | ||
| 44 | const timeout = setTimeout(() => { | ||
| 45 | this.pending.delete(id); | ||
| 46 | reject(new Error(`Request timed out: ${method}`)); | ||
| 47 | }, REQUEST_TIMEOUT_MS); | ||
| 48 | |||
| 49 | this.pending.set(id, { | ||
| 50 | resolve: resolve as (result: unknown) => void, | ||
| 51 | reject, | ||
| 52 | timeout, | ||
| 53 | }); | ||
| 54 | |||
| 55 | this.send(message); | ||
| 56 | }); | ||
| 57 | } | ||
| 58 | |||
| 59 | subscribe(channel: string, listener: PushListener): () => void { | ||
| 60 | let channelListeners = this.listeners.get(channel); | ||
| 61 | if (!channelListeners) { | ||
| 62 | channelListeners = new Set(); | ||
| 63 | this.listeners.set(channel, channelListeners); | ||
| 64 | } | ||
| 65 | channelListeners.add(listener); | ||
| 66 | |||
| 67 | return () => { | ||
| 68 | channelListeners!.delete(listener); | ||
| 69 | if (channelListeners!.size === 0) { | ||
| 70 | this.listeners.delete(channel); | ||
| 71 | } | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | dispose() { | ||
| 76 | this.disposed = true; | ||
| 77 | if (this.reconnectTimer !== null) { | ||
| 78 | clearTimeout(this.reconnectTimer); | ||
| 79 | this.reconnectTimer = null; | ||
| 80 | } | ||
| 81 | for (const pending of this.pending.values()) { | ||
| 82 | clearTimeout(pending.timeout); | ||
| 83 | pending.reject(new Error("Transport disposed")); | ||
| 84 | } | ||
| 85 | this.pending.clear(); | ||
| 86 | this.ws?.close(); | ||
| 87 | this.ws = null; | ||
| 88 | } | ||
| 89 | |||
| 90 | private connect() { | ||
| 91 | if (this.disposed) return; | ||
| 92 | |||
| 93 | const ws = new WebSocket(this.url); | ||
| 94 | |||
| 95 | ws.addEventListener("open", () => { | ||
| 96 | this.ws = ws; | ||
| 97 | this.reconnectAttempt = 0; | ||
| 98 | }); | ||
| 99 | |||
| 100 | ws.addEventListener("message", (event) => { | ||
| 101 | this.handleMessage(event.data); | ||
| 102 | }); | ||
| 103 | |||
| 104 | ws.addEventListener("close", () => { | ||
| 105 | this.ws = null; | ||
| 106 | this.scheduleReconnect(); | ||
| 107 | }); | ||
| 108 | |||
| 109 | ws.addEventListener("error", () => { | ||
| 110 | // close event will fire after error | ||
| 111 | }); | ||
| 112 | } | ||
| 113 | |||
| 114 | private handleMessage(raw: unknown) { | ||
| 115 | let parsed: unknown; | ||
| 116 | try { | ||
| 117 | parsed = JSON.parse(String(raw)); | ||
| 118 | } catch { | ||
| 119 | return; | ||
| 120 | } | ||
| 121 | |||
| 122 | if (!parsed || typeof parsed !== "object") return; | ||
| 123 | |||
| 124 | const message = parsed as Record<string, unknown>; | ||
| 125 | |||
| 126 | // Push event | ||
| 127 | if (message.type === "push") { | ||
| 128 | const push = message as unknown as WsPush; | ||
| 129 | const channelListeners = this.listeners.get(push.channel); | ||
| 130 | if (channelListeners) { | ||
| 131 | for (const listener of channelListeners) { | ||
| 132 | try { | ||
| 133 | listener(push.data); | ||
| 134 | } catch { | ||
| 135 | // Swallow listener errors | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | return; | ||
| 140 | } | ||
| 141 | |||
| 142 | // Response to a request | ||
| 143 | if (typeof message.id === "string") { | ||
| 144 | const response = message as unknown as WsResponse; | ||
| 145 | const pending = this.pending.get(response.id); | ||
| 146 | if (!pending) return; | ||
| 147 | |||
| 148 | clearTimeout(pending.timeout); | ||
| 149 | this.pending.delete(response.id); | ||
| 150 | |||
| 151 | if (response.error) { | ||
| 152 | pending.reject(new Error(response.error.message)); | ||
| 153 | } else { | ||
| 154 | pending.resolve(response.result); | ||
| 155 | } | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | private send(message: WsRequest) { | ||
| 160 | if (this.ws?.readyState === WebSocket.OPEN) { | ||
| 161 | this.ws.send(JSON.stringify(message)); | ||
| 162 | return; | ||
| 163 | } | ||
| 164 | |||
| 165 | // If not connected, wait for connection | ||
| 166 | const waitForOpen = () => { | ||
| 167 | const check = setInterval(() => { | ||
| 168 | if (this.disposed) { | ||
| 169 | clearInterval(check); | ||
| 170 | return; | ||
| 171 | } | ||
| 172 | if (this.ws?.readyState === WebSocket.OPEN) { | ||
| 173 | clearInterval(check); | ||
| 174 | this.ws.send(JSON.stringify(message)); | ||
| 175 | } | ||
| 176 | }, 50); | ||
| 177 | |||
| 178 | // Give up after timeout (the pending request will time out on its own) | ||
| 179 | setTimeout(() => clearInterval(check), REQUEST_TIMEOUT_MS); | ||
| 180 | }; | ||
| 181 | waitForOpen(); | ||
| 182 | } | ||
| 183 | |||
| 184 | private scheduleReconnect() { | ||
| 185 | if (this.disposed) return; | ||
| 186 | |||
| 187 | const delay = | ||
| 188 | RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ?? | ||
| 189 | RECONNECT_DELAYS_MS[0]!; | ||
| 190 | |||
| 191 | this.reconnectAttempt++; | ||
| 192 | this.reconnectTimer = setTimeout(() => { | ||
| 193 | this.reconnectTimer = null; | ||
| 194 | this.connect(); | ||
| 195 | }, delay); | ||
| 196 | } | ||
| 197 | } | ||
apps/web/tsconfig.json created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | { | ||
| 2 | "extends": "../../tsconfig.base.json", | ||
| 3 | "compilerOptions": { | ||
| 4 | "composite": true, | ||
| 5 | "jsx": "react-jsx", | ||
| 6 | "lib": ["ES2023", "DOM", "DOM.Iterable"], | ||
| 7 | "types": ["vite/client"] | ||
| 8 | }, | ||
| 9 | "include": ["src", "vite.config.ts"] | ||
| 10 | } | ||
apps/web/vite.config.ts created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import tailwindcss from "@tailwindcss/vite"; | ||
| 2 | import react from "@vitejs/plugin-react"; | ||
| 3 | import { defineConfig } from "vite"; | ||
| 4 | |||
| 5 | export default defineConfig({ | ||
| 6 | plugins: [ | ||
| 7 | react({ | ||
| 8 | babel: { | ||
| 9 | plugins: [["babel-plugin-react-compiler", { target: "19" }]], | ||
| 10 | }, | ||
| 11 | }), | ||
| 12 | tailwindcss(), | ||
| 13 | ], | ||
| 14 | define: { | ||
| 15 | // In dev mode, tell the web app where the WebSocket server lives | ||
| 16 | "import.meta.env.VITE_WS_URL": JSON.stringify( | ||
| 17 | process.env.VITE_WS_URL ?? "", | ||
| 18 | ), | ||
| 19 | }, | ||
| 20 | server: { | ||
| 21 | port: Number(process.env.PORT ?? 5173), | ||
| 22 | strictPort: true, | ||
| 23 | }, | ||
| 24 | build: { | ||
| 25 | outDir: "dist", | ||
| 26 | emptyOutDir: true, | ||
| 27 | }, | ||
| 28 | }); | ||
bun.lock+23-23| ... | @@ -28,8 +28,28 @@ | ... | @@ -28,8 +28,28 @@ |
| 28 | "wait-on": "^8.0.2", | 28 | "wait-on": "^8.0.2", |
| 29 | }, | 29 | }, |
| 30 | }, | 30 | }, |
| 31 | "apps/renderer": { | 31 | "apps/server": { |
| 32 | "name": "@t3tools/renderer", | 32 | "name": "t3", |
| 33 | "version": "0.1.0", | ||
| 34 | "bin": { | ||
| 35 | "t3": "./dist/index.js", | ||
| 36 | }, | ||
| 37 | "dependencies": { | ||
| 38 | "open": "^10.1.0", | ||
| 39 | "ws": "^8.18.0", | ||
| 40 | }, | ||
| 41 | "devDependencies": { | ||
| 42 | "@t3tools/contracts": "workspace:*", | ||
| 43 | "@t3tools/web": "workspace:*", | ||
| 44 | "@types/node": "^22.10.2", | ||
| 45 | "@types/ws": "^8.5.13", | ||
| 46 | "tsup": "^8.3.5", | ||
| 47 | "tsx": "^4.19.0", | ||
| 48 | "typescript": "^5.7.3", | ||
| 49 | }, | ||
| 50 | }, | ||
| 51 | "apps/web": { | ||
| 52 | "name": "@t3tools/web", | ||
| 33 | "version": "0.0.0", | 53 | "version": "0.0.0", |
| 34 | "dependencies": { | 54 | "dependencies": { |
| 35 | "@t3tools/contracts": "workspace:*", | 55 | "@t3tools/contracts": "workspace:*", |
| ... | @@ -53,26 +73,6 @@ | ... | @@ -53,26 +73,6 @@ |
| 53 | "vite": "^6.0.5", | 73 | "vite": "^6.0.5", |
| 54 | }, | 74 | }, |
| 55 | }, | 75 | }, |
| 56 | "apps/server": { | ||
| 57 | "name": "t3", | ||
| 58 | "version": "0.1.0", | ||
| 59 | "bin": { | ||
| 60 | "t3": "./dist/index.js", | ||
| 61 | }, | ||
| 62 | "dependencies": { | ||
| 63 | "open": "^10.1.0", | ||
| 64 | "ws": "^8.18.0", | ||
| 65 | }, | ||
| 66 | "devDependencies": { | ||
| 67 | "@t3tools/contracts": "workspace:*", | ||
| 68 | "@t3tools/renderer": "workspace:*", | ||
| 69 | "@types/node": "^22.10.2", | ||
| 70 | "@types/ws": "^8.5.13", | ||
| 71 | "tsup": "^8.3.5", | ||
| 72 | "tsx": "^4.19.0", | ||
| 73 | "typescript": "^5.7.3", | ||
| 74 | }, | ||
| 75 | }, | ||
| 76 | "packages/contracts": { | 76 | "packages/contracts": { |
| 77 | "name": "@t3tools/contracts", | 77 | "name": "@t3tools/contracts", |
| 78 | "version": "0.0.0", | 78 | "version": "0.0.0", |
| ... | @@ -294,7 +294,7 @@ | ... | @@ -294,7 +294,7 @@ |
| 294 | 294 | ||
| 295 | "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], | 295 | "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], |
| 296 | 296 | ||
| 297 | "@t3tools/renderer": ["@t3tools/renderer@workspace:apps/renderer"], | 297 | "@t3tools/web": ["@t3tools/web@workspace:apps/web"], |
| 298 | 298 | ||
| 299 | "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], | 299 | "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], |
| 300 | 300 |
package.json+3-3| ... | @@ -6,14 +6,14 @@ | ... | @@ -6,14 +6,14 @@ |
| 6 | "packages/*" | 6 | "packages/*" |
| 7 | ], | 7 | ], |
| 8 | "scripts": { | 8 | "scripts": { |
| 9 | "dev": "T3CODE_LOG_WS_EVENTS=1 VITE_WS_URL=ws://localhost:3773 turbo run dev --ui=tui --filter=@t3tools/contracts --filter=@t3tools/renderer --filter=t3 --parallel", | 9 | "dev": "T3CODE_LOG_WS_EVENTS=1 VITE_WS_URL=ws://localhost:3773 turbo run dev --ui=tui --filter=@t3tools/contracts --filter=@t3tools/web --filter=t3 --parallel", |
| 10 | "dev:server": "bun run build:contracts && bun run --cwd apps/server dev", | 10 | "dev:server": "bun run build:contracts && bun run --cwd apps/server dev", |
| 11 | "dev:web": "VITE_WS_URL=ws://localhost:3773 bun run --cwd apps/renderer dev", | 11 | "dev:web": "VITE_WS_URL=ws://localhost:3773 bun run --cwd apps/web dev", |
| 12 | "dev:desktop": "bun run --cwd apps/desktop dev", | 12 | "dev:desktop": "bun run --cwd apps/desktop dev", |
| 13 | "start": "node apps/server/dist/index.js", | 13 | "start": "node apps/server/dist/index.js", |
| 14 | "start:desktop": "bun run --cwd apps/desktop start", | 14 | "start:desktop": "bun run --cwd apps/desktop start", |
| 15 | "build": "turbo run build", | 15 | "build": "turbo run build", |
| 16 | "build:desktop": "bun run build:contracts && bun run --cwd apps/renderer build && bun run --cwd apps/server build && bun run --cwd apps/desktop build", | 16 | "build:desktop": "bun run build:contracts && bun run --cwd apps/web build && bun run --cwd apps/server build && bun run --cwd apps/desktop build", |
| 17 | "typecheck": "turbo run typecheck", | 17 | "typecheck": "turbo run typecheck", |
| 18 | "lint": "oxlint --report-unused-disable-directives", | 18 | "lint": "oxlint --report-unused-disable-directives", |
| 19 | "test": "turbo run test", | 19 | "test": "turbo run test", |