authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 22:18:15-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 22:19:49-08:00
log5120f7fec702a4bfd09ae64048ee99708247993a
tree274c10695e1c93816f281800f8841a5703ecbca4
parentb5bab3eb134fd7555914fe57031a064b08beb195
signature Commit is signed but in an unrecognized format.

renames


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
1414
1515## Package Roles
1616- `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.
1818- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.
1919
2020## Codex App Server (Important)
......@@ -24,7 +24,7 @@ How we use it in this codebase:
2424- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.
2525- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.
2626- 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`.
2828
2929Docs:
3030- 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
2929
3030## Workspace layout
3131
32- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built renderer, 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.
34- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared renderer.
32- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start.
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 web app.
3535- `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.
3636
3737## Codex prerequisites
......@@ -61,9 +61,9 @@ npx t3
6161
6262- `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner.
6363- `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.
65- `bun run start` — Runs the production server (serves built renderer as static files).
66- `bun run build` — Builds contracts, renderer, and server through Turbo.
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 web app as static files).
66- `bun run build` — Builds contracts, web app, and server through Turbo.
6767- `bun run typecheck` — Strict TypeScript checks for all packages.
6868- `bun run test` — Runs workspace tests.
6969
......@@ -76,7 +76,7 @@ T3 Code has a global runtime mode switch in the chat toolbar:
7676
7777## Provider architecture
7878
79The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
79The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
8080
8181- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }`
8282- **Push events**: `{ type: "push", channel, data }` for streaming provider events
apps/desktop/package.json+1-1
......@@ -4,7 +4,7 @@
44 "private": true,
55 "main": "dist-electron/main.js",
66 "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\"",
88 "dev:bundle": "tsup --watch",
99 "dev:electron": "bun run scripts/dev-electron.mjs",
1010 "build": "tsup",
apps/desktop/scripts/smoke-test.mjs+1-1
......@@ -8,7 +8,7 @@ const rootDir = resolve(__dirname, "../../..");
88const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
99const mainJs = resolve(desktopDir, "dist-electron/main.js");
1010
11console.log("Building contracts + renderer + server + desktop...");
11console.log("Building contracts + web + server + desktop...");
1212execSync("bun run build", { cwd: rootDir, stdio: "inherit" });
1313
1414console.log("\nLaunching Electron smoke test...");
apps/desktop/src/main.ts+4-4
......@@ -14,7 +14,7 @@ fixPath();
1414const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
1515const ROOT_DIR = path.resolve(__dirname, "../../..");
1616const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js");
17const RENDERER_ENTRY = path.join(ROOT_DIR, "apps/renderer/dist/index.html");
17const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
1818const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
1919const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
2020
......@@ -167,10 +167,10 @@ function createWindow(): BrowserWindow {
167167 if (isDevelopment) {
168168 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
169169 } else {
170 if (!fs.existsSync(RENDERER_ENTRY)) {
171 throw new Error(`Renderer bundle missing at ${RENDERER_ENTRY}`);
170 if (!fs.existsSync(WEB_ENTRY)) {
171 throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
172172 }
173 void window.loadFile(RENDERER_ENTRY);
173 void window.loadFile(WEB_ENTRY);
174174 }
175175
176176 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 @@
1import { useEffect, useMemo, useRef } from "react";
2
3import ChatView from "./components/ChatView";
4import DiffPanel from "./components/DiffPanel";
5import Sidebar from "./components/Sidebar";
6import { isElectron } from "./env";
7import { DEFAULT_MODEL } from "./model-logic";
8import { readNativeApi } from "./session-logic";
9import { StoreProvider, useStore } from "./store";
10import { onServerWelcome } from "./wsNativeApi";
11
12function 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
31function 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
93function 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
143function 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
169export 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 @@
1import type { Components } from "react-markdown";
2import ReactMarkdown from "react-markdown";
3import rehypeHighlight from "rehype-highlight";
4import remarkGfm from "remark-gfm";
5
6interface ChatMarkdownProps {
7 text: string;
8}
9
10const markdownComponents: Components = {
11 a({ node: _node, ...props }) {
12 return <a {...props} target="_blank" rel="noreferrer" />;
13 },
14};
15
16export 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 @@
1import {
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 type ProviderApprovalDecision,
4 type ProviderEvent,
5} from "@t3tools/contracts";
6import {
7 type FormEvent,
8 Fragment,
9 type KeyboardEvent,
10 useEffect,
11 useMemo,
12 useRef,
13 useState,
14} from "react";
15
16import { EDITORS, type EditorId } from "@t3tools/contracts";
17import { isElectron } from "../env";
18import { buildBootstrapInput } from "../historyBootstrap";
19import {
20 DEFAULT_MODEL,
21 DEFAULT_REASONING,
22 MODEL_OPTIONS,
23 REASONING_OPTIONS,
24 resolveModelSlug,
25} from "../model-logic";
26import {
27 derivePhase,
28 deriveTimelineEntries,
29 deriveWorkLogEntries,
30 formatDuration,
31 formatElapsed,
32 formatTimestamp,
33 readNativeApi,
34} from "../session-logic";
35import { useStore } from "../store";
36import ChatMarkdown from "./ChatMarkdown";
37
38function formatMessageMeta(createdAt: string, duration: string | null): string {
39 if (!duration) return formatTimestamp(createdAt);
40 return `${formatTimestamp(createdAt)} • ${duration}`;
41}
42
43const FILE_MANAGER_LABEL = navigator.platform.includes("Mac")
44 ? "Finder"
45 : navigator.platform.includes("Win")
46 ? "Explorer"
47 : "Files";
48
49function editorLabel(editor: (typeof EDITORS)[number]): string {
50 return editor.command ? editor.label : FILE_MANAGER_LABEL;
51}
52
53const LAST_EDITOR_KEY = "t3code:last-editor";
54const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
55
56function 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
63interface PendingApprovalCard {
64 requestId: string;
65 requestKind: "command" | "file-change";
66 createdAt: string;
67 detail?: string;
68}
69
70type SessionContinuityState = "resumed" | "new" | "fallback_new";
71
72interface EnsuredSessionInfo {
73 sessionId: string;
74 resolvedThreadId: string | null;
75 continuityState: SessionContinuityState;
76}
77
78function asRecord(value: unknown): Record<string, unknown> | undefined {
79 if (!value || typeof value !== "object") return undefined;
80 return value as Record<string, unknown>;
81}
82
83function asString(value: unknown): string | undefined {
84 return typeof value === "string" ? value : undefined;
85}
86
87function 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
94function 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
135export 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 @@
1import { isElectron } from "../env";
2import { useStore } from "../store";
3
4export 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 @@
1import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";
3import { isElectron } from "../env";
4import { useTheme } from "../hooks/useTheme";
5import { DEFAULT_MODEL } from "../model-logic";
6import { readNativeApi } from "../session-logic";
7import { useStore } from "../store";
8import type { Project } from "../types";
9
10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
11function 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
21function 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
28function 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
36export 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 */
6export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { buildBootstrapInput } from "./historyBootstrap";
4
5describe("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 @@
1import type { ChatMessage } from "./types";
2
3export interface BootstrapInputResult {
4 text: string;
5 includedCount: number;
6 omittedCount: number;
7 truncated: boolean;
8}
9
10const BOOTSTRAP_PREAMBLE =
11 "Continue this conversation using the transcript context below. The final section is the latest user request to answer now.";
12const TRANSCRIPT_HEADER = "Transcript context:";
13const LATEST_PROMPT_HEADER = "Latest user request (answer this now):";
14const OMITTED_SUMMARY = (count: number) =>
15 `[${count} earlier message(s) omitted to stay within input limits.]`;
16
17function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" {
18 return message.role === "assistant" ? "ASSISTANT" : "USER";
19}
20
21function buildMessageBlock(message: ChatMessage): string {
22 return `${messageRoleLabel(message)}:\n${message.text}`;
23}
24
25function 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
34export 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 @@
1import { useCallback, useEffect, useSyncExternalStore } from "react";
2
3type Theme = "light" | "dark" | "system";
4
5const STORAGE_KEY = "t3code:theme";
6const MEDIA_QUERY = "(prefers-color-scheme: dark)";
7
8let listeners: Array<() => void> = [];
9function emitChange() {
10 for (const listener of listeners) listener();
11}
12
13function getSystemDark(): boolean {
14 return window.matchMedia(MEDIA_QUERY).matches;
15}
16
17function getStored(): Theme {
18 const raw = localStorage.getItem(STORAGE_KEY);
19 if (raw === "light" || raw === "dark" || raw === "system") return raw;
20 return "system";
21}
22
23function 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
39applyTheme(getStored());
40
41function getSnapshot(): Theme {
42 return getStored();
43}
44
45function 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
72export 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
178body {
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
186body::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
197pre,
198code,
199textarea,
200input {
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 */
244label:has(> select#reasoning-effort) {
245 position: relative;
246}
247label: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 @@
1import React from "react";
2import ReactDOM from "react-dom/client";
3import "highlight.js/styles/github-dark.css";
4
5import App from "./App";
6import "./index.css";
7
8ReactDOM.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 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";
3
4describe("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
23describe("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 @@
1export {
2 DEFAULT_MODEL,
3 MODEL_OPTIONS,
4 normalizeModelSlug,
5 resolveModelSlug,
6} from "@t3tools/contracts";
7
8export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
9export const DEFAULT_REASONING = "high";
apps/renderer/src/persistenceSchema.test.ts deleted-172
......@@ -1,172 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_MODEL } from "./model-logic";
4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import type { Thread } from "./types";
6
7describe("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
116describe("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 @@
1import { z } from "zod";
2
3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import {
5 DEFAULT_RUNTIME_MODE,
6 type Project,
7 type RuntimeMode,
8 type Thread,
9} from "./types";
10
11const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
12
13const 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
21const 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
29const 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
39const persistedStateBodySchema = z.object({
40 projects: z.array(persistedProjectSchema),
41 threads: z.array(persistedThreadSchema),
42 activeThreadId: z.string().min(1).nullable(),
43});
44
45const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
46
47export const persistedStateV1Schema = persistedStateBodySchema.extend({
48 version: z.literal(1).optional(),
49});
50
51export const persistedStateV2Schema = persistedStateBodySchema.extend({
52 version: z.literal(2).optional(),
53});
54
55export const persistedStateV3Schema = persistedStateBodySchema.extend({
56 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
57 version: z.literal(3).optional(),
58});
59
60export const persistedStateV4Schema = persistedStateBodySchema.extend({
61 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
62 version: z.literal(4).optional(),
63});
64
65const persistedStateSchema = z.union([
66 persistedStateV4Schema,
67 persistedStateV3Schema,
68 persistedStateV2Schema,
69 persistedStateV1Schema,
70]);
71
72export interface PersistedStoreSnapshot {
73 projects: Project[];
74 threads: Thread[];
75 activeThreadId: string | null;
76 runtimeMode: RuntimeMode;
77}
78
79function 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
87function 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
97function 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
118export 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
159export 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 @@
1import { describe, expect, it } from "vitest";
2
3import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
4import {
5 type WorkLogEntry,
6 applyEventToMessages,
7 deriveTimelineEntries,
8 deriveWorkLogEntries,
9 evolveSession,
10} from "./session-logic";
11import type { ChatMessage } from "./types";
12
13function 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
25function 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
36describe("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
104describe("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
395describe("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
504describe("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 @@
1import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
2import type { ChatMessage, SessionPhase } from "./types";
3import { createWsNativeApi } from "./wsNativeApi";
4
5export 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
14let cachedApi: NativeApi | undefined;
15
16export 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
31export 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
36export function asString(value: unknown): string | undefined {
37 return typeof value === "string" ? value : undefined;
38}
39
40export 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
48export 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
61export 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
73export interface WorkLogEntry {
74 id: string;
75 createdAt: string;
76 label: string;
77 detail?: string;
78 tone: "thinking" | "tool" | "info" | "error";
79}
80
81export 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
95function 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
101function 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
111function 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
123function shouldShowItemLifecycle(type: string): boolean {
124 return (
125 type.includes("tool") ||
126 type.includes("command") ||
127 type.includes("file change")
128 );
129}
130
131function shouldDropMethod(method: string): boolean {
132 return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method);
133}
134
135function 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
151interface 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
161function 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
185function 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
218function 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
257function 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
291function 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
305function 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
311export 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
403function toTimestamp(isoDate: string): number {
404 const parsed = Date.parse(isoDate);
405 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
406}
407
408export 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
472export 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
479export 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
536export 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 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { type AppState, reducer } from "./store";
5import type { Thread } from "./types";
6
7function 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
18function 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
30function 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
46function 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
64describe("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 @@
1import {
2 type Dispatch,
3 type ReactNode,
4 createContext,
5 createElement,
6 useContext,
7 useEffect,
8 useReducer,
9} from "react";
10
11import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
15import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
16
17// ── Actions ──────────────────────────────────────────────────────────
18
19type 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
40export interface AppState {
41 projects: Project[];
42 threads: Thread[];
43 activeThreadId: string | null;
44 runtimeMode: RuntimeMode;
45 diffOpen: boolean;
46}
47
48const PERSISTED_STATE_KEY = "t3code:renderer-state:v4";
49const 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
57const initialState: AppState = {
58 projects: [],
59 threads: [],
60 activeThreadId: null,
61 runtimeMode: DEFAULT_RUNTIME_MODE,
62 diffOpen: false,
63};
64
65// ── Helpers ──────────────────────────────────────────────────────────
66
67function 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
90function 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
103function 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
111function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
112 return threads.find((t) => t.session?.sessionId === sessionId);
113}
114
115function 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
122function 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
136function 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
155function 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
165function 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
196export 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
385const StoreContext = createContext<{
386 state: AppState;
387 dispatch: Dispatch<Action>;
388}>({ state: initialState, dispatch: () => {} });
389
390export 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
400export function useStore() {
401 return useContext(StoreContext);
402}
apps/renderer/src/types.ts deleted-38
......@@ -1,38 +0,0 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
2
3export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
4export type RuntimeMode = "approval-required" | "full-access";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6
7export interface ChatMessage {
8 id: string;
9 role: "user" | "assistant";
10 text: string;
11 createdAt: string;
12 streaming: boolean;
13}
14
15export interface Project {
16 id: string;
17 name: string;
18 cwd: string;
19 model: string;
20 expanded: boolean;
21}
22
23export 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
3import type { NativeApi } from "@t3tools/contracts";
4
5interface DesktopBridge {
6 getWsUrl: () => string | null;
7 pickFolder: () => Promise<string | null>;
8}
9
10declare global {
11 interface Window {
12 nativeApi?: NativeApi;
13 desktopBridge?: DesktopBridge;
14 }
15}
apps/renderer/src/wsNativeApi.ts deleted-102
......@@ -1,102 +0,0 @@
1import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
2
3import { WsTransport } from "./wsTransport";
4
5let instance: { api: NativeApi; transport: WsTransport } | null = null;
6const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
7let 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 */
14export 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
31export 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 @@
1import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts";
2
3type PushListener = (data: unknown) => void;
4
5interface PendingRequest {
6 resolve: (result: unknown) => void;
7 reject: (error: Error) => void;
8 timeout: ReturnType<typeof setTimeout>;
9}
10
11const REQUEST_TIMEOUT_MS = 60_000;
12const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
13
14export 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 @@
1import tailwindcss from "@tailwindcss/vite";
2import react from "@vitejs/plugin-react";
3import { defineConfig } from "vite";
4
5export 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 @@
2424 },
2525 "devDependencies": {
2626 "@t3tools/contracts": "workspace:*",
27 "@t3tools/renderer": "workspace:*",
27 "@t3tools/web": "workspace:*",
2828 "@types/node": "^22.10.2",
2929 "@types/ws": "^8.5.13",
3030 "tsup": "^8.3.5",
apps/server/scripts/bundle-client.mjs+6-6
......@@ -1,5 +1,5 @@
11/**
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
33 * includes the web UI. This runs as a post-build step.
44 */
55import fs from "node:fs";
......@@ -7,15 +7,15 @@ import path from "node:path";
77import { fileURLToPath } from "node:url";
88
99const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const rendererDist = path.resolve(__dirname, "../../renderer/dist");
10const webDist = path.resolve(__dirname, "../../web/dist");
1111const target = path.resolve(__dirname, "../dist/client");
1212
13if (!fs.existsSync(rendererDist)) {
13if (!fs.existsSync(webDist)) {
1414 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.",
1616 );
1717 process.exit(0);
1818}
1919
20fs.cpSync(rendererDist, target, { recursive: true });
21console.log("✓ Bundled renderer into dist/client");
20fs.cpSync(webDist, target, { recursive: true });
21console.log("✓ Bundled web app into dist/client");
apps/server/src/index.ts+4-4
......@@ -84,8 +84,8 @@ function resolveStaticDir(): string | undefined {
8484 // Not bundled — check monorepo layout
8585 }
8686
87 // Monorepo layout: apps/server/dist/index.js → apps/renderer/dist/
88 const monorepoClient = path.resolve(__dirname, "../../renderer/dist");
87 // Monorepo layout: apps/server/dist/index.js → apps/web/dist/
88 const monorepoClient = path.resolve(__dirname, "../../web/dist");
8989 try {
9090 const stat = fs.statSync(path.join(monorepoClient, "index.html"));
9191 if (stat.isFile()) return monorepoClient;
......@@ -109,8 +109,8 @@ async function main() {
109109 const staticDir = devUrl ? undefined : resolveStaticDir();
110110
111111 if (!devUrl && !staticDir) {
112 logger.warn("renderer 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.",
112 logger.warn("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {
113 hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
114114 });
115115 }
116116
apps/server/src/wsServer.ts+1-1
......@@ -109,7 +109,7 @@ export function createServer(options: ServerOptions) {
109109 return;
110110 }
111111
112 // Serve static files from the renderer build
112 // Serve static files from the web app build
113113 if (!staticDir) {
114114 res.writeHead(503, { "Content-Type": "text/plain" });
115115 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 @@
1import { useEffect, useMemo, useRef } from "react";
2
3import ChatView from "./components/ChatView";
4import DiffPanel from "./components/DiffPanel";
5import Sidebar from "./components/Sidebar";
6import { isElectron } from "./env";
7import { DEFAULT_MODEL } from "./model-logic";
8import { readNativeApi } from "./session-logic";
9import { StoreProvider, useStore } from "./store";
10import { onServerWelcome } from "./wsNativeApi";
11
12function 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
31function 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
93function 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
143function 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
169export 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 @@
1import type { Components } from "react-markdown";
2import ReactMarkdown from "react-markdown";
3import rehypeHighlight from "rehype-highlight";
4import remarkGfm from "remark-gfm";
5
6interface ChatMarkdownProps {
7 text: string;
8}
9
10const markdownComponents: Components = {
11 a({ node: _node, ...props }) {
12 return <a {...props} target="_blank" rel="noreferrer" />;
13 },
14};
15
16export 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 @@
1import {
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 type ProviderApprovalDecision,
4 type ProviderEvent,
5} from "@t3tools/contracts";
6import {
7 type FormEvent,
8 Fragment,
9 type KeyboardEvent,
10 useEffect,
11 useMemo,
12 useRef,
13 useState,
14} from "react";
15
16import { EDITORS, type EditorId } from "@t3tools/contracts";
17import { isElectron } from "../env";
18import { buildBootstrapInput } from "../historyBootstrap";
19import {
20 DEFAULT_MODEL,
21 DEFAULT_REASONING,
22 MODEL_OPTIONS,
23 REASONING_OPTIONS,
24 resolveModelSlug,
25} from "../model-logic";
26import {
27 derivePhase,
28 deriveTimelineEntries,
29 deriveWorkLogEntries,
30 formatDuration,
31 formatElapsed,
32 formatTimestamp,
33 readNativeApi,
34} from "../session-logic";
35import { useStore } from "../store";
36import ChatMarkdown from "./ChatMarkdown";
37
38function formatMessageMeta(createdAt: string, duration: string | null): string {
39 if (!duration) return formatTimestamp(createdAt);
40 return `${formatTimestamp(createdAt)} • ${duration}`;
41}
42
43const FILE_MANAGER_LABEL = navigator.platform.includes("Mac")
44 ? "Finder"
45 : navigator.platform.includes("Win")
46 ? "Explorer"
47 : "Files";
48
49function editorLabel(editor: (typeof EDITORS)[number]): string {
50 return editor.command ? editor.label : FILE_MANAGER_LABEL;
51}
52
53const LAST_EDITOR_KEY = "t3code:last-editor";
54const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
55
56function 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
63interface PendingApprovalCard {
64 requestId: string;
65 requestKind: "command" | "file-change";
66 createdAt: string;
67 detail?: string;
68}
69
70type SessionContinuityState = "resumed" | "new" | "fallback_new";
71
72interface EnsuredSessionInfo {
73 sessionId: string;
74 resolvedThreadId: string | null;
75 continuityState: SessionContinuityState;
76}
77
78function asRecord(value: unknown): Record<string, unknown> | undefined {
79 if (!value || typeof value !== "object") return undefined;
80 return value as Record<string, unknown>;
81}
82
83function asString(value: unknown): string | undefined {
84 return typeof value === "string" ? value : undefined;
85}
86
87function 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
94function 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
135export 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 @@
1import { isElectron } from "../env";
2import { useStore } from "../store";
3
4export 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 @@
1import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";
3import { isElectron } from "../env";
4import { useTheme } from "../hooks/useTheme";
5import { DEFAULT_MODEL } from "../model-logic";
6import { readNativeApi } from "../session-logic";
7import { useStore } from "../store";
8import type { Project } from "../types";
9
10const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
11function 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
21function 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
28function 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
36export 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 */
6export 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 @@
1import { describe, expect, it } from "vitest";
2
3import { buildBootstrapInput } from "./historyBootstrap";
4
5describe("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 @@
1import type { ChatMessage } from "./types";
2
3export interface BootstrapInputResult {
4 text: string;
5 includedCount: number;
6 omittedCount: number;
7 truncated: boolean;
8}
9
10const BOOTSTRAP_PREAMBLE =
11 "Continue this conversation using the transcript context below. The final section is the latest user request to answer now.";
12const TRANSCRIPT_HEADER = "Transcript context:";
13const LATEST_PROMPT_HEADER = "Latest user request (answer this now):";
14const OMITTED_SUMMARY = (count: number) =>
15 `[${count} earlier message(s) omitted to stay within input limits.]`;
16
17function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" {
18 return message.role === "assistant" ? "ASSISTANT" : "USER";
19}
20
21function buildMessageBlock(message: ChatMessage): string {
22 return `${messageRoleLabel(message)}:\n${message.text}`;
23}
24
25function 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
34export 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 @@
1import { useCallback, useEffect, useSyncExternalStore } from "react";
2
3type Theme = "light" | "dark" | "system";
4
5const STORAGE_KEY = "t3code:theme";
6const MEDIA_QUERY = "(prefers-color-scheme: dark)";
7
8let listeners: Array<() => void> = [];
9function emitChange() {
10 for (const listener of listeners) listener();
11}
12
13function getSystemDark(): boolean {
14 return window.matchMedia(MEDIA_QUERY).matches;
15}
16
17function getStored(): Theme {
18 const raw = localStorage.getItem(STORAGE_KEY);
19 if (raw === "light" || raw === "dark" || raw === "system") return raw;
20 return "system";
21}
22
23function 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
39applyTheme(getStored());
40
41function getSnapshot(): Theme {
42 return getStored();
43}
44
45function 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
72export 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
178body {
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
186body::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
197pre,
198code,
199textarea,
200input {
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 */
244label:has(> select#reasoning-effort) {
245 position: relative;
246}
247label: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 @@
1import React from "react";
2import ReactDOM from "react-dom/client";
3import "highlight.js/styles/github-dark.css";
4
5import App from "./App";
6import "./index.css";
7
8ReactDOM.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 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";
3
4describe("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
23describe("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 @@
1export {
2 DEFAULT_MODEL,
3 MODEL_OPTIONS,
4 normalizeModelSlug,
5 resolveModelSlug,
6} from "@t3tools/contracts";
7
8export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
9export const DEFAULT_REASONING = "high";
apps/web/src/persistenceSchema.test.ts created+172
......@@ -0,0 +1,172 @@
1import { describe, expect, it } from "vitest";
2
3import { DEFAULT_MODEL } from "./model-logic";
4import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
5import type { Thread } from "./types";
6
7describe("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
116describe("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 @@
1import { z } from "zod";
2
3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import {
5 DEFAULT_RUNTIME_MODE,
6 type Project,
7 type RuntimeMode,
8 type Thread,
9} from "./types";
10
11const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
12
13const 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
21const 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
29const 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
39const persistedStateBodySchema = z.object({
40 projects: z.array(persistedProjectSchema),
41 threads: z.array(persistedThreadSchema),
42 activeThreadId: z.string().min(1).nullable(),
43});
44
45const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
46
47export const persistedStateV1Schema = persistedStateBodySchema.extend({
48 version: z.literal(1).optional(),
49});
50
51export const persistedStateV2Schema = persistedStateBodySchema.extend({
52 version: z.literal(2).optional(),
53});
54
55export const persistedStateV3Schema = persistedStateBodySchema.extend({
56 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
57 version: z.literal(3).optional(),
58});
59
60export const persistedStateV4Schema = persistedStateBodySchema.extend({
61 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
62 version: z.literal(4).optional(),
63});
64
65const persistedStateSchema = z.union([
66 persistedStateV4Schema,
67 persistedStateV3Schema,
68 persistedStateV2Schema,
69 persistedStateV1Schema,
70]);
71
72export interface PersistedStoreSnapshot {
73 projects: Project[];
74 threads: Thread[];
75 activeThreadId: string | null;
76 runtimeMode: RuntimeMode;
77}
78
79function 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
87function 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
97function 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
118export 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
159export 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 @@
1import { describe, expect, it } from "vitest";
2
3import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
4import {
5 type WorkLogEntry,
6 applyEventToMessages,
7 deriveTimelineEntries,
8 deriveWorkLogEntries,
9 evolveSession,
10} from "./session-logic";
11import type { ChatMessage } from "./types";
12
13function 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
25function 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
36describe("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
104describe("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
395describe("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
504describe("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 @@
1import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
2import type { ChatMessage, SessionPhase } from "./types";
3import { createWsNativeApi } from "./wsNativeApi";
4
5export 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
14let cachedApi: NativeApi | undefined;
15
16export 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
31export 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
36export function asString(value: unknown): string | undefined {
37 return typeof value === "string" ? value : undefined;
38}
39
40export 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
48export 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
61export 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
73export interface WorkLogEntry {
74 id: string;
75 createdAt: string;
76 label: string;
77 detail?: string;
78 tone: "thinking" | "tool" | "info" | "error";
79}
80
81export 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
95function 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
101function 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
111function 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
123function shouldShowItemLifecycle(type: string): boolean {
124 return (
125 type.includes("tool") ||
126 type.includes("command") ||
127 type.includes("file change")
128 );
129}
130
131function shouldDropMethod(method: string): boolean {
132 return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method);
133}
134
135function 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
151interface 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
161function 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
185function 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
218function 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
257function 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
291function 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
305function 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
311export 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
403function toTimestamp(isoDate: string): number {
404 const parsed = Date.parse(isoDate);
405 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
406}
407
408export 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
472export 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
479export 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
536export 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 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { type AppState, reducer } from "./store";
5import type { Thread } from "./types";
6
7function 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
18function 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
30function 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
46function 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
64describe("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 @@
1import {
2 type Dispatch,
3 type ReactNode,
4 createContext,
5 createElement,
6 useContext,
7 useEffect,
8 useReducer,
9} from "react";
10
11import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
12import { resolveModelSlug } from "./model-logic";
13import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
14import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
15import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
16
17// ── Actions ──────────────────────────────────────────────────────────
18
19type 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
40export interface AppState {
41 projects: Project[];
42 threads: Thread[];
43 activeThreadId: string | null;
44 runtimeMode: RuntimeMode;
45 diffOpen: boolean;
46}
47
48const PERSISTED_STATE_KEY = "t3code:renderer-state:v4";
49const 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
57const initialState: AppState = {
58 projects: [],
59 threads: [],
60 activeThreadId: null,
61 runtimeMode: DEFAULT_RUNTIME_MODE,
62 diffOpen: false,
63};
64
65// ── Helpers ──────────────────────────────────────────────────────────
66
67function 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
90function 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
103function 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
111function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
112 return threads.find((t) => t.session?.sessionId === sessionId);
113}
114
115function 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
122function 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
136function 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
155function 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
165function 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
196export 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
385const StoreContext = createContext<{
386 state: AppState;
387 dispatch: Dispatch<Action>;
388}>({ state: initialState, dispatch: () => {} });
389
390export 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
400export function useStore() {
401 return useContext(StoreContext);
402}
apps/web/src/types.ts created+38
......@@ -0,0 +1,38 @@
1import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
2
3export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
4export type RuntimeMode = "approval-required" | "full-access";
5export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
6
7export interface ChatMessage {
8 id: string;
9 role: "user" | "assistant";
10 text: string;
11 createdAt: string;
12 streaming: boolean;
13}
14
15export interface Project {
16 id: string;
17 name: string;
18 cwd: string;
19 model: string;
20 expanded: boolean;
21}
22
23export 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
3import type { NativeApi } from "@t3tools/contracts";
4
5interface DesktopBridge {
6 getWsUrl: () => string | null;
7 pickFolder: () => Promise<string | null>;
8}
9
10declare global {
11 interface Window {
12 nativeApi?: NativeApi;
13 desktopBridge?: DesktopBridge;
14 }
15}
apps/web/src/wsNativeApi.ts created+102
......@@ -0,0 +1,102 @@
1import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
2
3import { WsTransport } from "./wsTransport";
4
5let instance: { api: NativeApi; transport: WsTransport } | null = null;
6const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
7let 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 */
14export 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
31export 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 @@
1import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts";
2
3type PushListener = (data: unknown) => void;
4
5interface PendingRequest {
6 resolve: (result: unknown) => void;
7 reject: (error: Error) => void;
8 timeout: ReturnType<typeof setTimeout>;
9}
10
11const REQUEST_TIMEOUT_MS = 60_000;
12const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
13
14export 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 @@
1import tailwindcss from "@tailwindcss/vite";
2import react from "@vitejs/plugin-react";
3import { defineConfig } from "vite";
4
5export 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 @@
2828 "wait-on": "^8.0.2",
2929 },
3030 },
31 "apps/renderer": {
32 "name": "@t3tools/renderer",
31 "apps/server": {
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",
3353 "version": "0.0.0",
3454 "dependencies": {
3555 "@t3tools/contracts": "workspace:*",
......@@ -53,26 +73,6 @@
5373 "vite": "^6.0.5",
5474 },
5575 },
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 },
7676 "packages/contracts": {
7777 "name": "@t3tools/contracts",
7878 "version": "0.0.0",
......@@ -294,7 +294,7 @@
294294
295295 "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"],
296296
297 "@t3tools/renderer": ["@t3tools/renderer@workspace:apps/renderer"],
297 "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
298298
299299 "@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=="],
300300
package.json+3-3
......@@ -6,14 +6,14 @@
66 "packages/*"
77 ],
88 "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",
1010 "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",
1212 "dev:desktop": "bun run --cwd apps/desktop dev",
1313 "start": "node apps/server/dist/index.js",
1414 "start:desktop": "bun run --cwd apps/desktop start",
1515 "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",
1717 "typecheck": "turbo run typecheck",
1818 "lint": "oxlint --report-unused-disable-directives",
1919 "test": "turbo run test",