diff --git a/AGENTS.md b/AGENTS.md
index 8edd09be99ca44e5806d4ad5e0e4ae4b8487b020..7f5148fce0f2d5a09e6db4f11afc5d59ca8a0a5a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ If a tradeoff is required, choose correctness and robustness over short-term con
## Package Roles
- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
-- `apps/renderer`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
+- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.
## Codex App Server (Important)
@@ -24,7 +24,7 @@ How we use it in this codebase:
- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.
- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.
- WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`.
-- Renderer consumes provider event streams via WebSocket push on channel `providers.event`.
+- Web app consumes provider event streams via WebSocket push on channel `providers.event`.
Docs:
- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server
diff --git a/README.md b/README.md
index 6f90c7ad7e30fce036e7ca61735a65206305f574..1f18c9d30017780e9b4f1b3dd6ae7ede3cde0db8 100644
--- a/README.md
+++ b/README.md
@@ -29,9 +29,9 @@ T3 Code runs as a **Node.js WebSocket server** that wraps `codex app-server` (JS
## Workspace layout
-- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built renderer, and opens the browser on start.
-- `/apps/renderer`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket.
-- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared renderer.
+- `/apps/server`: Node.js WebSocket server. Wraps Codex app-server, serves the built web app, and opens the browser on start.
+- `/apps/web`: React + Vite UI. Session control, conversation, and provider event rendering. Connects to the server via WebSocket.
+- `/apps/desktop`: Electron shell. Spawns a desktop-scoped `t3` backend process and loads the shared web app.
- `/packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.
## Codex prerequisites
@@ -61,9 +61,9 @@ npx t3
- `bun run dev` — Starts contracts, server, and web dev tasks via Turborepo's parallel task runner.
- `bun run dev:server` — Starts just the WebSocket server (uses tsx for TS execution).
-- `bun run dev:web` — Starts just the Vite dev server for the renderer.
-- `bun run start` — Runs the production server (serves built renderer as static files).
-- `bun run build` — Builds contracts, renderer, and server through Turbo.
+- `bun run dev:web` — Starts just the Vite dev server for the web app.
+- `bun run start` — Runs the production server (serves built web app as static files).
+- `bun run build` — Builds contracts, web app, and server through Turbo.
- `bun run typecheck` — Strict TypeScript checks for all packages.
- `bun run test` — Runs workspace tests.
@@ -76,7 +76,7 @@ T3 Code has a global runtime mode switch in the chat toolbar:
## Provider architecture
-The renderer communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
+The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol:
- **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }`
- **Push events**: `{ type: "push", channel, data }` for streaming provider events
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 7cfe513719291b415fdb4f7bfa871ce26323e586..9fc5d44ac8fb9b62b38f6e19e2bc5653c66b6d7d 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -4,7 +4,7 @@
"private": true,
"main": "dist-electron/main.js",
"scripts": {
- "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\"",
+ "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\"",
"dev:bundle": "tsup --watch",
"dev:electron": "bun run scripts/dev-electron.mjs",
"build": "tsup",
diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs
index cc13d469315277e9cc1f60f1fac85835eadb4158..895d18b8b60fa1033821432038aaeccdf7eaa481 100644
--- a/apps/desktop/scripts/smoke-test.mjs
+++ b/apps/desktop/scripts/smoke-test.mjs
@@ -8,7 +8,7 @@ const rootDir = resolve(__dirname, "../../..");
const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
const mainJs = resolve(desktopDir, "dist-electron/main.js");
-console.log("Building contracts + renderer + server + desktop...");
+console.log("Building contracts + web + server + desktop...");
execSync("bun run build", { cwd: rootDir, stdio: "inherit" });
console.log("\nLaunching Electron smoke test...");
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index 3cc451911b1325097a773ac59674fc03ae4aeb19..3161a2e9dc1d46e24665ea70d14ca7ec09b6933c 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -14,7 +14,7 @@ fixPath();
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const ROOT_DIR = path.resolve(__dirname, "../../..");
const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.js");
-const RENDERER_ENTRY = path.join(ROOT_DIR, "apps/renderer/dist/index.html");
+const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
@@ -167,10 +167,10 @@ function createWindow(): BrowserWindow {
if (isDevelopment) {
void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
} else {
- if (!fs.existsSync(RENDERER_ENTRY)) {
- throw new Error(`Renderer bundle missing at ${RENDERER_ENTRY}`);
+ if (!fs.existsSync(WEB_ENTRY)) {
+ throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
}
- void window.loadFile(RENDERER_ENTRY);
+ void window.loadFile(WEB_ENTRY);
}
window.on("closed", () => {
diff --git a/apps/renderer/index.html b/apps/renderer/index.html
deleted file mode 100644
index fe848c5287a8cb351c508d4b9696309f0b1f8c86..0000000000000000000000000000000000000000
--- a/apps/renderer/index.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
- T3 Code
-
-
-
-
-
-
diff --git a/apps/renderer/package.json b/apps/renderer/package.json
deleted file mode 100644
index 0c04817c874680ec04c52b3e1952c0add18d7275..0000000000000000000000000000000000000000
--- a/apps/renderer/package.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "name": "@t3tools/renderer",
- "version": "0.0.0",
- "private": true,
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "typecheck": "tsc --noEmit",
- "test": "vitest run --passWithNoTests"
- },
- "dependencies": {
- "@t3tools/contracts": "workspace:*",
- "highlight.js": "^11.11.1",
- "lucide-react": "^0.563.0",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "react-markdown": "^10.1.0",
- "rehype-highlight": "^7.0.2",
- "remark-gfm": "^4.0.1",
- "zod": "^3.24.1"
- },
- "devDependencies": {
- "@tailwindcss/vite": "^4.0.0",
- "@types/react": "^19.0.0",
- "@types/react-dom": "^19.0.0",
- "@vitejs/plugin-react": "^4.3.4",
- "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
- "tailwindcss": "^4.0.0",
- "typescript": "^5.7.3",
- "vite": "^6.0.5"
- }
-}
diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx
deleted file mode 100644
index e12d563ab92d89ea62c4844ec0ecb8a527e80d4d..0000000000000000000000000000000000000000
--- a/apps/renderer/src/App.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import { useEffect, useMemo, useRef } from "react";
-
-import ChatView from "./components/ChatView";
-import DiffPanel from "./components/DiffPanel";
-import Sidebar from "./components/Sidebar";
-import { isElectron } from "./env";
-import { DEFAULT_MODEL } from "./model-logic";
-import { readNativeApi } from "./session-logic";
-import { StoreProvider, useStore } from "./store";
-import { onServerWelcome } from "./wsNativeApi";
-
-function EventRouter() {
- const api = useMemo(() => readNativeApi(), []);
- const { dispatch } = useStore();
- const activeAssistantItemRef = useRef(null);
-
- useEffect(() => {
- if (!api) return;
- return api.providers.onEvent((event) => {
- dispatch({
- type: "APPLY_EVENT",
- event,
- activeAssistantItemRef,
- });
- });
- }, [api, dispatch]);
-
- return null;
-}
-
-function AutoProjectBootstrap() {
- const { state, dispatch } = useStore();
- const bootstrappedRef = useRef(false);
-
- useEffect(() => {
- // Browser mode bootstraps from server welcome.
- // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
- if (isElectron) return;
-
- return onServerWelcome((payload) => {
- if (bootstrappedRef.current) return;
-
- // Don't create duplicate projects for the same cwd
- const existing = state.projects.find((p) => p.cwd === payload.cwd);
- if (existing) {
- bootstrappedRef.current = true;
- // Ensure a thread is active
- const existingThread = state.threads.find((t) => t.projectId === existing.id);
- if (existingThread && !state.activeThreadId) {
- dispatch({
- type: "SET_ACTIVE_THREAD",
- threadId: existingThread.id,
- });
- }
- return;
- }
-
- bootstrappedRef.current = true;
-
- // Create project + thread from server cwd
- const projectId = crypto.randomUUID();
- dispatch({
- type: "ADD_PROJECT",
- project: {
- id: projectId,
- name: payload.projectName,
- cwd: payload.cwd,
- model: DEFAULT_MODEL,
- expanded: true,
- },
- });
- dispatch({
- type: "ADD_THREAD",
- thread: {
- id: crypto.randomUUID(),
- codexThreadId: null,
- projectId,
- title: "New thread",
- model: DEFAULT_MODEL,
- session: null,
- messages: [],
- events: [],
- error: null,
- createdAt: new Date().toISOString(),
- },
- });
- });
- }, [state.projects, state.threads, state.activeThreadId, dispatch]);
-
- return null;
-}
-
-function DesktopProjectBootstrap() {
- const api = useMemo(() => readNativeApi(), []);
- const { dispatch } = useStore();
- const bootstrappedRef = useRef(false);
-
- useEffect(() => {
- if (!isElectron || !api || bootstrappedRef.current) return;
-
- let disposed = false;
- let retryDelayMs = 500;
- let retryTimer: ReturnType | null = null;
-
- const attemptBootstrap = async () => {
- try {
- const projects = await api.projects.list();
- if (disposed) return;
- dispatch({
- type: "SYNC_PROJECTS",
- projects: projects.map((project) => ({
- id: project.id,
- name: project.name,
- cwd: project.cwd,
- model: DEFAULT_MODEL,
- expanded: true,
- })),
- });
- bootstrappedRef.current = true;
- } catch {
- if (disposed) return;
- retryTimer = setTimeout(() => {
- retryTimer = null;
- void attemptBootstrap();
- }, retryDelayMs);
- retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
- }
- };
-
- void attemptBootstrap();
-
- return () => {
- disposed = true;
- if (retryTimer) {
- clearTimeout(retryTimer);
- }
- };
- }, [api, dispatch]);
-
- return null;
-}
-
-function Layout() {
- const api = useMemo(() => readNativeApi(), []);
- const { state } = useStore();
-
- if (!api) {
- return (
-
-
-
Connecting to T3 Code server...
-
-
- );
- }
-
- return (
-
-
-
-
-
-
- {state.diffOpen &&
}
-
- );
-}
-
-export default function App() {
- return (
-
-
-
- );
-}
diff --git a/apps/renderer/src/components/ChatMarkdown.tsx b/apps/renderer/src/components/ChatMarkdown.tsx
deleted file mode 100644
index 5104ddced5e66cfd9c8d77207f41406f36bdf69d..0000000000000000000000000000000000000000
--- a/apps/renderer/src/components/ChatMarkdown.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { Components } from "react-markdown";
-import ReactMarkdown from "react-markdown";
-import rehypeHighlight from "rehype-highlight";
-import remarkGfm from "remark-gfm";
-
-interface ChatMarkdownProps {
- text: string;
-}
-
-const markdownComponents: Components = {
- a({ node: _node, ...props }) {
- return ;
- },
-};
-
-export default function ChatMarkdown({ text }: ChatMarkdownProps) {
- return (
-
-
- {text}
-
-
- );
-}
diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx
deleted file mode 100644
index 3c28c7da551713933e03debef0d306ea76c1c8b0..0000000000000000000000000000000000000000
--- a/apps/renderer/src/components/ChatView.tsx
+++ /dev/null
@@ -1,1170 +0,0 @@
-import {
- PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
- type ProviderApprovalDecision,
- type ProviderEvent,
-} from "@t3tools/contracts";
-import {
- type FormEvent,
- Fragment,
- type KeyboardEvent,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
-
-import { EDITORS, type EditorId } from "@t3tools/contracts";
-import { isElectron } from "../env";
-import { buildBootstrapInput } from "../historyBootstrap";
-import {
- DEFAULT_MODEL,
- DEFAULT_REASONING,
- MODEL_OPTIONS,
- REASONING_OPTIONS,
- resolveModelSlug,
-} from "../model-logic";
-import {
- derivePhase,
- deriveTimelineEntries,
- deriveWorkLogEntries,
- formatDuration,
- formatElapsed,
- formatTimestamp,
- readNativeApi,
-} from "../session-logic";
-import { useStore } from "../store";
-import ChatMarkdown from "./ChatMarkdown";
-
-function formatMessageMeta(createdAt: string, duration: string | null): string {
- if (!duration) return formatTimestamp(createdAt);
- return `${formatTimestamp(createdAt)} • ${duration}`;
-}
-
-const FILE_MANAGER_LABEL = navigator.platform.includes("Mac")
- ? "Finder"
- : navigator.platform.includes("Win")
- ? "Explorer"
- : "Files";
-
-function editorLabel(editor: (typeof EDITORS)[number]): string {
- return editor.command ? editor.label : FILE_MANAGER_LABEL;
-}
-
-const LAST_EDITOR_KEY = "t3code:last-editor";
-const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
-
-function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
- if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
- if (tone === "tool") return "text-muted-foreground/70";
- if (tone === "thinking") return "text-muted-foreground/50";
- return "text-muted-foreground/40";
-}
-
-interface PendingApprovalCard {
- requestId: string;
- requestKind: "command" | "file-change";
- createdAt: string;
- detail?: string;
-}
-
-type SessionContinuityState = "resumed" | "new" | "fallback_new";
-
-interface EnsuredSessionInfo {
- sessionId: string;
- resolvedThreadId: string | null;
- continuityState: SessionContinuityState;
-}
-
-function asRecord(value: unknown): Record | undefined {
- if (!value || typeof value !== "object") return undefined;
- return value as Record;
-}
-
-function asString(value: unknown): string | undefined {
- return typeof value === "string" ? value : undefined;
-}
-
-function approvalDetail(event: ProviderEvent): string | undefined {
- const payload = asRecord(event.payload);
- const command = asString(payload?.command);
- if (command) return command;
- return asString(payload?.reason);
-}
-
-function derivePendingApprovals(
- events: ProviderEvent[],
-): PendingApprovalCard[] {
- const pending = new Map();
- const ordered = [...events].toReversed();
-
- for (const event of ordered) {
- if (
- event.method === "session/closed" ||
- event.method === "session/exited"
- ) {
- pending.clear();
- continue;
- }
-
- const requestId =
- event.requestId ?? asString(asRecord(event.payload)?.requestId);
- if (!requestId) continue;
-
- if (
- event.kind === "request" &&
- (event.requestKind === "command" || event.requestKind === "file-change")
- ) {
- const detail = approvalDetail(event);
- pending.set(requestId, {
- requestId,
- requestKind: event.requestKind,
- createdAt: event.createdAt,
- ...(detail ? { detail } : {}),
- });
- continue;
- }
-
- if (event.method === "item/requestApproval/decision") {
- pending.delete(requestId);
- }
- }
-
- return Array.from(pending.values());
-}
-
-export default function ChatView() {
- const { state, dispatch } = useStore();
- const api = useMemo(() => readNativeApi(), []);
- const [prompt, setPrompt] = useState("");
- const [isSending, setIsSending] = useState(false);
- const [isConnecting, setIsConnecting] = useState(false);
- const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
- const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
- const [lastEditor, setLastEditor] = useState(() => {
- const stored = localStorage.getItem(LAST_EDITOR_KEY);
- return EDITORS.some((e) => e.id === stored)
- ? (stored as EditorId)
- : EDITORS[0].id;
- });
- const [selectedEffort, setSelectedEffort] =
- useState(DEFAULT_REASONING);
- const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
- const [respondingRequestIds, setRespondingRequestIds] = useState(
- [],
- );
- const [expandedWorkGroups, setExpandedWorkGroups] = useState<
- Record
- >({});
- const [nowTick, setNowTick] = useState(() => Date.now());
- const messagesEndRef = useRef(null);
- const textareaRef = useRef(null);
- const modelMenuRef = useRef(null);
- const editorMenuRef = useRef(null);
-
- const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
- const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
- const selectedModel = resolveModelSlug(
- activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
- );
- const phase = derivePhase(activeThread?.session ?? null);
- const isWorking = phase === "running" || isSending || isConnecting;
- const nowIso = new Date(nowTick).toISOString();
- const modelOptions = MODEL_OPTIONS;
- const workLogEntries = useMemo(
- () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
- [activeThread?.events],
- );
- const latestTurnWorkEntries = useMemo(
- () =>
- deriveWorkLogEntries(
- activeThread?.events ?? [],
- activeThread?.latestTurnId,
- ),
- [activeThread?.events, activeThread?.latestTurnId],
- );
- const pendingApprovals = useMemo(
- () => derivePendingApprovals(activeThread?.events ?? []),
- [activeThread?.events],
- );
- const assistantCompletionByItemId = useMemo(() => {
- const map = new Map();
- const ordered = [...(activeThread?.events ?? [])].toReversed();
- for (const event of ordered) {
- if (event.method !== "item/completed") continue;
- if (!event.itemId) continue;
- map.set(event.itemId, event.createdAt);
- }
- return map;
- }, [activeThread?.events]);
- const timelineEntries = useMemo(
- () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
- [activeThread?.messages, workLogEntries],
- );
- const completionSummary = useMemo(() => {
- if (!activeThread?.latestTurnStartedAt) return null;
- if (!activeThread.latestTurnCompletedAt) return null;
- if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) {
- return null;
- }
-
- if (
- typeof activeThread.latestTurnDurationMs === "number" &&
- Number.isFinite(activeThread.latestTurnDurationMs) &&
- activeThread.latestTurnDurationMs >= 0
- ) {
- return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`;
- }
-
- const elapsed = formatElapsed(
- activeThread.latestTurnStartedAt,
- activeThread.latestTurnCompletedAt,
- );
- return elapsed ? `Worked for ${elapsed}` : null;
- }, [
- activeThread?.latestTurnStartedAt,
- activeThread?.latestTurnCompletedAt,
- activeThread?.latestTurnDurationMs,
- latestTurnWorkEntries,
- ]);
- const completionDividerBeforeEntryId = useMemo(() => {
- if (!activeThread?.latestTurnStartedAt) return null;
- if (!activeThread.latestTurnCompletedAt) return null;
- if (!completionSummary) return null;
-
- const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt);
- const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt);
- if (Number.isNaN(turnStartedAt)) return null;
- if (Number.isNaN(turnCompletedAt)) return null;
-
- let inRangeMatch: string | null = null;
- let fallbackMatch: string | null = null;
- for (const timelineEntry of timelineEntries) {
- if (timelineEntry.kind !== "message") continue;
- if (timelineEntry.message.role !== "assistant") continue;
- const messageAt = Date.parse(timelineEntry.message.createdAt);
- if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue;
- fallbackMatch = timelineEntry.id;
- if (messageAt <= turnCompletedAt) {
- inRangeMatch = timelineEntry.id;
- }
- }
- return inRangeMatch ?? fallbackMatch;
- }, [
- activeThread?.latestTurnCompletedAt,
- activeThread?.latestTurnStartedAt,
- completionSummary,
- timelineEntries,
- ]);
- const runtimeSessionConfig =
- state.runtimeMode === "full-access"
- ? ({
- approvalPolicy: "never",
- sandboxMode: "danger-full-access",
- } as const)
- : ({
- approvalPolicy: "on-request",
- sandboxMode: "workspace-write",
- } as const);
-
- const handleRuntimeModeChange = async (
- mode: "approval-required" | "full-access",
- ) => {
- if (mode === state.runtimeMode) return;
- dispatch({ type: "SET_RUNTIME_MODE", mode });
- if (!api) return;
-
- const sessionIds = state.threads
- .map((t) => t.session)
- .filter(
- (s): s is NonNullable => s !== null && s.status !== "closed",
- )
- .map((s) => s.sessionId);
-
- if (sessionIds.length === 0) return;
-
- setIsSwitchingRuntimeMode(true);
- try {
- await Promise.all(
- sessionIds.map((id) =>
- api.providers.stopSession({ sessionId: id }).catch(() => undefined),
- ),
- );
- } finally {
- setIsSwitchingRuntimeMode(false);
- }
- };
-
- // Auto-scroll on new messages
- const messageCount = activeThread?.messages.length ?? 0;
- const workLogCount = workLogEntries.length;
- useEffect(() => {
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [messageCount]);
- useEffect(() => {
- if (phase !== "running") return;
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
- }, [phase, workLogCount]);
-
- useEffect(() => {
- setExpandedWorkGroups({});
- }, [activeThread?.id]);
-
- // Auto-resize textarea
- useEffect(() => {
- const ta = textareaRef.current;
- if (!ta) return;
- ta.style.height = "auto";
- ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
- }, [prompt]);
-
- useEffect(() => {
- if (phase !== "running") return;
- const timer = window.setInterval(() => {
- setNowTick(Date.now());
- }, 250);
- return () => {
- window.clearInterval(timer);
- };
- }, [phase]);
-
- useEffect(() => {
- if (!isModelMenuOpen) return;
-
- const handleClickOutside = (event: MouseEvent) => {
- if (!modelMenuRef.current) return;
- if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) {
- setIsModelMenuOpen(false);
- }
- };
-
- window.addEventListener("mousedown", handleClickOutside);
- return () => {
- window.removeEventListener("mousedown", handleClickOutside);
- };
- }, [isModelMenuOpen]);
-
- useEffect(() => {
- if (!isEditorMenuOpen) return;
-
- const handleClickOutside = (event: MouseEvent) => {
- if (!editorMenuRef.current) return;
- if (
- event.target instanceof Node &&
- !editorMenuRef.current.contains(event.target)
- ) {
- setIsEditorMenuOpen(false);
- }
- };
-
- window.addEventListener("mousedown", handleClickOutside);
- return () => {
- window.removeEventListener("mousedown", handleClickOutside);
- };
- }, [isEditorMenuOpen]);
-
- // Cmd+O / Ctrl+O to open in last-used editor
- useEffect(() => {
- const handler = (e: globalThis.KeyboardEvent) => {
- if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
- if (api && activeProject) {
- e.preventDefault();
- void api.shell.openInEditor(activeProject.cwd, lastEditor);
- }
- }
- };
- window.addEventListener("keydown", handler);
- return () => window.removeEventListener("keydown", handler);
- }, [api, activeProject, lastEditor]);
-
- const openInEditor = (editorId: EditorId) => {
- if (!api || !activeProject) return;
- void api.shell.openInEditor(activeProject.cwd, editorId);
- setLastEditor(editorId);
- localStorage.setItem(LAST_EDITOR_KEY, editorId);
- setIsEditorMenuOpen(false);
- };
-
- const ensureSession = async (): Promise => {
- if (!api || !activeThread || !activeProject) return null;
- if (activeThread.session && activeThread.session.status !== "closed") {
- const sessionThreadId = activeThread.session.threadId ?? null;
- const continuityState: SessionContinuityState =
- activeThread.codexThreadId === null
- ? "new"
- : sessionThreadId === activeThread.codexThreadId
- ? "resumed"
- : "fallback_new";
- return {
- sessionId: activeThread.session.sessionId,
- resolvedThreadId: sessionThreadId,
- continuityState,
- } satisfies EnsuredSessionInfo;
- }
-
- const priorCodexThreadId = activeThread.codexThreadId;
- setIsConnecting(true);
- try {
- const session = await api.providers.startSession({
- provider: "codex",
- cwd: activeProject.cwd || undefined,
- model: selectedModel || undefined,
- resumeThreadId: priorCodexThreadId ?? undefined,
- approvalPolicy: runtimeSessionConfig.approvalPolicy,
- sandboxMode: runtimeSessionConfig.sandboxMode,
- });
- dispatch({
- type: "UPDATE_SESSION",
- threadId: activeThread.id,
- session,
- });
- const resolvedThreadId = session.threadId ?? null;
- const continuityState: SessionContinuityState =
- priorCodexThreadId === null
- ? "new"
- : resolvedThreadId === priorCodexThreadId
- ? "resumed"
- : "fallback_new";
- return {
- sessionId: session.sessionId,
- resolvedThreadId,
- continuityState,
- };
- } catch (err) {
- dispatch({
- type: "SET_ERROR",
- threadId: activeThread.id,
- error: err instanceof Error ? err.message : "Failed to connect.",
- });
- return null;
- } finally {
- setIsConnecting(false);
- }
- };
-
- const onSend = async (e: FormEvent) => {
- e.preventDefault();
- if (!api || !activeThread || isSending || isConnecting) return;
- const trimmed = prompt.trim();
- if (!trimmed) return;
-
- // Auto-title from first message
- if (activeThread.messages.length === 0) {
- const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed;
- dispatch({
- type: "SET_THREAD_TITLE",
- threadId: activeThread.id,
- title,
- });
- }
-
- dispatch({
- type: "SET_ERROR",
- threadId: activeThread.id,
- error: null,
- });
- dispatch({
- type: "PUSH_USER_MESSAGE",
- threadId: activeThread.id,
- id: crypto.randomUUID(),
- text: trimmed,
- });
- const previousMessages = activeThread.messages;
- setPrompt("");
-
- const sessionInfo = await ensureSession();
- if (!sessionInfo) return;
-
- setIsSending(true);
- try {
- const shouldBootstrap =
- previousMessages.length > 0 &&
- (sessionInfo.continuityState === "new" ||
- sessionInfo.continuityState === "fallback_new");
- const input = shouldBootstrap
- ? buildBootstrapInput(
- previousMessages,
- trimmed,
- PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
- ).text
- : trimmed;
- await api.providers.sendTurn({
- sessionId: sessionInfo.sessionId,
- input,
- model: selectedModel || undefined,
- effort: selectedEffort || undefined,
- });
- } catch (err) {
- dispatch({
- type: "SET_ERROR",
- threadId: activeThread.id,
- error: err instanceof Error ? err.message : "Failed to send message.",
- });
- } finally {
- setIsSending(false);
- }
- };
-
- const onInterrupt = async () => {
- if (!api || !activeThread?.session) return;
- await api.providers.interruptTurn({
- sessionId: activeThread.session.sessionId,
- turnId: activeThread.session.activeTurnId,
- });
- };
-
- const onRespondToApproval = async (
- requestId: string,
- decision: ProviderApprovalDecision,
- ) => {
- if (!api || !activeThread?.session) return;
-
- setRespondingRequestIds((existing) =>
- existing.includes(requestId) ? existing : [...existing, requestId],
- );
- try {
- await api.providers.respondToRequest({
- sessionId: activeThread.session.sessionId,
- requestId,
- decision,
- });
- } catch (err) {
- dispatch({
- type: "SET_ERROR",
- threadId: activeThread.id,
- error:
- err instanceof Error
- ? err.message
- : "Failed to submit approval decision.",
- });
- } finally {
- setRespondingRequestIds((existing) =>
- existing.filter((id) => id !== requestId),
- );
- }
- };
-
- const onModelSelect = (model: string) => {
- if (!activeThread) return;
- dispatch({
- type: "SET_THREAD_MODEL",
- threadId: activeThread.id,
- model: resolveModelSlug(model),
- });
- setIsModelMenuOpen(false);
- };
-
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- void onSend(e as unknown as FormEvent);
- }
- };
-
- // Empty state: no active thread
- if (!activeThread) {
- return (
-
- {isElectron &&
}
-
-
-
Select a thread or create a new one to get started.
-
-
-
- );
- }
-
- return (
-
- {/* Top bar */}
-
-
- {/* Error banner */}
- {activeThread.error && (
-
- {activeThread.error}
-
- )}
-
- {pendingApprovals.length > 0 && (
-
- {pendingApprovals.map((approval) => {
- const isResponding = respondingRequestIds.includes(
- approval.requestId,
- );
- return (
-
-
- {approval.requestKind === "command"
- ? "Command approval requested"
- : "File-change approval requested"}
-
- {approval.detail && (
-
- {approval.detail}
-
- )}
-
-
- void onRespondToApproval(approval.requestId, "accept")
- }
- >
- Approve once
-
-
- void onRespondToApproval(
- approval.requestId,
- "acceptForSession",
- )
- }
- >
- Always allow this session
-
-
- void onRespondToApproval(approval.requestId, "decline")
- }
- >
- Decline
-
-
- void onRespondToApproval(approval.requestId, "cancel")
- }
- >
- Cancel turn
-
-
-
- );
- })}
-
- )}
-
- {/* Messages */}
-
- {activeThread.messages.length === 0 && !isWorking ? (
-
-
Send a message to start the conversation.
-
- ) : (
-
- {timelineEntries.map((timelineEntry, index) => {
- if (
- timelineEntry.kind === "work" &&
- timelineEntries[index - 1]?.kind === "work"
- ) {
- return null;
- }
-
- const showCompletionDivider =
- timelineEntry.kind === "message" &&
- timelineEntry.message.role === "assistant" &&
- completionDividerBeforeEntryId === timelineEntry.id;
-
- if (timelineEntry.kind === "work") {
- const groupedEntries = [timelineEntry.entry];
- let cursor = index + 1;
- while (cursor < timelineEntries.length) {
- const nextEntry = timelineEntries[cursor];
- if (!nextEntry || nextEntry.kind !== "work") break;
- groupedEntries.push(nextEntry.entry);
- cursor += 1;
- }
-
- const groupId = timelineEntry.id;
- const isExpanded = expandedWorkGroups[groupId] ?? false;
- const hasOverflow =
- groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
- const visibleEntries =
- hasOverflow && !isExpanded
- ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
- : groupedEntries;
- const hiddenCount = groupedEntries.length - visibleEntries.length;
- const onlyToolEntries = groupedEntries.every(
- (entry) => entry.tone === "tool",
- );
- const groupLabel = onlyToolEntries
- ? groupedEntries.length === 1
- ? "Tool call"
- : `Tool calls (${groupedEntries.length})`
- : groupedEntries.length === 1
- ? "Work event"
- : `Work log (${groupedEntries.length})`;
-
- return (
-
-
-
-
- {groupLabel}
-
- {hasOverflow && (
-
- setExpandedWorkGroups((existing) => ({
- ...existing,
- [groupId]: !existing[groupId],
- }))
- }
- >
- {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
-
- )}
-
-
- {visibleEntries.map((workEntry) => (
-
-
-
- {workEntry.detail ? (
- <>
- {workEntry.label}
-
- {workEntry.detail}
-
- >
- ) : (
- workEntry.label
- )}
-
-
- ))}
-
-
-
- );
- }
-
- if (timelineEntry.message.role === "user") {
- return (
-
-
-
-
- {timelineEntry.message.text}
-
-
- {formatTimestamp(timelineEntry.message.createdAt)}
-
-
-
-
- );
- }
-
- return (
-
- {showCompletionDivider && (
-
-
-
- {completionSummary
- ? `Response • ${completionSummary}`
- : "Response"}
-
-
-
- )}
-
-
-
- {formatMessageMeta(
- timelineEntry.message.createdAt,
- timelineEntry.message.streaming
- ? formatElapsed(
- timelineEntry.message.createdAt,
- nowIso,
- )
- : formatElapsed(
- timelineEntry.message.createdAt,
- assistantCompletionByItemId.get(
- timelineEntry.message.id,
- ),
- ),
- )}
-
-
-
- );
- })}
- {isWorking && (
-
- )}
-
-
- )}
-
-
- {/* Input bar */}
-
-
- );
-}
diff --git a/apps/renderer/src/components/DiffPanel.tsx b/apps/renderer/src/components/DiffPanel.tsx
deleted file mode 100644
index 6a546c848fab82d87987c852e64e556dc4442fb1..0000000000000000000000000000000000000000
--- a/apps/renderer/src/components/DiffPanel.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { isElectron } from "../env";
-import { useStore } from "../store";
-
-export default function DiffPanel() {
- const { dispatch } = useStore();
-
- return (
-
- {/* Header */}
-
-
Uncommitted changes
- dispatch({ type: "TOGGLE_DIFF" })}
- >
- ×
-
-
-
- {/* Placeholder content */}
-
-
-
-
-
- M
-
- src/App.tsx
-
-
-
- const oldValue = "hello";
-
+ const newValue = "world";
-
-
-
-
-
-
- A
-
- src/utils.ts
-
-
-
-
-
- Diff integration coming soon
-
-
-
- );
-}
diff --git a/apps/renderer/src/components/Sidebar.tsx b/apps/renderer/src/components/Sidebar.tsx
deleted file mode 100644
index 82568b4f9fbd623b3800feabee7094b5559fc9ea..0000000000000000000000000000000000000000
--- a/apps/renderer/src/components/Sidebar.tsx
+++ /dev/null
@@ -1,390 +0,0 @@
-import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { isElectron } from "../env";
-import { useTheme } from "../hooks/useTheme";
-import { DEFAULT_MODEL } from "../model-logic";
-import { readNativeApi } from "../session-logic";
-import { useStore } from "../store";
-import type { Project } from "../types";
-
-const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
-function formatRelativeTime(iso: string): string {
- const diff = Date.now() - new Date(iso).getTime();
- const minutes = Math.floor(diff / 60_000);
- if (minutes < 1) return "just now";
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h ago`;
- return `${Math.floor(hours / 24)}d ago`;
-}
-
-function inferProjectName(cwd: string): string {
- const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
- const parts = normalized.split("/");
- const last = parts[parts.length - 1];
- return last && last.length > 0 ? last : "project";
-}
-
-function threadStatusLabel(
- status: "connecting" | "ready" | "running" | "error" | "closed" | undefined,
-): "Working" | "Connecting" | null {
- if (status === "running") return "Working";
- if (status === "connecting") return "Connecting";
- return null;
-}
-
-export default function Sidebar() {
- const { state, dispatch } = useStore();
- const api = useMemo(() => readNativeApi(), []);
- const { theme, setTheme } = useTheme();
- const [addingProject, setAddingProject] = useState(false);
- const [newCwd, setNewCwd] = useState("");
- const [isPickingFolder, setIsPickingFolder] = useState(false);
- const [isAddingProject, setIsAddingProject] = useState(false);
-
- const handleNewThread = useCallback(
- (projectId: string) => {
- dispatch({
- type: "ADD_THREAD",
- thread: {
- id: crypto.randomUUID(),
- codexThreadId: null,
- projectId,
- title: "New thread",
- model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
- session: null,
- messages: [],
- events: [],
- error: null,
- createdAt: new Date().toISOString(),
- },
- });
- },
- [dispatch, state.projects],
- );
-
- const focusMostRecentThreadForProject = useCallback(
- (projectId: string) => {
- const latestThread = state.threads
- .filter((thread) => thread.projectId === projectId)
- .toSorted((a, b) => {
- const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
- if (byDate !== 0) return byDate;
- return b.id.localeCompare(a.id);
- })[0];
- if (!latestThread) return;
-
- dispatch({
- type: "SET_ACTIVE_THREAD",
- threadId: latestThread.id,
- });
- },
- [dispatch, state.threads],
- );
-
- const addProjectFromPath = useCallback(
- async (rawCwd: string) => {
- const cwd = rawCwd.trim();
- if (!cwd || isAddingProject) return;
-
- setIsAddingProject(true);
- try {
- if (isElectron && api) {
- const result = await api.projects.add({ cwd });
- const project: Project = {
- id: result.project.id,
- name: result.project.name,
- cwd: result.project.cwd,
- model: DEFAULT_MODEL,
- expanded: true,
- };
- const existingById = state.projects.find((p) => p.id === project.id);
- const existingByCwd = state.projects.find((p) => p.cwd === project.cwd);
- if (!existingById && !existingByCwd) {
- dispatch({ type: "ADD_PROJECT", project });
- }
- const resolvedProjectId = existingByCwd?.id ?? project.id;
-
- if (result.created) {
- handleNewThread(resolvedProjectId);
- } else {
- focusMostRecentThreadForProject(resolvedProjectId);
- }
- } else {
- const existing = state.projects.find((project) => project.cwd === cwd);
- if (existing) {
- focusMostRecentThreadForProject(existing.id);
- return;
- }
-
- const name = inferProjectName(cwd);
- const project: Project = {
- id: crypto.randomUUID(),
- name,
- cwd,
- model: DEFAULT_MODEL,
- expanded: true,
- };
- dispatch({ type: "ADD_PROJECT", project });
- handleNewThread(project.id);
- }
- } finally {
- setIsAddingProject(false);
- setNewCwd("");
- setAddingProject(false);
- }
- },
- [
- api,
- dispatch,
- focusMostRecentThreadForProject,
- handleNewThread,
- isAddingProject,
- state.projects,
- ],
- );
-
- const handleAddProject = () => {
- void addProjectFromPath(newCwd);
- };
-
- const handlePickFolder = async () => {
- if (!api || isPickingFolder) return;
- setIsPickingFolder(true);
- try {
- const pickedPath = await api.dialogs.pickFolder();
- if (!pickedPath) return;
- await addProjectFromPath(pickedPath);
- } finally {
- setIsPickingFolder(false);
- }
- };
-
- useEffect(() => {
- const onWindowKeyDown = (event: KeyboardEvent) => {
- const isNewThreadShortcut =
- event.metaKey &&
- event.shiftKey &&
- !event.ctrlKey &&
- !event.altKey &&
- event.key.toLowerCase() === "o";
- if (!isNewThreadShortcut) return;
-
- const target = event.target;
- if (target instanceof HTMLElement) {
- const tagName = target.tagName;
- if (
- target.isContentEditable ||
- tagName === "INPUT" ||
- tagName === "TEXTAREA" ||
- tagName === "SELECT"
- ) {
- return;
- }
- }
-
- const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
- const projectId = activeThread?.projectId ?? state.projects[0]?.id;
- if (!projectId) return;
-
- event.preventDefault();
- handleNewThread(projectId);
- };
-
- window.addEventListener("keydown", onWindowKeyDown);
- return () => {
- window.removeEventListener("keydown", onWindowKeyDown);
- };
- }, [handleNewThread, state.activeThreadId, state.projects, state.threads]);
-
- return (
-
- {/* Drag region / traffic light space (Electron only) */}
- {isElectron &&
}
- {/* Branding */}
-
-
- T3 Code
-
- setTheme(THEME_CYCLE[theme])}
- aria-label={`Theme: ${theme}`}
- title={`Theme: ${theme}`}
- >
- {theme === "system" ? (
-
- ) : theme === "light" ? (
-
- ) : (
-
- )}
-
-
-
- {/* New thread (global) */}
-
- {
- if (state.projects.length === 0) {
- setAddingProject(true);
- return;
- }
- const firstProject = state.projects[0];
- if (firstProject) handleNewThread(firstProject.id);
- }}
- >
- +
- New thread
-
-
-
- {/* Project list */}
-
- {state.projects.map((project) => {
- const threads = state.threads
- .filter((t) => t.projectId === project.id)
- .toSorted((a, b) => {
- const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
- if (byDate !== 0) return byDate;
- return b.id.localeCompare(a.id);
- });
- return (
-
- {/* Project header */}
-
dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}
- >
-
- {project.expanded ? "▼" : "▶"}
-
-
- {project.name}
-
- {threads.length}
-
-
- {/* Threads */}
- {project.expanded && (
-
- {threads.map((thread) => {
- const isActive = state.activeThreadId === thread.id;
- const threadStatus = threadStatusLabel(thread.session?.status);
- return (
-
- dispatch({
- type: "SET_ACTIVE_THREAD",
- threadId: thread.id,
- })
- }
- >
-
- {threadStatus && (
-
-
- {threadStatus}
-
- )}
- {thread.title}
-
-
- {formatRelativeTime(thread.createdAt)}
-
-
- );
- })}
-
- {/* New thread within project */}
-
handleNewThread(project.id)}
- >
- + New thread
-
-
- )}
-
- );
- })}
-
- {state.projects.length === 0 && !addingProject && (
-
- No projects yet.
-
- Add one to get started.
-
- )}
-
-
- {/* Add project form */}
- {addingProject ? (
-
-
- Add project
-
-
setNewCwd(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleAddProject();
- if (e.key === "Escape") setAddingProject(false);
- }}
- />
- {isElectron && api && (
-
void handlePickFolder()}
- disabled={isPickingFolder || isAddingProject}
- >
- {isPickingFolder ? "Picking folder..." : "Browse for folder"}
-
- )}
-
-
- {isAddingProject ? "Adding..." : "Add"}
-
- setAddingProject(false)}
- >
- Cancel
-
-
-
- ) : (
-
- setAddingProject(true)}
- >
- + Add project
-
-
- )}
-
- );
-}
diff --git a/apps/renderer/src/env.ts b/apps/renderer/src/env.ts
deleted file mode 100644
index 39c54e02314003d86b2c789192c5ea6892a1d949..0000000000000000000000000000000000000000
--- a/apps/renderer/src/env.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * True when running inside the Electron preload bridge, false in a regular browser.
- * The preload script sets window.nativeApi via contextBridge before any renderer
- * code executes, so this is reliable at module load time.
- */
-export const isElectron =
- typeof window !== "undefined" &&
- (window.desktopBridge !== undefined || window.nativeApi !== undefined);
diff --git a/apps/renderer/src/historyBootstrap.test.ts b/apps/renderer/src/historyBootstrap.test.ts
deleted file mode 100644
index eb3b6929d62188fcdb3c0a4d0b5cc95882a28f09..0000000000000000000000000000000000000000
--- a/apps/renderer/src/historyBootstrap.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { buildBootstrapInput } from "./historyBootstrap";
-
-describe("buildBootstrapInput", () => {
- it("includes full transcript when under budget", () => {
- const result = buildBootstrapInput(
- [
- {
- id: "u-1",
- role: "user",
- text: "hello",
- createdAt: "2026-02-09T00:00:00.000Z",
- streaming: false,
- },
- {
- id: "a-1",
- role: "assistant",
- text: "world",
- createdAt: "2026-02-09T00:00:01.000Z",
- streaming: false,
- },
- ],
- "what's next?",
- 1_500,
- );
-
- expect(result.includedCount).toBe(2);
- expect(result.omittedCount).toBe(0);
- expect(result.truncated).toBe(false);
- expect(result.text).toContain("USER:\nhello");
- expect(result.text).toContain("ASSISTANT:\nworld");
- expect(result.text).toContain("Latest user request (answer this now):");
- expect(result.text).toContain("what's next?");
- });
-
- it("truncates older transcript messages when over budget", () => {
- const result = buildBootstrapInput(
- [
- {
- id: "u-1",
- role: "user",
- text: "first question with details",
- createdAt: "2026-02-09T00:00:00.000Z",
- streaming: false,
- },
- {
- id: "a-1",
- role: "assistant",
- text: "first answer with details",
- createdAt: "2026-02-09T00:00:01.000Z",
- streaming: false,
- },
- {
- id: "u-2",
- role: "user",
- text: "second question with details",
- createdAt: "2026-02-09T00:00:02.000Z",
- streaming: false,
- },
- ],
- "final request",
- 320,
- );
-
- expect(result.truncated).toBe(true);
- expect(result.omittedCount).toBeGreaterThan(0);
- expect(result.includedCount).toBeLessThan(3);
- expect(result.text).toContain("omitted to stay within input limits");
- expect(result.text.length).toBeLessThanOrEqual(320);
- });
-
- it("preserves the latest prompt when prompt-only fallback is required", () => {
- const latestPrompt = "Please keep this exact latest prompt.";
- const result = buildBootstrapInput(
- [
- {
- id: "u-1",
- role: "user",
- text: "old context",
- createdAt: "2026-02-09T00:00:00.000Z",
- streaming: false,
- },
- ],
- latestPrompt,
- latestPrompt.length + 3,
- );
-
- expect(result.text).toBe(latestPrompt);
- expect(result.includedCount).toBe(0);
- expect(result.omittedCount).toBe(1);
- expect(result.truncated).toBe(true);
- });
-});
diff --git a/apps/renderer/src/historyBootstrap.ts b/apps/renderer/src/historyBootstrap.ts
deleted file mode 100644
index 6d0f504d33fa536cb8371bfaef8adb294f566a2a..0000000000000000000000000000000000000000
--- a/apps/renderer/src/historyBootstrap.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type { ChatMessage } from "./types";
-
-export interface BootstrapInputResult {
- text: string;
- includedCount: number;
- omittedCount: number;
- truncated: boolean;
-}
-
-const BOOTSTRAP_PREAMBLE =
- "Continue this conversation using the transcript context below. The final section is the latest user request to answer now.";
-const TRANSCRIPT_HEADER = "Transcript context:";
-const LATEST_PROMPT_HEADER = "Latest user request (answer this now):";
-const OMITTED_SUMMARY = (count: number) =>
- `[${count} earlier message(s) omitted to stay within input limits.]`;
-
-function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" {
- return message.role === "assistant" ? "ASSISTANT" : "USER";
-}
-
-function buildMessageBlock(message: ChatMessage): string {
- return `${messageRoleLabel(message)}:\n${message.text}`;
-}
-
-function finalizeWithPrompt(
- transcriptBody: string,
- latestPrompt: string,
- maxChars: number,
-): string | null {
- const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`;
- return text.length <= maxChars ? text : null;
-}
-
-export function buildBootstrapInput(
- previousMessages: ChatMessage[],
- latestPrompt: string,
- maxChars: number,
-): BootstrapInputResult {
- const budget = Number.isFinite(maxChars)
- ? Math.max(1, Math.floor(maxChars))
- : 1;
- const promptOnly =
- latestPrompt.length <= budget
- ? latestPrompt
- : latestPrompt.slice(0, budget);
-
- if (previousMessages.length === 0) {
- return {
- text: promptOnly,
- includedCount: 0,
- omittedCount: 0,
- truncated: promptOnly.length !== latestPrompt.length,
- };
- }
-
- const newestFirstBlocks: string[] = [];
- for (let index = previousMessages.length - 1; index >= 0; index -= 1) {
- const message = previousMessages[index];
- if (!message) continue;
- newestFirstBlocks.push(buildMessageBlock(message));
- }
-
- if (newestFirstBlocks.length === 0) {
- return {
- text: promptOnly,
- includedCount: 0,
- omittedCount: previousMessages.length,
- truncated: true,
- };
- }
-
- // Include a contiguous suffix from newest to oldest, then reverse to chronological.
- let includedNewestFirst: string[] = [];
- for (const block of newestFirstBlocks) {
- const nextNewestFirst = [...includedNewestFirst, block];
- const nextChronological = [...nextNewestFirst].reverse();
- const omittedCount = newestFirstBlocks.length - nextChronological.length;
- const transcriptBody =
- omittedCount > 0
- ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}`
- : nextChronological.join("\n\n");
- if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) {
- break;
- }
- includedNewestFirst = nextNewestFirst;
- }
-
- let includedChronological = [...includedNewestFirst].reverse();
- while (true) {
- const omittedCount =
- newestFirstBlocks.length - includedChronological.length;
- const transcriptBody =
- omittedCount > 0
- ? includedChronological.length > 0
- ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}`
- : OMITTED_SUMMARY(omittedCount)
- : includedChronological.join("\n\n");
- const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget);
- if (finalized) {
- return {
- text: finalized,
- includedCount: includedChronological.length,
- omittedCount,
- truncated:
- omittedCount > 0 || latestPrompt.length !== promptOnly.length,
- };
- }
-
- if (includedChronological.length === 0) {
- return {
- text: promptOnly,
- includedCount: 0,
- omittedCount: previousMessages.length,
- truncated: true,
- };
- }
-
- includedChronological = includedChronological.slice(1);
- }
-}
diff --git a/apps/renderer/src/hooks/useTheme.ts b/apps/renderer/src/hooks/useTheme.ts
deleted file mode 100644
index 4099cdc91212557eb630cd9d673d0f22c1ae018c..0000000000000000000000000000000000000000
--- a/apps/renderer/src/hooks/useTheme.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useCallback, useEffect, useSyncExternalStore } from "react";
-
-type Theme = "light" | "dark" | "system";
-
-const STORAGE_KEY = "t3code:theme";
-const MEDIA_QUERY = "(prefers-color-scheme: dark)";
-
-let listeners: Array<() => void> = [];
-function emitChange() {
- for (const listener of listeners) listener();
-}
-
-function getSystemDark(): boolean {
- return window.matchMedia(MEDIA_QUERY).matches;
-}
-
-function getStored(): Theme {
- const raw = localStorage.getItem(STORAGE_KEY);
- if (raw === "light" || raw === "dark" || raw === "system") return raw;
- return "system";
-}
-
-function applyTheme(theme: Theme, suppressTransitions = false) {
- if (suppressTransitions) {
- document.documentElement.classList.add("no-transitions");
- }
- const isDark = theme === "dark" || (theme === "system" && getSystemDark());
- document.documentElement.classList.toggle("dark", isDark);
- if (suppressTransitions) {
- // Force a reflow so the no-transitions class takes effect before removal
- document.documentElement.offsetHeight;
- requestAnimationFrame(() => {
- document.documentElement.classList.remove("no-transitions");
- });
- }
-}
-
-// Apply immediately on module load to prevent flash
-applyTheme(getStored());
-
-function getSnapshot(): Theme {
- return getStored();
-}
-
-function subscribe(listener: () => void): () => void {
- listeners.push(listener);
-
- // Listen for system preference changes
- const mq = window.matchMedia(MEDIA_QUERY);
- const handleChange = () => {
- if (getStored() === "system") applyTheme("system", true);
- emitChange();
- };
- mq.addEventListener("change", handleChange);
-
- // Listen for storage changes from other tabs
- const handleStorage = (e: StorageEvent) => {
- if (e.key === STORAGE_KEY) {
- applyTheme(getStored(), true);
- emitChange();
- }
- };
- window.addEventListener("storage", handleStorage);
-
- return () => {
- listeners = listeners.filter((l) => l !== listener);
- mq.removeEventListener("change", handleChange);
- window.removeEventListener("storage", handleStorage);
- };
-}
-
-export function useTheme() {
- const theme = useSyncExternalStore(subscribe, getSnapshot);
-
- const resolvedTheme: "light" | "dark" =
- theme === "system" ? (getSystemDark() ? "dark" : "light") : theme;
-
- const setTheme = useCallback((next: Theme) => {
- localStorage.setItem(STORAGE_KEY, next);
- applyTheme(next, true);
- emitChange();
- }, []);
-
- // Keep DOM in sync on mount/change
- useEffect(() => {
- applyTheme(theme);
- }, [theme]);
-
- return { theme, setTheme, resolvedTheme } as const;
-}
diff --git a/apps/renderer/src/index.css b/apps/renderer/src/index.css
deleted file mode 100644
index 96a2093aef5d198d85d73f3c4ebce2654a77b0d0..0000000000000000000000000000000000000000
--- a/apps/renderer/src/index.css
+++ /dev/null
@@ -1,340 +0,0 @@
-@import "tailwindcss";
-
-@custom-variant dark (&:is(.dark, .dark *));
-
-@theme inline {
- --animate-skeleton: skeleton 2s -1s infinite linear;
- --color-warning-foreground: var(--warning-foreground);
- --color-warning: var(--warning);
- --color-success-foreground: var(--success-foreground);
- --color-success: var(--success);
- --color-info-foreground: var(--info-foreground);
- --color-info: var(--info);
- --color-destructive-foreground: var(--destructive-foreground);
- --color-sidebar-ring: var(--sidebar-ring);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar: var(--sidebar);
- --color-chart-5: var(--chart-5);
- --color-chart-4: var(--chart-4);
- --color-chart-3: var(--chart-3);
- --color-chart-2: var(--chart-2);
- --color-chart-1: var(--chart-1);
- --color-ring: var(--ring);
- --color-input: var(--input);
- --color-border: var(--border);
- --color-destructive: var(--destructive);
- --color-accent-foreground: var(--accent-foreground);
- --color-accent: var(--accent);
- --color-muted-foreground: var(--muted-foreground);
- --color-muted: var(--muted);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-secondary: var(--secondary);
- --color-primary-foreground: var(--primary-foreground);
- --color-primary: var(--primary);
- --color-popover-foreground: var(--popover-foreground);
- --color-popover: var(--popover);
- --color-card-foreground: var(--card-foreground);
- --color-card: var(--card);
- --color-foreground: var(--foreground);
- --color-background: var(--background);
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
- --radius-2xl: calc(var(--radius) + 8px);
- --radius-3xl: calc(var(--radius) + 12px);
- --radius-4xl: calc(var(--radius) + 16px);
- @keyframes skeleton {
- to {
- background-position: -200% 0;
- }
- }
-}
-
-@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
- }
-}
-
-/* Suppress all transitions during theme changes */
-.no-transitions,
-.no-transitions *,
-.no-transitions *::before,
-.no-transitions *::after {
- transition-duration: 0s !important;
- animation-duration: 0s !important;
-}
-
-:root {
- color-scheme: light;
- --radius: 0.625rem;
- --background: var(--color-white);
- --foreground: var(--color-neutral-800);
- --card: var(--color-white);
- --card-foreground: var(--color-neutral-800);
- --popover: var(--color-white);
- --popover-foreground: var(--color-neutral-800);
- --primary: oklch(0.488 0.217 264);
- --primary-foreground: var(--color-white);
- --secondary: --alpha(var(--color-black) / 4%);
- --secondary-foreground: var(--color-neutral-800);
- --muted: --alpha(var(--color-black) / 4%);
- --muted-foreground: color-mix(
- in srgb,
- var(--color-neutral-500) 90%,
- var(--color-black)
- );
- --accent: --alpha(var(--color-black) / 5%);
- --accent-foreground: var(--color-neutral-800);
- --destructive: var(--color-red-500);
- --border: --alpha(var(--color-black) / 8%);
- --input: --alpha(var(--color-black) / 10%);
- --ring: oklch(0.488 0.217 264);
- --chart-1: oklch(0.646 0.222 41.116);
- --chart-2: oklch(0.6 0.118 184.704);
- --chart-3: oklch(0.398 0.07 227.392);
- --chart-4: oklch(0.828 0.189 84.429);
- --chart-5: oklch(0.769 0.188 70.08);
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.141 0.005 285.823);
- --sidebar-primary: oklch(0.21 0.006 285.885);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.967 0.001 286.375);
- --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
- --sidebar-border: oklch(0.92 0.004 286.32);
- --sidebar-ring: oklch(0.705 0.015 286.067);
- --destructive-foreground: var(--color-red-700);
- --info: var(--color-blue-500);
- --info-foreground: var(--color-blue-700);
- --success: var(--color-emerald-500);
- --success-foreground: var(--color-emerald-700);
- --warning: var(--color-amber-500);
- --warning-foreground: var(--color-amber-700);
-
- @variant dark {
- color-scheme: dark;
- --background: color-mix(
- in srgb,
- var(--color-neutral-950) 95%,
- var(--color-white)
- );
- --foreground: var(--color-neutral-100);
- --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
- --card-foreground: var(--color-neutral-100);
- --popover: color-mix(in srgb, var(--background) 98%, var(--color-white));
- --popover-foreground: var(--color-neutral-100);
- --primary: oklch(0.588 0.217 264);
- --primary-foreground: var(--color-white);
- --secondary: --alpha(var(--color-white) / 4%);
- --secondary-foreground: var(--color-neutral-100);
- --muted: --alpha(var(--color-white) / 4%);
- --muted-foreground: color-mix(
- in srgb,
- var(--color-neutral-500) 90%,
- var(--color-white)
- );
- --accent: --alpha(var(--color-white) / 4%);
- --accent-foreground: var(--color-neutral-100);
- --destructive: color-mix(
- in srgb,
- var(--color-red-500) 90%,
- var(--color-white)
- );
- --border: --alpha(var(--color-white) / 6%);
- --input: --alpha(var(--color-white) / 8%);
- --ring: oklch(0.588 0.217 264);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
- --sidebar: oklch(0.21 0.006 285.885);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.274 0.006 286.033);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.552 0.016 285.938);
- --destructive-foreground: var(--color-red-400);
- --info: var(--color-blue-500);
- --info-foreground: var(--color-blue-400);
- --success: var(--color-emerald-500);
- --success-foreground: var(--color-emerald-400);
- --warning: var(--color-amber-500);
- --warning-foreground: var(--color-amber-400);
- }
-}
-
-body {
- font-family:
- "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
- margin: 0;
- padding: 0;
- min-height: 100vh;
-}
-
-body::after {
- content: "";
- position: fixed;
- inset: 0;
- pointer-events: none;
- opacity: 0.035;
- 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");
- background-repeat: repeat;
- background-size: 256px 256px;
-}
-
-pre,
-code,
-textarea,
-input {
- font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
-}
-
-/* Window drag region (frameless titlebar) */
-.drag-region {
- -webkit-app-region: drag;
-}
-
-.drag-region button,
-.drag-region input,
-.drag-region textarea,
-.drag-region select,
-.drag-region a {
- -webkit-app-region: no-drag;
-}
-
-/* Scrollbar styling */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: rgba(0, 0, 0, 0.15);
- border-radius: 3px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: rgba(0, 0, 0, 0.25);
-}
-
-.dark ::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.1);
-}
-
-.dark ::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.18);
-}
-
-/* Reasoning select -- clickable label surface */
-label:has(> select#reasoning-effort) {
- position: relative;
-}
-label:has(> select#reasoning-effort) select {
- position: absolute;
- inset: 0;
- opacity: 0;
- cursor: pointer;
- width: 100%;
- height: 100%;
-}
-
-/* Chat markdown rendering */
-.chat-markdown {
- overflow-wrap: anywhere;
-}
-
-.chat-markdown > :first-child {
- margin-top: 0;
-}
-
-.chat-markdown > :last-child {
- margin-bottom: 0;
-}
-
-.chat-markdown p,
-.chat-markdown ul,
-.chat-markdown ol,
-.chat-markdown blockquote,
-.chat-markdown pre,
-.chat-markdown table {
- margin: 0.65rem 0;
-}
-
-.chat-markdown ul,
-.chat-markdown ol {
- padding-left: 1.25rem;
-}
-
-.chat-markdown li + li {
- margin-top: 0.25rem;
-}
-
-.chat-markdown a {
- color: var(--info-foreground);
- text-decoration: underline;
- text-decoration-color: color-mix(
- in srgb,
- var(--info-foreground) 40%,
- transparent
- );
-}
-
-.chat-markdown a:hover {
- opacity: 0.8;
-}
-
-.chat-markdown blockquote {
- border-left: 2px solid var(--border);
- padding-left: 0.8rem;
- color: var(--muted-foreground);
-}
-
-.chat-markdown :not(pre) > code {
- border: 1px solid var(--border);
- border-radius: 0.375rem;
- background: var(--muted);
- padding: 0.1rem 0.35rem;
- color: var(--foreground);
-}
-
-.chat-markdown pre {
- overflow-x: auto;
- border: 1px solid var(--border);
- border-radius: 0.75rem;
- background: var(--muted);
- padding: 0.8rem 0.9rem;
-}
-
-.chat-markdown pre code {
- border: none;
- background: transparent;
- padding: 0;
- line-height: 1.5;
-}
-
-.chat-markdown table {
- width: 100%;
- border-collapse: collapse;
-}
-
-.chat-markdown th,
-.chat-markdown td {
- border: 1px solid var(--border);
- padding: 0.35rem 0.45rem;
- text-align: left;
-}
diff --git a/apps/renderer/src/main.tsx b/apps/renderer/src/main.tsx
deleted file mode 100644
index 7d73b7ad2d06c4f8d6373b59e8b3ee97c127cbe6..0000000000000000000000000000000000000000
--- a/apps/renderer/src/main.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import "highlight.js/styles/github-dark.css";
-
-import App from "./App";
-import "./index.css";
-
-ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
- ,
-);
diff --git a/apps/renderer/src/model-logic.test.ts b/apps/renderer/src/model-logic.test.ts
deleted file mode 100644
index 91222e0f8d2613e724b4d864a24a65e13d6f57bd..0000000000000000000000000000000000000000
--- a/apps/renderer/src/model-logic.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";
-
-describe("normalizeModelSlug", () => {
- it("maps 5.3 aliases to canonical codex slug", () => {
- expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
- expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
- });
-
- it("preserves non-aliased slugs", () => {
- expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
- expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
- });
-
- it("returns null for empty input", () => {
- expect(normalizeModelSlug("")).toBeNull();
- expect(normalizeModelSlug(" ")).toBeNull();
- expect(normalizeModelSlug(null)).toBeNull();
- expect(normalizeModelSlug(undefined)).toBeNull();
- });
-});
-
-describe("resolveModelSlug", () => {
- it("falls back to default for unknown models", () => {
- expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
- });
-
- it("resolves supported hard-coded models", () => {
- expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
- expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
- expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2");
- });
-});
diff --git a/apps/renderer/src/model-logic.ts b/apps/renderer/src/model-logic.ts
deleted file mode 100644
index 72f615b188d55c5d1728bc0369d29000b502e6e2..0000000000000000000000000000000000000000
--- a/apps/renderer/src/model-logic.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export {
- DEFAULT_MODEL,
- MODEL_OPTIONS,
- normalizeModelSlug,
- resolveModelSlug,
-} from "@t3tools/contracts";
-
-export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
-export const DEFAULT_REASONING = "high";
diff --git a/apps/renderer/src/persistenceSchema.test.ts b/apps/renderer/src/persistenceSchema.test.ts
deleted file mode 100644
index 5ed9a4900ba5ae86d7c347825bebc4b69df88578..0000000000000000000000000000000000000000
--- a/apps/renderer/src/persistenceSchema.test.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { DEFAULT_MODEL } from "./model-logic";
-import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
-import type { Thread } from "./types";
-
-describe("hydratePersistedState", () => {
- it("returns null for invalid payloads", () => {
- expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull();
- expect(hydratePersistedState("not-json", false)).toBeNull();
- });
-
- it("migrates the legacy default model to the current default", () => {
- const payload = JSON.stringify({
- projects: [
- {
- id: "p-1",
- name: "Project",
- cwd: "/tmp/project",
- model: "gpt-5.2-codex",
- expanded: true,
- },
- ],
- threads: [
- {
- id: "t-1",
- projectId: "p-1",
- title: "Thread",
- model: "gpt-5.2-codex",
- messages: [
- {
- id: "m-1",
- role: "assistant",
- text: "Hello",
- createdAt: "2026-02-08T10:00:00.000Z",
- streaming: true,
- },
- ],
- createdAt: "2026-02-08T10:00:00.000Z",
- },
- ],
- activeThreadId: "t-1",
- });
-
- const hydrated = hydratePersistedState(payload, true);
- expect(hydrated).not.toBeNull();
- expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
- expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
- expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
- expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
- expect(hydrated?.runtimeMode).toBe("full-access");
- });
-
- it("filters unknown project references and repairs active thread", () => {
- const payload = JSON.stringify({
- projects: [
- {
- id: "p-1",
- name: "Project",
- cwd: "/tmp/project",
- model: "gpt-5.3-codex",
- expanded: false,
- },
- ],
- threads: [
- {
- id: "t-1",
- projectId: "p-1",
- title: "Valid thread",
- model: "gpt-5.3-codex",
- messages: [],
- createdAt: "2026-02-08T10:00:00.000Z",
- },
- {
- id: "t-2",
- projectId: "p-missing",
- title: "Dangling thread",
- model: "gpt-5.3-codex",
- messages: [],
- createdAt: "2026-02-08T10:00:00.000Z",
- },
- ],
- activeThreadId: "t-2",
- });
-
- const hydrated = hydratePersistedState(payload, false);
- expect(hydrated).not.toBeNull();
- expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
- expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
- expect(hydrated?.activeThreadId).toBe("t-1");
- expect(hydrated?.runtimeMode).toBe("full-access");
- });
-
- it("hydrates runtime mode from v3 payload", () => {
- const payload = JSON.stringify({
- version: 3,
- runtimeMode: "approval-required",
- projects: [
- {
- id: "p-1",
- name: "Project",
- cwd: "/tmp/project",
- model: "gpt-5.3-codex",
- expanded: false,
- },
- ],
- threads: [],
- activeThreadId: null,
- });
-
- const hydrated = hydratePersistedState(payload, false);
- expect(hydrated?.runtimeMode).toBe("approval-required");
- });
-});
-
-describe("toPersistedState", () => {
- it("writes v4 payload and strips non-persisted thread fields", () => {
- const thread: Thread = {
- id: "t-1",
- codexThreadId: "thr_1",
- projectId: "p-1",
- title: "Thread",
- model: "gpt-5.3-codex",
- session: null,
- messages: [
- {
- id: "m-1",
- role: "user",
- text: "Hi",
- createdAt: "2026-02-08T10:00:00.000Z",
- streaming: false,
- },
- ],
- events: [],
- error: "boom",
- createdAt: "2026-02-08T10:00:00.000Z",
- };
-
- const persisted = toPersistedState({
- projects: [
- {
- id: "p-1",
- name: "Project",
- cwd: "/tmp/project",
- model: "gpt-5.3-codex",
- expanded: true,
- },
- ],
- threads: [thread],
- activeThreadId: "t-1",
- runtimeMode: "full-access",
- });
-
- expect(persisted.version).toBe(4);
- expect(persisted.runtimeMode).toBe("full-access");
- expect(persisted.threads[0]).toEqual({
- id: "t-1",
- codexThreadId: "thr_1",
- projectId: "p-1",
- title: "Thread",
- model: "gpt-5.3-codex",
- messages: thread.messages,
- createdAt: thread.createdAt,
- });
- const persistedThread = persisted.threads[0];
- expect(persistedThread).toBeDefined();
- if (!persistedThread) return;
-
- expect("error" in persistedThread).toBe(false);
- expect("session" in persistedThread).toBe(false);
- });
-});
diff --git a/apps/renderer/src/persistenceSchema.ts b/apps/renderer/src/persistenceSchema.ts
deleted file mode 100644
index e6b4983a21353f7e865bc9c7fdcddf346524bd7c..0000000000000000000000000000000000000000
--- a/apps/renderer/src/persistenceSchema.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { z } from "zod";
-
-import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
-import {
- DEFAULT_RUNTIME_MODE,
- type Project,
- type RuntimeMode,
- type Thread,
-} from "./types";
-
-const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
-
-const persistedProjectSchema = z.object({
- id: z.string().min(1),
- name: z.string().min(1),
- cwd: z.string().min(1),
- model: z.string().min(1),
- expanded: z.boolean(),
-});
-
-const persistedMessageSchema = z.object({
- id: z.string().min(1),
- role: z.enum(["user", "assistant"]),
- text: z.string(),
- createdAt: z.string().min(1),
- streaming: z.boolean(),
-});
-
-const persistedThreadSchema = z.object({
- id: z.string().min(1),
- codexThreadId: z.string().min(1).nullable().default(null),
- projectId: z.string().min(1),
- title: z.string().min(1),
- model: z.string().min(1),
- messages: z.array(persistedMessageSchema),
- createdAt: z.string().min(1),
-});
-
-const persistedStateBodySchema = z.object({
- projects: z.array(persistedProjectSchema),
- threads: z.array(persistedThreadSchema),
- activeThreadId: z.string().min(1).nullable(),
-});
-
-const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
-
-export const persistedStateV1Schema = persistedStateBodySchema.extend({
- version: z.literal(1).optional(),
-});
-
-export const persistedStateV2Schema = persistedStateBodySchema.extend({
- version: z.literal(2).optional(),
-});
-
-export const persistedStateV3Schema = persistedStateBodySchema.extend({
- runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
- version: z.literal(3).optional(),
-});
-
-export const persistedStateV4Schema = persistedStateBodySchema.extend({
- runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
- version: z.literal(4).optional(),
-});
-
-const persistedStateSchema = z.union([
- persistedStateV4Schema,
- persistedStateV3Schema,
- persistedStateV2Schema,
- persistedStateV1Schema,
-]);
-
-export interface PersistedStoreSnapshot {
- projects: Project[];
- threads: Thread[];
- activeThreadId: string | null;
- runtimeMode: RuntimeMode;
-}
-
-function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string {
- if (!isLegacyPayload) {
- return model;
- }
-
- return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model;
-}
-
-function hydrateProject(
- project: z.infer,
- isLegacyPayload: boolean,
-): Project {
- return {
- ...project,
- model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),
- };
-}
-
-function hydrateThread(
- thread: z.infer,
- isLegacyPayload: boolean,
-): Thread {
- return {
- id: thread.id,
- codexThreadId: thread.codexThreadId,
- projectId: thread.projectId,
- title: thread.title,
- model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
- session: null,
- messages: thread.messages.map((message) => ({
- ...message,
- streaming: false,
- })),
- events: [],
- error: null,
- createdAt: thread.createdAt,
- };
-}
-
-export function hydratePersistedState(
- raw: string,
- isLegacyPayload: boolean,
-): PersistedStoreSnapshot | null {
- let parsedJson: unknown;
- try {
- parsedJson = JSON.parse(raw);
- } catch {
- return null;
- }
-
- const parsedState = persistedStateSchema.safeParse(parsedJson);
- if (!parsedState.success) {
- return null;
- }
-
- const projects = parsedState.data.projects.map((project) =>
- hydrateProject(project, isLegacyPayload),
- );
- const projectIds = new Set(projects.map((project) => project.id));
- const threads = parsedState.data.threads
- .map((thread) => hydrateThread(thread, isLegacyPayload))
- .filter((thread) => projectIds.has(thread.projectId));
- const hasActiveThread = Boolean(
- parsedState.data.activeThreadId &&
- threads.some((thread) => thread.id === parsedState.data.activeThreadId),
- );
-
- return {
- projects,
- threads,
- activeThreadId: hasActiveThread
- ? parsedState.data.activeThreadId
- : (threads[0]?.id ?? null),
- runtimeMode:
- "runtimeMode" in parsedState.data
- ? parsedState.data.runtimeMode
- : DEFAULT_RUNTIME_MODE,
- };
-}
-
-export function toPersistedState(
- state: PersistedStoreSnapshot,
-): z.infer {
- return {
- version: 4,
- projects: state.projects,
- threads: state.threads.map((thread) => ({
- id: thread.id,
- codexThreadId: thread.codexThreadId,
- projectId: thread.projectId,
- title: thread.title,
- model: thread.model,
- messages: thread.messages,
- createdAt: thread.createdAt,
- })),
- activeThreadId: state.activeThreadId,
- runtimeMode: state.runtimeMode,
- };
-}
diff --git a/apps/renderer/src/session-logic.test.ts b/apps/renderer/src/session-logic.test.ts
deleted file mode 100644
index 40fea5d77a7994e30bb5a5904b783478511013f2..0000000000000000000000000000000000000000
--- a/apps/renderer/src/session-logic.test.ts
+++ /dev/null
@@ -1,632 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
-import {
- type WorkLogEntry,
- applyEventToMessages,
- deriveTimelineEntries,
- deriveWorkLogEntries,
- evolveSession,
-} from "./session-logic";
-import type { ChatMessage } from "./types";
-
-function makeEvent(overrides: Partial): ProviderEvent {
- return {
- id: "evt-1",
- kind: "notification",
- provider: "codex",
- sessionId: "sess-1",
- createdAt: "2026-02-08T10:00:00.000Z",
- method: "noop",
- ...overrides,
- };
-}
-
-function makeSession(overrides: Partial = {}): ProviderSession {
- return {
- sessionId: "sess-1",
- provider: "codex",
- status: "ready",
- createdAt: "2026-02-08T09:59:00.000Z",
- updatedAt: "2026-02-08T09:59:00.000Z",
- ...overrides,
- };
-}
-
-describe("deriveTimelineEntries", () => {
- it("interleaves messages and work entries by timestamp", () => {
- const messages: ChatMessage[] = [
- {
- id: "m-user",
- role: "user",
- text: "Hi",
- createdAt: "2026-02-08T10:00:00.000Z",
- streaming: false,
- },
- {
- id: "m-assistant",
- role: "assistant",
- text: "Hello",
- createdAt: "2026-02-08T10:05:00.000Z",
- streaming: false,
- },
- ];
- const workEntries: WorkLogEntry[] = [
- {
- id: "w-1",
- label: "Tool call",
- createdAt: "2026-02-08T10:02:00.000Z",
- tone: "tool",
- },
- {
- id: "w-2",
- label: "Preamble",
- createdAt: "2026-02-08T10:03:00.000Z",
- tone: "thinking",
- },
- ];
-
- const timeline = deriveTimelineEntries(messages, workEntries);
-
- expect(timeline.map((entry) => entry.id)).toEqual([
- "message:m-user",
- "work:w-1",
- "work:w-2",
- "message:m-assistant",
- ]);
- });
-
- it("prefers work entries when timestamps are equal", () => {
- const messages: ChatMessage[] = [
- {
- id: "m-1",
- role: "assistant",
- text: "Done",
- createdAt: "2026-02-08T10:00:00.000Z",
- streaming: false,
- },
- ];
- const workEntries: WorkLogEntry[] = [
- {
- id: "w-1",
- label: "Tool call",
- createdAt: "2026-02-08T10:00:00.000Z",
- tone: "tool",
- },
- ];
-
- const timeline = deriveTimelineEntries(messages, workEntries);
-
- expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]);
- });
-});
-
-describe("deriveWorkLogEntries", () => {
- it("drops preamble/work events from the visible work log", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-1",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:00.000Z",
- payload: { item: { type: "preamble", text: "thinking" } },
- }),
- makeEvent({
- id: "evt-2",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:01.000Z",
- payload: { item: { type: "work", text: "planning" } },
- }),
- makeEvent({
- id: "evt-3",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:02.000Z",
- payload: { item: { type: "tool_call", command: "ls -la" } },
- }),
- ],
- "turn-1",
- );
-
- expect(entries).toHaveLength(1);
- expect(entries[0]?.label).toBe("Tool call");
- });
-
- it("does not surface successful turn completion as a work-log row", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-start",
- method: "turn/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:00.000Z",
- }),
- makeEvent({
- id: "evt-complete",
- method: "turn/completed",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:10.000Z",
- payload: { turn: { id: "turn-1", status: "completed" } },
- }),
- ],
- "turn-1",
- );
-
- expect(entries).toHaveLength(0);
- });
-
- it("shows failed turn completion in the work log", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-start",
- method: "turn/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:00.000Z",
- }),
- makeEvent({
- id: "evt-complete",
- method: "turn/completed",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:10.000Z",
- payload: {
- turn: {
- id: "turn-1",
- status: "failed",
- error: { message: "sandbox denied" },
- },
- },
- }),
- ],
- "turn-1",
- );
-
- expect(entries).toHaveLength(1);
- expect(entries[0]?.label).toBe("Turn failed");
- expect(entries[0]?.detail).toBe("sandbox denied");
- });
-
- it("hides reasoning and agent-message noise from the visible work log", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-turn-start",
- method: "turn/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:00.000Z",
- }),
- makeEvent({
- id: "evt-summary-part",
- method: "item/reasoning/summaryPartAdded",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:01.000Z",
- }),
- makeEvent({
- id: "evt-summary-delta",
- method: "item/reasoning/summaryTextDelta",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:02.000Z",
- }),
- makeEvent({
- id: "evt-agent-start",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:03.000Z",
- payload: {
- item: { id: "item-msg", type: "agentMessage", text: "Working..." },
- },
- }),
- makeEvent({
- id: "evt-agent-complete",
- method: "item/completed",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:04.000Z",
- payload: {
- item: {
- id: "item-msg",
- type: "agentMessage",
- text: "Done response",
- },
- },
- }),
- makeEvent({
- id: "evt-tool-start",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:05.000Z",
- payload: {
- item: { id: "item-tool", type: "tool_call", command: "ls -la" },
- },
- }),
- makeEvent({
- id: "evt-tool-complete",
- method: "item/completed",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:06.000Z",
- payload: {
- item: { id: "item-tool", type: "tool_call", command: "ls -la" },
- },
- }),
- makeEvent({
- id: "evt-turn-complete",
- method: "turn/completed",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:08.000Z",
- payload: { turn: { id: "turn-1", status: "completed" } },
- }),
- ],
- "turn-1",
- );
-
- expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]);
- });
-
- it("coalesces command start/completed lifecycle into one entry", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-command-start",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:01.000Z",
- payload: {
- item: {
- id: "item-command",
- type: "command_execution",
- command: "git status --short",
- },
- },
- }),
- makeEvent({
- id: "evt-command-complete",
- method: "item/completed",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:02.000Z",
- payload: {
- item: {
- id: "item-command",
- type: "command_execution",
- command: "git status --short",
- },
- },
- }),
- ],
- "turn-1",
- );
-
- expect(entries).toHaveLength(1);
- expect(entries[0]?.label).toBe("Command run");
- expect(entries[0]?.detail).toBe("git status --short");
- });
-
- it("preserves full tool-call detail text without data truncation", () => {
- const longCommand =
- '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"';
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-tool-start",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:05.000Z",
- payload: {
- item: { id: "item-tool", type: "tool_call", command: longCommand },
- },
- }),
- ],
- "turn-1",
- );
-
- expect(entries[0]?.detail).toBe(longCommand);
- });
-
- it("hides generic tool-call rows that have no identifying detail", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-tool-start",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:05.000Z",
- payload: {
- item: { id: "item-tool", type: "tool_call" },
- },
- }),
- ],
- "turn-1",
- );
-
- expect(entries).toHaveLength(0);
- });
-
- it("includes collab tool-call rows with tool name detail", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-collab-start",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:05.000Z",
- payload: {
- item: {
- id: "item-collab",
- type: "collabAgentToolCall",
- tool: "spawnAgent",
- },
- },
- }),
- ],
- "turn-1",
- );
-
- expect(entries).toHaveLength(1);
- expect(entries[0]?.label).toBe("Tool call");
- expect(entries[0]?.detail).toBe("spawnAgent");
- });
-
- it("can derive work-log entries across all turns when not scoped", () => {
- const entries = deriveWorkLogEntries(
- [
- makeEvent({
- id: "evt-turn-1-tool",
- method: "item/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:00:01.000Z",
- payload: { item: { type: "tool_call", command: "ls -la" } },
- }),
- makeEvent({
- id: "evt-turn-2-tool",
- method: "item/started",
- turnId: "turn-2",
- createdAt: "2026-02-08T10:00:02.000Z",
- payload: { item: { type: "tool_call", command: "pwd" } },
- }),
- ],
- undefined,
- );
-
- expect(entries).toHaveLength(2);
- expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]);
- });
-});
-
-describe("evolveSession", () => {
- it("updates thread id when thread starts", () => {
- const previous = makeSession();
- const next = evolveSession(
- previous,
- makeEvent({
- method: "thread/started",
- createdAt: "2026-02-08T10:01:00.000Z",
- payload: { thread: { id: "thread-1" } },
- }),
- );
-
- expect(next.threadId).toBe("thread-1");
- expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z");
- });
-
- it("moves to running and records active turn on turn start", () => {
- const previous = makeSession();
- const next = evolveSession(
- previous,
- makeEvent({
- method: "turn/started",
- turnId: "turn-1",
- createdAt: "2026-02-08T10:02:00.000Z",
- }),
- );
-
- expect(next.status).toBe("running");
- expect(next.activeTurnId).toBe("turn-1");
- expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z");
- });
-
- it("returns to ready and clears active turn on successful completion", () => {
- const previous = makeSession({
- status: "running",
- activeTurnId: "turn-1",
- lastError: "older error",
- });
- const next = evolveSession(
- previous,
- makeEvent({
- method: "turn/completed",
- createdAt: "2026-02-08T10:03:00.000Z",
- payload: { turn: { id: "turn-1", status: "completed" } },
- }),
- );
-
- expect(next.status).toBe("ready");
- expect(next.activeTurnId).toBeUndefined();
- expect(next.lastError).toBe("older error");
- });
-
- it("marks session as error when turn fails", () => {
- const previous = makeSession({
- status: "running",
- activeTurnId: "turn-1",
- });
- const next = evolveSession(
- previous,
- makeEvent({
- method: "turn/completed",
- createdAt: "2026-02-08T10:03:00.000Z",
- payload: {
- turn: { id: "turn-1", status: "failed", error: { message: "boom" } },
- },
- }),
- );
-
- expect(next.status).toBe("error");
- expect(next.activeTurnId).toBeUndefined();
- expect(next.lastError).toBe("boom");
- });
-
- it("moves to error on runtime error events", () => {
- const previous = makeSession();
- const next = evolveSession(
- previous,
- makeEvent({
- kind: "error",
- method: "runtime/error",
- createdAt: "2026-02-08T10:04:00.000Z",
- message: "runtime failure",
- }),
- );
-
- expect(next.status).toBe("error");
- expect(next.lastError).toBe("runtime failure");
- });
-
- it("closes session on close lifecycle events", () => {
- const previous = makeSession({
- status: "running",
- activeTurnId: "turn-1",
- });
- const next = evolveSession(
- previous,
- makeEvent({
- method: "session/closed",
- createdAt: "2026-02-08T10:05:00.000Z",
- message: "closed",
- }),
- );
-
- expect(next.status).toBe("closed");
- expect(next.activeTurnId).toBeUndefined();
- expect(next.lastError).toBe("closed");
- });
-});
-
-describe("applyEventToMessages", () => {
- it("handles start/delta/completed flow for assistant messages", () => {
- const activeAssistantItemRef = { current: null as string | null };
- const started = applyEventToMessages(
- [],
- makeEvent({
- method: "item/started",
- createdAt: "2026-02-08T10:00:00.000Z",
- payload: {
- item: {
- id: "item-1",
- type: "agentMessage",
- text: "Hello",
- },
- },
- }),
- activeAssistantItemRef,
- );
-
- expect(started).toEqual([
- {
- id: "item-1",
- role: "assistant",
- text: "Hello",
- createdAt: "2026-02-08T10:00:00.000Z",
- streaming: true,
- },
- ]);
- expect(activeAssistantItemRef.current).toBe("item-1");
-
- const withDelta = applyEventToMessages(
- started,
- makeEvent({
- method: "item/agentMessage/delta",
- createdAt: "2026-02-08T10:00:01.000Z",
- itemId: "item-1",
- textDelta: " world",
- }),
- activeAssistantItemRef,
- );
- expect(withDelta[0]?.text).toBe("Hello world");
- expect(withDelta[0]?.streaming).toBe(true);
-
- const completed = applyEventToMessages(
- withDelta,
- makeEvent({
- method: "item/completed",
- createdAt: "2026-02-08T10:00:02.000Z",
- payload: {
- item: {
- id: "item-1",
- type: "agentMessage",
- text: "Hello world!",
- },
- },
- }),
- activeAssistantItemRef,
- );
- expect(completed[0]?.text).toBe("Hello world!");
- expect(completed[0]?.streaming).toBe(false);
- expect(activeAssistantItemRef.current).toBeNull();
- });
-
- it("supports out-of-order delta before started/completed", () => {
- const activeAssistantItemRef = { current: null as string | null };
- const withDelta = applyEventToMessages(
- [],
- makeEvent({
- method: "item/agentMessage/delta",
- createdAt: "2026-02-08T10:00:01.000Z",
- itemId: "item-2",
- textDelta: "Partial",
- }),
- activeAssistantItemRef,
- );
- expect(withDelta[0]?.id).toBe("item-2");
- expect(withDelta[0]?.text).toBe("Partial");
- expect(withDelta[0]?.streaming).toBe(true);
- expect(activeAssistantItemRef.current).toBe("item-2");
-
- const completed = applyEventToMessages(
- withDelta,
- makeEvent({
- method: "item/completed",
- createdAt: "2026-02-08T10:00:02.000Z",
- payload: {
- item: {
- id: "item-2",
- type: "agentMessage",
- text: "Partial + final",
- },
- },
- }),
- activeAssistantItemRef,
- );
-
- expect(completed[0]?.text).toBe("Partial + final");
- expect(completed[0]?.streaming).toBe(false);
- });
-
- it("clears all streaming flags when turn completes", () => {
- const previous: ChatMessage[] = [
- {
- id: "m-user",
- role: "user",
- text: "Hi",
- createdAt: "2026-02-08T10:00:00.000Z",
- streaming: false,
- },
- {
- id: "m-assistant",
- role: "assistant",
- text: "Typing",
- createdAt: "2026-02-08T10:00:01.000Z",
- streaming: true,
- },
- ];
-
- const next = applyEventToMessages(
- previous,
- makeEvent({
- method: "turn/completed",
- }),
- { current: null },
- );
-
- expect(next.every((entry) => entry.streaming === false)).toBe(true);
- });
-});
diff --git a/apps/renderer/src/session-logic.ts b/apps/renderer/src/session-logic.ts
deleted file mode 100644
index 9f55647070b8564f842977764261d0aa2469dc28..0000000000000000000000000000000000000000
--- a/apps/renderer/src/session-logic.ts
+++ /dev/null
@@ -1,636 +0,0 @@
-import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
-import type { ChatMessage, SessionPhase } from "./types";
-import { createWsNativeApi } from "./wsNativeApi";
-
-export const PROVIDER_OPTIONS: Array<{
- value: ProviderKind;
- label: string;
- available: boolean;
-}> = [
- { value: "codex", label: "Codex", available: true },
- { value: "claudeCode", label: "Claude Code (soon)", available: false },
-];
-
-let cachedApi: NativeApi | undefined;
-
-export function readNativeApi(): NativeApi | undefined {
- if (typeof window === "undefined") return undefined;
- if (cachedApi) return cachedApi;
-
- // Prefer Electron preload bridge if available
- if (window.nativeApi) {
- cachedApi = window.nativeApi;
- return cachedApi;
- }
-
- // Fall back to WebSocket transport
- cachedApi = createWsNativeApi();
- return cachedApi;
-}
-
-export function asObject(value: unknown): Record | undefined {
- if (!value || typeof value !== "object") return undefined;
- return value as Record;
-}
-
-export function asString(value: unknown): string | undefined {
- return typeof value === "string" ? value : undefined;
-}
-
-export function formatTimestamp(isoDate: string): string {
- return new Intl.DateTimeFormat(undefined, {
- hour: "numeric",
- minute: "2-digit",
- second: "2-digit",
- }).format(new Date(isoDate));
-}
-
-export function formatDuration(durationMs: number): string {
- if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
- if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
- if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
- if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
-
- const minutes = Math.floor(durationMs / 60_000);
- const seconds = Math.round((durationMs % 60_000) / 1_000);
- if (seconds === 0) return `${minutes}m`;
- if (seconds === 60) return `${minutes + 1}m`;
- return `${minutes}m ${seconds}s`;
-}
-
-export function formatElapsed(startIso: string, endIso: string | undefined): string | null {
- if (!endIso) return null;
-
- const startedAt = Date.parse(startIso);
- const endedAt = Date.parse(endIso);
- if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
- return null;
- }
-
- return formatDuration(endedAt - startedAt);
-}
-
-export interface WorkLogEntry {
- id: string;
- createdAt: string;
- label: string;
- detail?: string;
- tone: "thinking" | "tool" | "info" | "error";
-}
-
-export type TimelineEntry =
- | {
- id: string;
- kind: "message";
- createdAt: string;
- message: ChatMessage;
- }
- | {
- id: string;
- kind: "work";
- createdAt: string;
- entry: WorkLogEntry;
- };
-
-function normalizeDetail(value: string | undefined): string | undefined {
- if (!value) return undefined;
- const trimmed = value.trim();
- return trimmed.length > 0 ? trimmed : undefined;
-}
-
-function normalizeItemType(raw: string | undefined): string {
- if (!raw) return "item";
- return raw
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
- .replace(/[._/-]/g, " ")
- .replace(/\s+/g, " ")
- .trim()
- .toLowerCase();
-}
-
-function shouldDropItemType(type: string): boolean {
- if (
- type.includes("preamble") ||
- type.includes("reasoning") ||
- type.includes("thought")
- ) {
- return true;
- }
-
- return type === "work" || type.startsWith("work ");
-}
-
-function shouldShowItemLifecycle(type: string): boolean {
- return (
- type.includes("tool") ||
- type.includes("command") ||
- type.includes("file change")
- );
-}
-
-function shouldDropMethod(method: string): boolean {
- return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method);
-}
-
-function itemTypeMeta(type: string): {
- label: string;
- tone: WorkLogEntry["tone"];
-} {
- if (type.includes("command")) {
- return { label: "Command run", tone: "tool" };
- }
- if (type.includes("file change")) {
- return { label: "File change", tone: "tool" };
- }
- if (type.includes("tool")) {
- return { label: "Tool call", tone: "tool" };
- }
- return { label: "Work item", tone: "info" };
-}
-
-interface ItemLifecycleCandidate {
- id: string;
- itemId?: string;
- createdAt: string;
- label: string;
- detail?: string;
- tone: WorkLogEntry["tone"];
- phase: "started" | "completed";
-}
-
-function extractDetail(
- payload: Record | undefined,
- item: Record | undefined,
-): string | undefined {
- const candidates = [
- asString(item?.command),
- asString(item?.tool),
- asString(item?.name),
- asString(item?.title),
- asString(item?.summary),
- asString(item?.text),
- asString(item?.prompt),
- asString(payload?.message),
- asString(payload?.prompt),
- asString(payload?.command),
- ];
-
- for (const candidate of candidates) {
- const detail = normalizeDetail(candidate);
- if (detail) return detail;
- }
- return undefined;
-}
-
-function lifecycleCandidateFromItemEvent(
- event: ProviderEvent,
-): ItemLifecycleCandidate | null {
- const payload = asObject(event.payload);
- const item = asObject(payload?.item);
- const normalizedType = normalizeItemType(asString(item?.type));
- if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) {
- return null;
- }
- if (!shouldShowItemLifecycle(normalizedType)) {
- return null;
- }
-
- const meta = itemTypeMeta(normalizedType);
- const isStarted = event.method === "item/started";
- const isCompleted = event.method === "item/completed";
- if (!isStarted && !isCompleted) {
- return null;
- }
-
- const detail = extractDetail(payload, item);
- const itemId = event.itemId ?? asString(item?.id);
- return {
- id: event.id,
- ...(itemId ? { itemId } : {}),
- createdAt: event.createdAt,
- label: meta.label,
- ...(detail ? { detail } : {}),
- tone: meta.tone,
- phase: isCompleted ? "completed" : "started",
- };
-}
-
-function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
- if (event.kind !== "request") return null;
- if (shouldDropMethod(event.method)) return null;
-
- if (event.method.includes("commandExecution")) {
- return {
- id: event.id,
- createdAt: event.createdAt,
- label: "Command approval requested",
- tone: "tool",
- };
- }
-
- if (event.method.includes("fileChange")) {
- return {
- id: event.id,
- createdAt: event.createdAt,
- label: "File-change approval requested",
- tone: "tool",
- };
- }
-
- if (event.method.includes("requestUserInput")) {
- return {
- id: event.id,
- createdAt: event.createdAt,
- label: "Tool requested user input",
- tone: "tool",
- };
- }
-
- return {
- id: event.id,
- createdAt: event.createdAt,
- label: `Request: ${event.method}`,
- tone: "info",
- };
-}
-
-function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
- if (event.kind !== "notification") return null;
- if (shouldDropMethod(event.method)) return null;
- if (event.method === "item/agentMessage/delta") return null;
- if (event.method === "turn/started") {
- return null;
- }
- if (event.method === "thread/started") return null;
-
- if (event.method === "turn/completed") {
- const payload = asObject(event.payload);
- const turn = asObject(payload?.turn);
- const status = asString(turn?.status);
- if (status !== "failed") {
- return null;
- }
- const turnError = asObject(turn?.error);
- const turnErrorMessage = asString(turnError?.message);
- const turnErrorDetail = normalizeDetail(turnErrorMessage);
-
- return {
- id: event.id,
- createdAt: event.createdAt,
- label: "Turn failed",
- ...(turnErrorDetail ? { detail: turnErrorDetail } : {}),
- tone: "error",
- };
- }
-
- if (event.method.startsWith("item/")) return null;
-
- return null;
-}
-
-function entryFromError(event: ProviderEvent): WorkLogEntry | null {
- if (event.kind !== "error") return null;
- if (shouldDropMethod(event.method)) return null;
- const detail = normalizeDetail(event.message);
-
- return {
- id: event.id,
- createdAt: event.createdAt,
- label: "Runtime error",
- ...(detail ? { detail } : {}),
- tone: "error",
- };
-}
-
-function eventTurnId(event: ProviderEvent): string | undefined {
- const payload = asObject(event.payload);
- const turn = asObject(payload?.turn);
- return event.turnId ?? asString(turn?.id);
-}
-
-export function deriveWorkLogEntries(
- events: ProviderEvent[],
- turnId: string | undefined,
-): WorkLogEntry[] {
- const ordered = [...events].toReversed();
- const entries: WorkLogEntry[] = [];
- const turnStartedAtIso = turnId
- ? ordered.find((event) => {
- if (event.method !== "turn/started") return false;
- return eventTurnId(event) === turnId;
- })?.createdAt
- : undefined;
- const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN;
-
- const shouldIncludeEvent = (event: ProviderEvent): boolean => {
- if (!turnId) return true;
- const scopedTurnId = eventTurnId(event);
- if (scopedTurnId && scopedTurnId !== turnId) {
- return false;
- }
-
- if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {
- const eventAt = Date.parse(event.createdAt);
- if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
- return false;
- }
- }
-
- return true;
- };
-
- const completedLifecycleItemIds = new Set();
- for (const event of ordered) {
- if (!shouldIncludeEvent(event)) continue;
- const candidate = lifecycleCandidateFromItemEvent(event);
- if (candidate?.phase === "completed" && candidate.itemId) {
- completedLifecycleItemIds.add(candidate.itemId);
- }
- }
-
- for (const event of ordered) {
- if (!shouldIncludeEvent(event)) continue;
-
- const lifecycleCandidate = lifecycleCandidateFromItemEvent(event);
- if (lifecycleCandidate) {
- if (
- lifecycleCandidate.phase === "started" &&
- lifecycleCandidate.itemId &&
- completedLifecycleItemIds.has(lifecycleCandidate.itemId)
- ) {
- continue;
- }
- if (
- lifecycleCandidate.label === "Tool call" &&
- !lifecycleCandidate.detail
- ) {
- continue;
- }
-
- entries.push({
- id: lifecycleCandidate.id,
- createdAt: lifecycleCandidate.createdAt,
- label: lifecycleCandidate.label,
- ...(lifecycleCandidate.detail
- ? { detail: lifecycleCandidate.detail }
- : {}),
- tone: lifecycleCandidate.tone,
- });
- continue;
- }
-
- const fromRequest = entryFromRequest(event);
- if (fromRequest) {
- entries.push(fromRequest);
- continue;
- }
-
- const fromNotification = entryFromNotification(event);
- if (fromNotification) {
- entries.push(fromNotification);
- continue;
- }
-
- const fromError = entryFromError(event);
- if (fromError) {
- entries.push(fromError);
- }
- }
-
- return entries;
-}
-
-function toTimestamp(isoDate: string): number {
- const parsed = Date.parse(isoDate);
- return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
-}
-
-export function deriveTimelineEntries(
- messages: ChatMessage[],
- workEntries: WorkLogEntry[],
-): TimelineEntry[] {
- const timeline: TimelineEntry[] = [];
- let messageIndex = 0;
- let workIndex = 0;
-
- while (messageIndex < messages.length || workIndex < workEntries.length) {
- const message = messages[messageIndex];
- const workEntry = workEntries[workIndex];
-
- if (!message && workEntry) {
- timeline.push({
- id: `work:${workEntry.id}`,
- kind: "work",
- createdAt: workEntry.createdAt,
- entry: workEntry,
- });
- workIndex += 1;
- continue;
- }
-
- if (!workEntry && message) {
- timeline.push({
- id: `message:${message.id}`,
- kind: "message",
- createdAt: message.createdAt,
- message,
- });
- messageIndex += 1;
- continue;
- }
-
- if (!message || !workEntry) {
- break;
- }
-
- const messageAt = toTimestamp(message.createdAt);
- const workAt = toTimestamp(workEntry.createdAt);
-
- if (workAt <= messageAt) {
- timeline.push({
- id: `work:${workEntry.id}`,
- kind: "work",
- createdAt: workEntry.createdAt,
- entry: workEntry,
- });
- workIndex += 1;
- continue;
- }
-
- timeline.push({
- id: `message:${message.id}`,
- kind: "message",
- createdAt: message.createdAt,
- message,
- });
- messageIndex += 1;
- }
-
- return timeline;
-}
-
-export function derivePhase(session: ProviderSession | null): SessionPhase {
- if (!session || session.status === "closed") return "disconnected";
- if (session.status === "connecting") return "connecting";
- if (session.status === "running") return "running";
- return "ready";
-}
-
-export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession {
- const payload = asObject(event.payload);
-
- if (event.method === "thread/started") {
- const thread = asObject(payload?.thread);
- return {
- ...previous,
- threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId,
- updatedAt: event.createdAt,
- };
- }
-
- if (event.method === "turn/started") {
- const turn = asObject(payload?.turn);
- return {
- ...previous,
- status: "running",
- activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId,
- updatedAt: event.createdAt,
- };
- }
-
- if (event.method === "turn/completed") {
- const turn = asObject(payload?.turn);
- const status = asString(turn?.status);
- const turnError = asObject(turn?.error);
- return {
- ...previous,
- status: status === "failed" ? "error" : "ready",
- activeTurnId: undefined,
- lastError: asString(turnError?.message) ?? previous.lastError,
- updatedAt: event.createdAt,
- };
- }
-
- if (event.kind === "error") {
- return {
- ...previous,
- status: "error",
- lastError: event.message ?? previous.lastError,
- updatedAt: event.createdAt,
- };
- }
-
- if (event.method === "session/closed" || event.method === "session/exited") {
- return {
- ...previous,
- status: "closed",
- activeTurnId: undefined,
- lastError: event.message ?? previous.lastError,
- updatedAt: event.createdAt,
- };
- }
-
- return { ...previous, updatedAt: event.createdAt };
-}
-
-export function applyEventToMessages(
- previous: ChatMessage[],
- event: ProviderEvent,
- activeAssistantItemRef: { current: string | null },
-): ChatMessage[] {
- const payload = asObject(event.payload);
-
- if (event.method === "item/started") {
- const item = asObject(payload?.item);
- if (asString(item?.type) !== "agentMessage") return previous;
- const itemId = asString(item?.id);
- if (!itemId) return previous;
-
- activeAssistantItemRef.current = itemId;
- const seedText = asString(item?.text) ?? "";
- const filtered = previous.filter((entry) => entry.id !== itemId);
- return [
- ...filtered,
- {
- id: itemId,
- role: "assistant",
- text: seedText,
- createdAt: event.createdAt,
- streaming: true,
- },
- ];
- }
-
- if (event.method === "item/agentMessage/delta") {
- const itemId = event.itemId ?? asString(payload?.itemId);
- const delta = event.textDelta ?? asString(payload?.delta) ?? "";
- if (!itemId || !delta) return previous;
-
- const existingIndex = previous.findIndex((entry) => entry.id === itemId);
- if (existingIndex === -1) {
- activeAssistantItemRef.current = itemId;
- return [
- ...previous,
- {
- id: itemId,
- role: "assistant",
- text: delta,
- createdAt: event.createdAt,
- streaming: true,
- },
- ];
- }
-
- const updated = [...previous];
- const existing = updated[existingIndex];
- if (!existing) return previous;
- updated[existingIndex] = {
- ...existing,
- text: `${existing.text}${delta}`,
- streaming: true,
- };
- return updated;
- }
-
- if (event.method === "item/completed") {
- const item = asObject(payload?.item);
- if (asString(item?.type) !== "agentMessage") return previous;
- const itemId = asString(item?.id);
- if (!itemId) return previous;
-
- const fullText = asString(item?.text);
- const existingIndex = previous.findIndex((entry) => entry.id === itemId);
- if (existingIndex === -1) {
- return [
- ...previous,
- {
- id: itemId,
- role: "assistant",
- text: fullText ?? "",
- createdAt: event.createdAt,
- streaming: false,
- },
- ];
- }
-
- const updated = [...previous];
- const existing = updated[existingIndex];
- if (!existing) return previous;
- updated[existingIndex] = {
- ...existing,
- text: fullText ?? existing.text,
- streaming: false,
- };
-
- if (activeAssistantItemRef.current === itemId) {
- activeAssistantItemRef.current = null;
- }
- return updated;
- }
-
- if (event.method === "turn/completed") {
- return previous.map((entry) => ({ ...entry, streaming: false }));
- }
-
- return previous;
-}
diff --git a/apps/renderer/src/store.test.ts b/apps/renderer/src/store.test.ts
deleted file mode 100644
index c10bfe8e7951423616b87004d28cd74d6a06fa74..0000000000000000000000000000000000000000
--- a/apps/renderer/src/store.test.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
-import { describe, expect, it } from "vitest";
-
-import { type AppState, reducer } from "./store";
-import type { Thread } from "./types";
-
-function makeSession(overrides: Partial = {}): ProviderSession {
- return {
- sessionId: "sess-1",
- provider: "codex",
- status: "ready",
- createdAt: "2026-02-09T00:00:00.000Z",
- updatedAt: "2026-02-09T00:00:00.000Z",
- ...overrides,
- };
-}
-
-function makeEvent(overrides: Partial = {}): ProviderEvent {
- return {
- id: "evt-1",
- kind: "notification",
- provider: "codex",
- sessionId: "sess-1",
- createdAt: "2026-02-09T00:00:01.000Z",
- method: "thread/started",
- ...overrides,
- };
-}
-
-function makeThread(overrides: Partial = {}): Thread {
- return {
- id: "thread-local-1",
- codexThreadId: null,
- projectId: "project-1",
- title: "Thread",
- model: "gpt-5.3-codex",
- session: makeSession(),
- messages: [],
- events: [],
- error: null,
- createdAt: "2026-02-09T00:00:00.000Z",
- ...overrides,
- };
-}
-
-function makeState(thread: Thread): AppState {
- return {
- projects: [
- {
- id: "project-1",
- name: "Project",
- cwd: "/tmp/project",
- model: "gpt-5.3-codex",
- expanded: true,
- },
- ],
- threads: [thread],
- activeThreadId: thread.id,
- runtimeMode: "full-access",
- diffOpen: false,
- };
-}
-
-describe("store reducer thread continuity", () => {
- it("stores codexThreadId from UPDATE_SESSION", () => {
- const state = makeState(
- makeThread({
- session: null,
- }),
- );
- const next = reducer(state, {
- type: "UPDATE_SESSION",
- threadId: "thread-local-1",
- session: makeSession({ threadId: "thr_123" }),
- });
-
- expect(next.threads[0]?.codexThreadId).toBe("thr_123");
- });
-
- it("backfills codexThreadId from routed provider events", () => {
- const state = makeState(makeThread({ codexThreadId: null }));
- const next = reducer(state, {
- type: "APPLY_EVENT",
- event: makeEvent({
- method: "thread/started",
- payload: { thread: { id: "thr_backfilled" } },
- }),
- activeAssistantItemRef: { current: null },
- });
-
- expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled");
- });
-
- it("ignores events from a foreign thread within the same session", () => {
- const state = makeState(makeThread({ codexThreadId: "thr_expected" }));
- const next = reducer(state, {
- type: "APPLY_EVENT",
- event: makeEvent({
- method: "turn/started",
- threadId: "thr_unexpected",
- payload: { turn: { id: "turn-1" } },
- }),
- activeAssistantItemRef: { current: null },
- });
-
- expect(next).toBe(state);
- });
-
- it("rebases thread identity on thread/started during connect", () => {
- const state = makeState(
- makeThread({
- codexThreadId: "thr_old",
- session: makeSession({
- status: "connecting",
- threadId: "thr_old",
- }),
- }),
- );
- const next = reducer(state, {
- type: "APPLY_EVENT",
- event: makeEvent({
- method: "thread/started",
- threadId: "thr_new",
- payload: { thread: { id: "thr_new" } },
- }),
- activeAssistantItemRef: { current: null },
- });
-
- expect(next.threads[0]?.codexThreadId).toBe("thr_new");
- expect(next.threads[0]?.session?.threadId).toBe("thr_new");
- });
-
- it("reconciles project ids by cwd when syncing backend projects", () => {
- const state: AppState = {
- projects: [
- {
- id: "project-old-a",
- name: "A",
- cwd: "/tmp/a",
- model: "gpt-5.3-codex",
- expanded: false,
- },
- {
- id: "project-old-b",
- name: "B",
- cwd: "/tmp/b",
- model: "gpt-5.3-codex",
- expanded: true,
- },
- ],
- threads: [
- makeThread({
- id: "thread-a",
- projectId: "project-old-a",
- }),
- makeThread({
- id: "thread-b",
- projectId: "project-old-b",
- }),
- ],
- activeThreadId: "thread-b",
- runtimeMode: "full-access",
- diffOpen: false,
- };
-
- const next = reducer(state, {
- type: "SYNC_PROJECTS",
- projects: [
- {
- id: "project-new-a",
- name: "A",
- cwd: "/tmp/a",
- model: "gpt-5.3-codex",
- expanded: true,
- },
- ],
- });
-
- expect(next.projects).toHaveLength(1);
- expect(next.projects[0]?.id).toBe("project-new-a");
- // Preserve existing project UI preferences by cwd
- expect(next.projects[0]?.expanded).toBe(false);
- expect(next.threads).toHaveLength(1);
- expect(next.threads[0]?.id).toBe("thread-a");
- expect(next.threads[0]?.projectId).toBe("project-new-a");
- expect(next.activeThreadId).toBe("thread-a");
- });
-});
diff --git a/apps/renderer/src/store.ts b/apps/renderer/src/store.ts
deleted file mode 100644
index f9b075b89e015391082b5c7e8139591613f8aa54..0000000000000000000000000000000000000000
--- a/apps/renderer/src/store.ts
+++ /dev/null
@@ -1,402 +0,0 @@
-import {
- type Dispatch,
- type ReactNode,
- createContext,
- createElement,
- useContext,
- useEffect,
- useReducer,
-} from "react";
-
-import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
-import { resolveModelSlug } from "./model-logic";
-import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
-import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
-import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
-
-// ── Actions ──────────────────────────────────────────────────────────
-
-type Action =
- | { type: "ADD_PROJECT"; project: Project }
- | { type: "SYNC_PROJECTS"; projects: Project[] }
- | { type: "TOGGLE_PROJECT"; projectId: string }
- | { type: "ADD_THREAD"; thread: Thread }
- | { type: "SET_ACTIVE_THREAD"; threadId: string }
- | { type: "TOGGLE_DIFF" }
- | {
- type: "APPLY_EVENT";
- event: ProviderEvent;
- activeAssistantItemRef: { current: string | null };
- }
- | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
- | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }
- | { type: "SET_ERROR"; threadId: string; error: string | null }
- | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
- | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
- | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
-
-// ── State ────────────────────────────────────────────────────────────
-
-export interface AppState {
- projects: Project[];
- threads: Thread[];
- activeThreadId: string | null;
- runtimeMode: RuntimeMode;
- diffOpen: boolean;
-}
-
-const PERSISTED_STATE_KEY = "t3code:renderer-state:v4";
-const LEGACY_PERSISTED_STATE_KEYS = [
- "t3code:renderer-state:v3",
- "codething:renderer-state:v4",
- "codething:renderer-state:v3",
- "codething:renderer-state:v2",
- "codething:renderer-state:v1",
-] as const;
-
-const initialState: AppState = {
- projects: [],
- threads: [],
- activeThreadId: null,
- runtimeMode: DEFAULT_RUNTIME_MODE,
- diffOpen: false,
-};
-
-// ── Helpers ──────────────────────────────────────────────────────────
-
-function readPersistedState(): AppState {
- if (typeof window === "undefined") return initialState;
-
- try {
- const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
- const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;
- const rawLegacyV3 = window.localStorage.getItem(legacyV3Key);
- const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);
- const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);
- const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1;
- if (!raw) return initialState;
- const hydrated = hydratePersistedState(
- raw,
- !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1),
- );
- if (!hydrated) return initialState;
-
- return { ...hydrated, diffOpen: false };
- } catch {
- return initialState;
- }
-}
-
-function persistState(state: AppState): void {
- if (typeof window === "undefined") return;
-
- try {
- window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state)));
- for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
- window.localStorage.removeItem(legacyKey);
- }
- } catch {
- // Ignore quota/storage errors to avoid breaking chat UX.
- }
-}
-
-function updateThread(
- threads: Thread[],
- threadId: string,
- updater: (t: Thread) => Thread,
-): Thread[] {
- return threads.map((t) => (t.id === threadId ? updater(t) : t));
-}
-
-function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
- return threads.find((t) => t.session?.sessionId === sessionId);
-}
-
-function getEventTurnId(event: ProviderEvent): string | undefined {
- if (event.turnId) return event.turnId;
- const payload = asObject(event.payload);
- const turn = asObject(payload?.turn);
- return asString(turn?.id);
-}
-
-function getEventThreadId(event: ProviderEvent): string | undefined {
- if (event.threadId) return event.threadId;
- const payload = asObject(event.payload);
- const payloadThread = asObject(payload?.thread);
- const payloadMessage = asObject(payload?.msg);
- return (
- asString(payload?.threadId) ??
- asString(payloadThread?.id) ??
- asString(payload?.conversationId) ??
- asString(payload?.thread_id) ??
- asString(payloadMessage?.thread_id)
- );
-}
-
-function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean {
- const eventThreadId = getEventThreadId(event);
- if (!eventThreadId) {
- return false;
- }
-
- const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId;
- if (!expectedThreadId || eventThreadId === expectedThreadId) {
- return false;
- }
-
- // During connect, accept a thread/started notification as an identity rebind.
- if (event.method === "thread/started" && thread.session?.status === "connecting") {
- return false;
- }
-
- return true;
-}
-
-function durationMs(startIso: string, endIso: string): number | undefined {
- const start = Date.parse(startIso);
- const end = Date.parse(endIso);
- if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
- return undefined;
- }
-
- return end - start;
-}
-
-function updateTurnFields(thread: Thread, event: ProviderEvent): Partial {
- if (event.method === "turn/started") {
- return {
- latestTurnId: getEventTurnId(event) ?? thread.latestTurnId,
- latestTurnStartedAt: event.createdAt,
- latestTurnCompletedAt: undefined,
- latestTurnDurationMs: undefined,
- };
- }
-
- if (event.method === "turn/completed") {
- const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId;
- const startedAt =
- completedTurnId && completedTurnId === thread.latestTurnId
- ? thread.latestTurnStartedAt
- : undefined;
- const elapsed =
- startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined;
-
- return {
- latestTurnId: completedTurnId ?? thread.latestTurnId,
- latestTurnCompletedAt: event.createdAt,
- latestTurnDurationMs: elapsed,
- };
- }
-
- return {};
-}
-
-// ── Reducer ──────────────────────────────────────────────────────────
-
-export function reducer(state: AppState, action: Action): AppState {
- switch (action.type) {
- case "ADD_PROJECT":
- if (state.projects.some((project) => project.cwd === action.project.cwd)) {
- return state;
- }
- return {
- ...state,
- projects: [
- ...state.projects,
- {
- ...action.project,
- model: resolveModelSlug(action.project.model),
- },
- ],
- };
-
- case "SYNC_PROJECTS": {
- const previousByCwd = new Map(
- state.projects.map((project) => [project.cwd, project] as const),
- );
- const nextProjects = action.projects.map((project) => {
- const previous = previousByCwd.get(project.cwd);
- return {
- ...project,
- model: resolveModelSlug(previous?.model ?? project.model),
- expanded: previous?.expanded ?? project.expanded,
- };
- });
- const previousProjectById = new Map(
- state.projects.map((project) => [project.id, project] as const),
- );
- const nextProjectIdByCwd = new Map(
- nextProjects.map((project) => [project.cwd, project.id] as const),
- );
- const nextThreads = state.threads
- .map((thread) => {
- const previousProject = previousProjectById.get(thread.projectId);
- if (!previousProject) return null;
- const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd);
- if (!mappedProjectId) return null;
- return {
- ...thread,
- projectId: mappedProjectId,
- };
- })
- .filter((thread): thread is Thread => thread !== null);
- const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)
- ? state.activeThreadId
- : (nextThreads[0]?.id ?? null);
-
- return {
- ...state,
- projects: nextProjects,
- threads: nextThreads,
- activeThreadId,
- };
- }
-
- case "TOGGLE_PROJECT":
- return {
- ...state,
- projects: state.projects.map((p) =>
- p.id === action.projectId ? { ...p, expanded: !p.expanded } : p,
- ),
- };
-
- case "ADD_THREAD":
- return {
- ...state,
- threads: [
- ...state.threads,
- {
- ...action.thread,
- model: resolveModelSlug(action.thread.model),
- },
- ],
- activeThreadId: action.thread.id,
- };
-
- case "SET_ACTIVE_THREAD":
- return { ...state, activeThreadId: action.threadId };
-
- case "TOGGLE_DIFF":
- return { ...state, diffOpen: !state.diffOpen };
-
- case "APPLY_EVENT": {
- const { event, activeAssistantItemRef } = action;
- const target = findThreadBySessionId(state.threads, event.sessionId);
- if (!target) return state;
- if (shouldIgnoreForeignThreadEvent(target, event)) return state;
-
- return {
- ...state,
- threads: updateThread(state.threads, target.id, (t) => ({
- ...t,
- ...(() => {
- const eventThreadId = getEventThreadId(event);
- const shouldRebindIdentity =
- event.method === "thread/started" && t.session?.status === "connecting";
- return {
- codexThreadId: shouldRebindIdentity
- ? (eventThreadId ?? t.codexThreadId)
- : (t.codexThreadId ?? eventThreadId ?? null),
- error: event.kind === "error" && event.message ? event.message : t.error,
- };
- })(),
- session: t.session ? evolveSession(t.session, event) : t.session,
- messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
- events: [event, ...t.events],
- ...updateTurnFields(t, event),
- })),
- };
- }
-
- case "UPDATE_SESSION":
- return {
- ...state,
- threads: updateThread(state.threads, action.threadId, (t) => ({
- ...t,
- session: action.session,
- codexThreadId: action.session.threadId ?? t.codexThreadId,
- events: [],
- error: null,
- latestTurnId: undefined,
- latestTurnStartedAt: undefined,
- latestTurnCompletedAt: undefined,
- latestTurnDurationMs: undefined,
- })),
- };
-
- case "PUSH_USER_MESSAGE":
- return {
- ...state,
- threads: updateThread(state.threads, action.threadId, (t) => ({
- ...t,
- messages: [
- ...t.messages,
- {
- id: action.id,
- role: "user" as const,
- text: action.text,
- createdAt: new Date().toISOString(),
- streaming: false,
- },
- ],
- })),
- };
-
- case "SET_ERROR":
- return {
- ...state,
- threads: updateThread(state.threads, action.threadId, (t) => ({
- ...t,
- error: action.error,
- })),
- };
-
- case "SET_THREAD_TITLE":
- return {
- ...state,
- threads: updateThread(state.threads, action.threadId, (t) => ({
- ...t,
- title: action.title,
- })),
- };
-
- case "SET_THREAD_MODEL":
- return {
- ...state,
- threads: updateThread(state.threads, action.threadId, (t) => ({
- ...t,
- model: resolveModelSlug(action.model),
- })),
- };
-
- case "SET_RUNTIME_MODE":
- return {
- ...state,
- runtimeMode: action.mode,
- };
-
- default:
- return state;
- }
-}
-
-// ── Context ──────────────────────────────────────────────────────────
-
-const StoreContext = createContext<{
- state: AppState;
- dispatch: Dispatch;
-}>({ state: initialState, dispatch: () => {} });
-
-export function StoreProvider({ children }: { children: ReactNode }) {
- const [state, dispatch] = useReducer(reducer, undefined, readPersistedState);
-
- useEffect(() => {
- persistState(state);
- }, [state]);
-
- return createElement(StoreContext.Provider, { value: { state, dispatch } }, children);
-}
-
-export function useStore() {
- return useContext(StoreContext);
-}
diff --git a/apps/renderer/src/types.ts b/apps/renderer/src/types.ts
deleted file mode 100644
index ebe3731d121be92b304c7e0e4c050b604802e482..0000000000000000000000000000000000000000
--- a/apps/renderer/src/types.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
-
-export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
-export type RuntimeMode = "approval-required" | "full-access";
-export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
-
-export interface ChatMessage {
- id: string;
- role: "user" | "assistant";
- text: string;
- createdAt: string;
- streaming: boolean;
-}
-
-export interface Project {
- id: string;
- name: string;
- cwd: string;
- model: string;
- expanded: boolean;
-}
-
-export interface Thread {
- id: string;
- codexThreadId: string | null;
- projectId: string;
- title: string;
- model: string;
- session: ProviderSession | null;
- messages: ChatMessage[];
- events: ProviderEvent[];
- error: string | null;
- createdAt: string;
- latestTurnId?: string | undefined;
- latestTurnStartedAt?: string | undefined;
- latestTurnCompletedAt?: string | undefined;
- latestTurnDurationMs?: number | undefined;
-}
diff --git a/apps/renderer/src/vite-env.d.ts b/apps/renderer/src/vite-env.d.ts
deleted file mode 100644
index 0d83bb7e8c8421139cfeaffd58bbdc30e71f93d1..0000000000000000000000000000000000000000
--- a/apps/renderer/src/vite-env.d.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-///
-
-import type { NativeApi } from "@t3tools/contracts";
-
-interface DesktopBridge {
- getWsUrl: () => string | null;
- pickFolder: () => Promise;
-}
-
-declare global {
- interface Window {
- nativeApi?: NativeApi;
- desktopBridge?: DesktopBridge;
- }
-}
diff --git a/apps/renderer/src/wsNativeApi.ts b/apps/renderer/src/wsNativeApi.ts
deleted file mode 100644
index 1ed5d5d2a1f6c47712e22ef4e4f8f22083232405..0000000000000000000000000000000000000000
--- a/apps/renderer/src/wsNativeApi.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
-
-import { WsTransport } from "./wsTransport";
-
-let instance: { api: NativeApi; transport: WsTransport } | null = null;
-const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
-let lastWelcome: WsWelcomePayload | null = null;
-
-/**
- * Subscribe to the server welcome message. If a welcome was already received
- * before this call, the listener fires synchronously with the cached payload.
- * This avoids the race between WebSocket connect and React effect registration.
- */
-export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {
- welcomeListeners.add(listener);
-
- // Replay cached welcome for late subscribers
- if (lastWelcome) {
- try {
- listener(lastWelcome);
- } catch {
- // Swallow listener errors
- }
- }
-
- return () => {
- welcomeListeners.delete(listener);
- };
-}
-
-export function createWsNativeApi(): NativeApi {
- if (instance) return instance.api;
-
- const transport = new WsTransport();
-
- // Listen for server welcome and forward to registered listeners.
- // Also cache it so late subscribers (React effects) get it immediately.
- transport.subscribe(WS_CHANNELS.serverWelcome, (data) => {
- const payload = data as WsWelcomePayload;
- lastWelcome = payload;
- for (const listener of welcomeListeners) {
- try {
- listener(payload);
- } catch {
- // Swallow listener errors
- }
- }
- });
-
- const api: NativeApi = {
- todos: {
- list: async () => [],
- add: async () => [],
- toggle: async () => [],
- remove: async () => [],
- },
- dialogs: {
- pickFolder: async () => {
- if (!window.desktopBridge) return null;
- return window.desktopBridge.pickFolder();
- },
- },
- terminal: {
- run: async () => ({
- stdout: "",
- stderr: "Terminal not available in web mode",
- code: 1,
- signal: null,
- timedOut: false,
- }),
- },
- agent: {
- spawn: async () => "",
- kill: async () => {},
- write: async () => {},
- onOutput: () => () => {},
- onExit: () => () => {},
- },
- providers: {
- startSession: (input) => transport.request(WS_METHODS.providersStartSession, input),
- sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input),
- interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input),
- respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input),
- stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),
- listSessions: () => transport.request(WS_METHODS.providersListSessions),
- onEvent: (callback) =>
- transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void),
- },
- projects: {
- list: () => transport.request(WS_METHODS.projectsList),
- add: (input) => transport.request(WS_METHODS.projectsAdd, input),
- remove: (input) => transport.request(WS_METHODS.projectsRemove, input),
- },
- shell: {
- openInEditor: (cwd, editor) =>
- transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
- },
- };
-
- instance = { api, transport };
- return api;
-}
diff --git a/apps/renderer/src/wsTransport.ts b/apps/renderer/src/wsTransport.ts
deleted file mode 100644
index 20a40f8f05e7aec884ef53c4da1614ae390b69f4..0000000000000000000000000000000000000000
--- a/apps/renderer/src/wsTransport.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts";
-
-type PushListener = (data: unknown) => void;
-
-interface PendingRequest {
- resolve: (result: unknown) => void;
- reject: (error: Error) => void;
- timeout: ReturnType;
-}
-
-const REQUEST_TIMEOUT_MS = 60_000;
-const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
-
-export class WsTransport {
- private ws: WebSocket | null = null;
- private nextId = 1;
- private readonly pending = new Map();
- private readonly listeners = new Map>();
- private reconnectAttempt = 0;
- private reconnectTimer: ReturnType | null = null;
- private disposed = false;
- private readonly url: string;
-
- constructor(url?: string) {
- const bridgeUrl = window.desktopBridge?.getWsUrl();
- // In dev mode, VITE_WS_URL points to the server's WebSocket endpoint.
- // In production, the page is served by the WS server on the same host:port.
- const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
- this.url =
- url ??
- (bridgeUrl && bridgeUrl.length > 0
- ? bridgeUrl
- : envUrl && envUrl.length > 0
- ? envUrl
- : `ws://${window.location.hostname}:${window.location.port}`);
- this.connect();
- }
-
- async request(method: string, params?: unknown): Promise {
- const id = String(this.nextId++);
- const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) };
-
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- this.pending.delete(id);
- reject(new Error(`Request timed out: ${method}`));
- }, REQUEST_TIMEOUT_MS);
-
- this.pending.set(id, {
- resolve: resolve as (result: unknown) => void,
- reject,
- timeout,
- });
-
- this.send(message);
- });
- }
-
- subscribe(channel: string, listener: PushListener): () => void {
- let channelListeners = this.listeners.get(channel);
- if (!channelListeners) {
- channelListeners = new Set();
- this.listeners.set(channel, channelListeners);
- }
- channelListeners.add(listener);
-
- return () => {
- channelListeners!.delete(listener);
- if (channelListeners!.size === 0) {
- this.listeners.delete(channel);
- }
- };
- }
-
- dispose() {
- this.disposed = true;
- if (this.reconnectTimer !== null) {
- clearTimeout(this.reconnectTimer);
- this.reconnectTimer = null;
- }
- for (const pending of this.pending.values()) {
- clearTimeout(pending.timeout);
- pending.reject(new Error("Transport disposed"));
- }
- this.pending.clear();
- this.ws?.close();
- this.ws = null;
- }
-
- private connect() {
- if (this.disposed) return;
-
- const ws = new WebSocket(this.url);
-
- ws.addEventListener("open", () => {
- this.ws = ws;
- this.reconnectAttempt = 0;
- });
-
- ws.addEventListener("message", (event) => {
- this.handleMessage(event.data);
- });
-
- ws.addEventListener("close", () => {
- this.ws = null;
- this.scheduleReconnect();
- });
-
- ws.addEventListener("error", () => {
- // close event will fire after error
- });
- }
-
- private handleMessage(raw: unknown) {
- let parsed: unknown;
- try {
- parsed = JSON.parse(String(raw));
- } catch {
- return;
- }
-
- if (!parsed || typeof parsed !== "object") return;
-
- const message = parsed as Record;
-
- // Push event
- if (message.type === "push") {
- const push = message as unknown as WsPush;
- const channelListeners = this.listeners.get(push.channel);
- if (channelListeners) {
- for (const listener of channelListeners) {
- try {
- listener(push.data);
- } catch {
- // Swallow listener errors
- }
- }
- }
- return;
- }
-
- // Response to a request
- if (typeof message.id === "string") {
- const response = message as unknown as WsResponse;
- const pending = this.pending.get(response.id);
- if (!pending) return;
-
- clearTimeout(pending.timeout);
- this.pending.delete(response.id);
-
- if (response.error) {
- pending.reject(new Error(response.error.message));
- } else {
- pending.resolve(response.result);
- }
- }
- }
-
- private send(message: WsRequest) {
- if (this.ws?.readyState === WebSocket.OPEN) {
- this.ws.send(JSON.stringify(message));
- return;
- }
-
- // If not connected, wait for connection
- const waitForOpen = () => {
- const check = setInterval(() => {
- if (this.disposed) {
- clearInterval(check);
- return;
- }
- if (this.ws?.readyState === WebSocket.OPEN) {
- clearInterval(check);
- this.ws.send(JSON.stringify(message));
- }
- }, 50);
-
- // Give up after timeout (the pending request will time out on its own)
- setTimeout(() => clearInterval(check), REQUEST_TIMEOUT_MS);
- };
- waitForOpen();
- }
-
- private scheduleReconnect() {
- if (this.disposed) return;
-
- const delay =
- RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ??
- RECONNECT_DELAYS_MS[0]!;
-
- this.reconnectAttempt++;
- this.reconnectTimer = setTimeout(() => {
- this.reconnectTimer = null;
- this.connect();
- }, delay);
- }
-}
diff --git a/apps/renderer/tsconfig.json b/apps/renderer/tsconfig.json
deleted file mode 100644
index 86307ececcae55ed61c43377c9935faefe54e810..0000000000000000000000000000000000000000
--- a/apps/renderer/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "composite": true,
- "jsx": "react-jsx",
- "lib": ["ES2023", "DOM", "DOM.Iterable"],
- "types": ["vite/client"]
- },
- "include": ["src", "vite.config.ts"]
-}
diff --git a/apps/renderer/vite.config.ts b/apps/renderer/vite.config.ts
deleted file mode 100644
index f5559e3d1f3bb84ff00f89f94b1908d088b98850..0000000000000000000000000000000000000000
--- a/apps/renderer/vite.config.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import tailwindcss from "@tailwindcss/vite";
-import react from "@vitejs/plugin-react";
-import { defineConfig } from "vite";
-
-export default defineConfig({
- plugins: [
- react({
- babel: {
- plugins: [["babel-plugin-react-compiler", { target: "19" }]],
- },
- }),
- tailwindcss(),
- ],
- define: {
- // In dev mode, tell the renderer where the WebSocket server lives
- "import.meta.env.VITE_WS_URL": JSON.stringify(
- process.env.VITE_WS_URL ?? "",
- ),
- },
- server: {
- port: Number(process.env.PORT ?? 5173),
- strictPort: true,
- },
- build: {
- outDir: "dist",
- emptyOutDir: true,
- },
-});
diff --git a/apps/server/package.json b/apps/server/package.json
index 267fdd3542d569a2ea5ab56fd46597e17115f232..d9b3af34a798dc5d537dc42f31bc72d91b894aec 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -24,7 +24,7 @@
},
"devDependencies": {
"@t3tools/contracts": "workspace:*",
- "@t3tools/renderer": "workspace:*",
+ "@t3tools/web": "workspace:*",
"@types/node": "^22.10.2",
"@types/ws": "^8.5.13",
"tsup": "^8.3.5",
diff --git a/apps/server/scripts/bundle-client.mjs b/apps/server/scripts/bundle-client.mjs
index d26a74f0b41584b316bd77680de2a4acc3c08447..33b44cd5e75e9cdadb76f908bc60f1f90abe045d 100644
--- a/apps/server/scripts/bundle-client.mjs
+++ b/apps/server/scripts/bundle-client.mjs
@@ -1,5 +1,5 @@
/**
- * Copies the built renderer into dist/client/ so the published npm package
+ * Copies the built web app into dist/client/ so the published npm package
* includes the web UI. This runs as a post-build step.
*/
import fs from "node:fs";
@@ -7,15 +7,15 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const rendererDist = path.resolve(__dirname, "../../renderer/dist");
+const webDist = path.resolve(__dirname, "../../web/dist");
const target = path.resolve(__dirname, "../dist/client");
-if (!fs.existsSync(rendererDist)) {
+if (!fs.existsSync(webDist)) {
console.log(
- "⚠ Renderer dist not found — skipping client bundle. Run `bun run --cwd apps/renderer build` first.",
+ "⚠ Web dist not found — skipping client bundle. Run `bun run --cwd apps/web build` first.",
);
process.exit(0);
}
-fs.cpSync(rendererDist, target, { recursive: true });
-console.log("✓ Bundled renderer into dist/client");
+fs.cpSync(webDist, target, { recursive: true });
+console.log("✓ Bundled web app into dist/client");
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 97e2c5bccaf7e46b5119b2dd3a9fef60935c42e1..d34065760877755749806158a6343887a578eb98 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -84,8 +84,8 @@ function resolveStaticDir(): string | undefined {
// Not bundled — check monorepo layout
}
- // Monorepo layout: apps/server/dist/index.js → apps/renderer/dist/
- const monorepoClient = path.resolve(__dirname, "../../renderer/dist");
+ // Monorepo layout: apps/server/dist/index.js → apps/web/dist/
+ const monorepoClient = path.resolve(__dirname, "../../web/dist");
try {
const stat = fs.statSync(path.join(monorepoClient, "index.html"));
if (stat.isFile()) return monorepoClient;
@@ -109,8 +109,8 @@ async function main() {
const staticDir = devUrl ? undefined : resolveStaticDir();
if (!devUrl && !staticDir) {
- logger.warn("renderer bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {
- hint: "Run `bun run --cwd apps/renderer build` or set VITE_DEV_SERVER_URL for dev mode.",
+ logger.warn("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", {
+ hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.",
});
}
diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts
index c946b1742fd22077340c0fae996996867b32752b..ae9dc7ca687ea5ddf8b4987bc42bec1a77b4b6a9 100644
--- a/apps/server/src/wsServer.ts
+++ b/apps/server/src/wsServer.ts
@@ -109,7 +109,7 @@ export function createServer(options: ServerOptions) {
return;
}
- // Serve static files from the renderer build
+ // Serve static files from the web app build
if (!staticDir) {
res.writeHead(503, { "Content-Type": "text/plain" });
res.end("No static directory configured and no dev URL set.");
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..fe848c5287a8cb351c508d4b9696309f0b1f8c86
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ T3 Code
+
+
+
+
+
+
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..57f79caa4fcf643be57e4483062b5c6d559d8c52
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@t3tools/web",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run --passWithNoTests"
+ },
+ "dependencies": {
+ "@t3tools/contracts": "workspace:*",
+ "highlight.js": "^11.11.1",
+ "lucide-react": "^0.563.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-markdown": "^10.1.0",
+ "rehype-highlight": "^7.0.2",
+ "remark-gfm": "^4.0.1",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.7.3",
+ "vite": "^6.0.5"
+ }
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e12d563ab92d89ea62c4844ec0ecb8a527e80d4d
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useMemo, useRef } from "react";
+
+import ChatView from "./components/ChatView";
+import DiffPanel from "./components/DiffPanel";
+import Sidebar from "./components/Sidebar";
+import { isElectron } from "./env";
+import { DEFAULT_MODEL } from "./model-logic";
+import { readNativeApi } from "./session-logic";
+import { StoreProvider, useStore } from "./store";
+import { onServerWelcome } from "./wsNativeApi";
+
+function EventRouter() {
+ const api = useMemo(() => readNativeApi(), []);
+ const { dispatch } = useStore();
+ const activeAssistantItemRef = useRef(null);
+
+ useEffect(() => {
+ if (!api) return;
+ return api.providers.onEvent((event) => {
+ dispatch({
+ type: "APPLY_EVENT",
+ event,
+ activeAssistantItemRef,
+ });
+ });
+ }, [api, dispatch]);
+
+ return null;
+}
+
+function AutoProjectBootstrap() {
+ const { state, dispatch } = useStore();
+ const bootstrappedRef = useRef(false);
+
+ useEffect(() => {
+ // Browser mode bootstraps from server welcome.
+ // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
+ if (isElectron) return;
+
+ return onServerWelcome((payload) => {
+ if (bootstrappedRef.current) return;
+
+ // Don't create duplicate projects for the same cwd
+ const existing = state.projects.find((p) => p.cwd === payload.cwd);
+ if (existing) {
+ bootstrappedRef.current = true;
+ // Ensure a thread is active
+ const existingThread = state.threads.find((t) => t.projectId === existing.id);
+ if (existingThread && !state.activeThreadId) {
+ dispatch({
+ type: "SET_ACTIVE_THREAD",
+ threadId: existingThread.id,
+ });
+ }
+ return;
+ }
+
+ bootstrappedRef.current = true;
+
+ // Create project + thread from server cwd
+ const projectId = crypto.randomUUID();
+ dispatch({
+ type: "ADD_PROJECT",
+ project: {
+ id: projectId,
+ name: payload.projectName,
+ cwd: payload.cwd,
+ model: DEFAULT_MODEL,
+ expanded: true,
+ },
+ });
+ dispatch({
+ type: "ADD_THREAD",
+ thread: {
+ id: crypto.randomUUID(),
+ codexThreadId: null,
+ projectId,
+ title: "New thread",
+ model: DEFAULT_MODEL,
+ session: null,
+ messages: [],
+ events: [],
+ error: null,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ });
+ }, [state.projects, state.threads, state.activeThreadId, dispatch]);
+
+ return null;
+}
+
+function DesktopProjectBootstrap() {
+ const api = useMemo(() => readNativeApi(), []);
+ const { dispatch } = useStore();
+ const bootstrappedRef = useRef(false);
+
+ useEffect(() => {
+ if (!isElectron || !api || bootstrappedRef.current) return;
+
+ let disposed = false;
+ let retryDelayMs = 500;
+ let retryTimer: ReturnType | null = null;
+
+ const attemptBootstrap = async () => {
+ try {
+ const projects = await api.projects.list();
+ if (disposed) return;
+ dispatch({
+ type: "SYNC_PROJECTS",
+ projects: projects.map((project) => ({
+ id: project.id,
+ name: project.name,
+ cwd: project.cwd,
+ model: DEFAULT_MODEL,
+ expanded: true,
+ })),
+ });
+ bootstrappedRef.current = true;
+ } catch {
+ if (disposed) return;
+ retryTimer = setTimeout(() => {
+ retryTimer = null;
+ void attemptBootstrap();
+ }, retryDelayMs);
+ retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
+ }
+ };
+
+ void attemptBootstrap();
+
+ return () => {
+ disposed = true;
+ if (retryTimer) {
+ clearTimeout(retryTimer);
+ }
+ };
+ }, [api, dispatch]);
+
+ return null;
+}
+
+function Layout() {
+ const api = useMemo(() => readNativeApi(), []);
+ const { state } = useStore();
+
+ if (!api) {
+ return (
+
+
+
Connecting to T3 Code server...
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {state.diffOpen &&
}
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5104ddced5e66cfd9c8d77207f41406f36bdf69d
--- /dev/null
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -0,0 +1,28 @@
+import type { Components } from "react-markdown";
+import ReactMarkdown from "react-markdown";
+import rehypeHighlight from "rehype-highlight";
+import remarkGfm from "remark-gfm";
+
+interface ChatMarkdownProps {
+ text: string;
+}
+
+const markdownComponents: Components = {
+ a({ node: _node, ...props }) {
+ return ;
+ },
+};
+
+export default function ChatMarkdown({ text }: ChatMarkdownProps) {
+ return (
+
+
+ {text}
+
+
+ );
+}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3c28c7da551713933e03debef0d306ea76c1c8b0
--- /dev/null
+++ b/apps/web/src/components/ChatView.tsx
@@ -0,0 +1,1170 @@
+import {
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
+ type ProviderApprovalDecision,
+ type ProviderEvent,
+} from "@t3tools/contracts";
+import {
+ type FormEvent,
+ Fragment,
+ type KeyboardEvent,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { EDITORS, type EditorId } from "@t3tools/contracts";
+import { isElectron } from "../env";
+import { buildBootstrapInput } from "../historyBootstrap";
+import {
+ DEFAULT_MODEL,
+ DEFAULT_REASONING,
+ MODEL_OPTIONS,
+ REASONING_OPTIONS,
+ resolveModelSlug,
+} from "../model-logic";
+import {
+ derivePhase,
+ deriveTimelineEntries,
+ deriveWorkLogEntries,
+ formatDuration,
+ formatElapsed,
+ formatTimestamp,
+ readNativeApi,
+} from "../session-logic";
+import { useStore } from "../store";
+import ChatMarkdown from "./ChatMarkdown";
+
+function formatMessageMeta(createdAt: string, duration: string | null): string {
+ if (!duration) return formatTimestamp(createdAt);
+ return `${formatTimestamp(createdAt)} • ${duration}`;
+}
+
+const FILE_MANAGER_LABEL = navigator.platform.includes("Mac")
+ ? "Finder"
+ : navigator.platform.includes("Win")
+ ? "Explorer"
+ : "Files";
+
+function editorLabel(editor: (typeof EDITORS)[number]): string {
+ return editor.command ? editor.label : FILE_MANAGER_LABEL;
+}
+
+const LAST_EDITOR_KEY = "t3code:last-editor";
+const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
+
+function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
+ if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
+ if (tone === "tool") return "text-muted-foreground/70";
+ if (tone === "thinking") return "text-muted-foreground/50";
+ return "text-muted-foreground/40";
+}
+
+interface PendingApprovalCard {
+ requestId: string;
+ requestKind: "command" | "file-change";
+ createdAt: string;
+ detail?: string;
+}
+
+type SessionContinuityState = "resumed" | "new" | "fallback_new";
+
+interface EnsuredSessionInfo {
+ sessionId: string;
+ resolvedThreadId: string | null;
+ continuityState: SessionContinuityState;
+}
+
+function asRecord(value: unknown): Record | undefined {
+ if (!value || typeof value !== "object") return undefined;
+ return value as Record;
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+function approvalDetail(event: ProviderEvent): string | undefined {
+ const payload = asRecord(event.payload);
+ const command = asString(payload?.command);
+ if (command) return command;
+ return asString(payload?.reason);
+}
+
+function derivePendingApprovals(
+ events: ProviderEvent[],
+): PendingApprovalCard[] {
+ const pending = new Map();
+ const ordered = [...events].toReversed();
+
+ for (const event of ordered) {
+ if (
+ event.method === "session/closed" ||
+ event.method === "session/exited"
+ ) {
+ pending.clear();
+ continue;
+ }
+
+ const requestId =
+ event.requestId ?? asString(asRecord(event.payload)?.requestId);
+ if (!requestId) continue;
+
+ if (
+ event.kind === "request" &&
+ (event.requestKind === "command" || event.requestKind === "file-change")
+ ) {
+ const detail = approvalDetail(event);
+ pending.set(requestId, {
+ requestId,
+ requestKind: event.requestKind,
+ createdAt: event.createdAt,
+ ...(detail ? { detail } : {}),
+ });
+ continue;
+ }
+
+ if (event.method === "item/requestApproval/decision") {
+ pending.delete(requestId);
+ }
+ }
+
+ return Array.from(pending.values());
+}
+
+export default function ChatView() {
+ const { state, dispatch } = useStore();
+ const api = useMemo(() => readNativeApi(), []);
+ const [prompt, setPrompt] = useState("");
+ const [isSending, setIsSending] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
+ const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
+ const [lastEditor, setLastEditor] = useState(() => {
+ const stored = localStorage.getItem(LAST_EDITOR_KEY);
+ return EDITORS.some((e) => e.id === stored)
+ ? (stored as EditorId)
+ : EDITORS[0].id;
+ });
+ const [selectedEffort, setSelectedEffort] =
+ useState(DEFAULT_REASONING);
+ const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
+ const [respondingRequestIds, setRespondingRequestIds] = useState(
+ [],
+ );
+ const [expandedWorkGroups, setExpandedWorkGroups] = useState<
+ Record
+ >({});
+ const [nowTick, setNowTick] = useState(() => Date.now());
+ const messagesEndRef = useRef(null);
+ const textareaRef = useRef(null);
+ const modelMenuRef = useRef(null);
+ const editorMenuRef = useRef(null);
+
+ const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
+ const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
+ const selectedModel = resolveModelSlug(
+ activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
+ );
+ const phase = derivePhase(activeThread?.session ?? null);
+ const isWorking = phase === "running" || isSending || isConnecting;
+ const nowIso = new Date(nowTick).toISOString();
+ const modelOptions = MODEL_OPTIONS;
+ const workLogEntries = useMemo(
+ () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
+ [activeThread?.events],
+ );
+ const latestTurnWorkEntries = useMemo(
+ () =>
+ deriveWorkLogEntries(
+ activeThread?.events ?? [],
+ activeThread?.latestTurnId,
+ ),
+ [activeThread?.events, activeThread?.latestTurnId],
+ );
+ const pendingApprovals = useMemo(
+ () => derivePendingApprovals(activeThread?.events ?? []),
+ [activeThread?.events],
+ );
+ const assistantCompletionByItemId = useMemo(() => {
+ const map = new Map();
+ const ordered = [...(activeThread?.events ?? [])].toReversed();
+ for (const event of ordered) {
+ if (event.method !== "item/completed") continue;
+ if (!event.itemId) continue;
+ map.set(event.itemId, event.createdAt);
+ }
+ return map;
+ }, [activeThread?.events]);
+ const timelineEntries = useMemo(
+ () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries),
+ [activeThread?.messages, workLogEntries],
+ );
+ const completionSummary = useMemo(() => {
+ if (!activeThread?.latestTurnStartedAt) return null;
+ if (!activeThread.latestTurnCompletedAt) return null;
+ if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) {
+ return null;
+ }
+
+ if (
+ typeof activeThread.latestTurnDurationMs === "number" &&
+ Number.isFinite(activeThread.latestTurnDurationMs) &&
+ activeThread.latestTurnDurationMs >= 0
+ ) {
+ return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`;
+ }
+
+ const elapsed = formatElapsed(
+ activeThread.latestTurnStartedAt,
+ activeThread.latestTurnCompletedAt,
+ );
+ return elapsed ? `Worked for ${elapsed}` : null;
+ }, [
+ activeThread?.latestTurnStartedAt,
+ activeThread?.latestTurnCompletedAt,
+ activeThread?.latestTurnDurationMs,
+ latestTurnWorkEntries,
+ ]);
+ const completionDividerBeforeEntryId = useMemo(() => {
+ if (!activeThread?.latestTurnStartedAt) return null;
+ if (!activeThread.latestTurnCompletedAt) return null;
+ if (!completionSummary) return null;
+
+ const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt);
+ const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt);
+ if (Number.isNaN(turnStartedAt)) return null;
+ if (Number.isNaN(turnCompletedAt)) return null;
+
+ let inRangeMatch: string | null = null;
+ let fallbackMatch: string | null = null;
+ for (const timelineEntry of timelineEntries) {
+ if (timelineEntry.kind !== "message") continue;
+ if (timelineEntry.message.role !== "assistant") continue;
+ const messageAt = Date.parse(timelineEntry.message.createdAt);
+ if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue;
+ fallbackMatch = timelineEntry.id;
+ if (messageAt <= turnCompletedAt) {
+ inRangeMatch = timelineEntry.id;
+ }
+ }
+ return inRangeMatch ?? fallbackMatch;
+ }, [
+ activeThread?.latestTurnCompletedAt,
+ activeThread?.latestTurnStartedAt,
+ completionSummary,
+ timelineEntries,
+ ]);
+ const runtimeSessionConfig =
+ state.runtimeMode === "full-access"
+ ? ({
+ approvalPolicy: "never",
+ sandboxMode: "danger-full-access",
+ } as const)
+ : ({
+ approvalPolicy: "on-request",
+ sandboxMode: "workspace-write",
+ } as const);
+
+ const handleRuntimeModeChange = async (
+ mode: "approval-required" | "full-access",
+ ) => {
+ if (mode === state.runtimeMode) return;
+ dispatch({ type: "SET_RUNTIME_MODE", mode });
+ if (!api) return;
+
+ const sessionIds = state.threads
+ .map((t) => t.session)
+ .filter(
+ (s): s is NonNullable => s !== null && s.status !== "closed",
+ )
+ .map((s) => s.sessionId);
+
+ if (sessionIds.length === 0) return;
+
+ setIsSwitchingRuntimeMode(true);
+ try {
+ await Promise.all(
+ sessionIds.map((id) =>
+ api.providers.stopSession({ sessionId: id }).catch(() => undefined),
+ ),
+ );
+ } finally {
+ setIsSwitchingRuntimeMode(false);
+ }
+ };
+
+ // Auto-scroll on new messages
+ const messageCount = activeThread?.messages.length ?? 0;
+ const workLogCount = workLogEntries.length;
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [messageCount]);
+ useEffect(() => {
+ if (phase !== "running") return;
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [phase, workLogCount]);
+
+ useEffect(() => {
+ setExpandedWorkGroups({});
+ }, [activeThread?.id]);
+
+ // Auto-resize textarea
+ useEffect(() => {
+ const ta = textareaRef.current;
+ if (!ta) return;
+ ta.style.height = "auto";
+ ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
+ }, [prompt]);
+
+ useEffect(() => {
+ if (phase !== "running") return;
+ const timer = window.setInterval(() => {
+ setNowTick(Date.now());
+ }, 250);
+ return () => {
+ window.clearInterval(timer);
+ };
+ }, [phase]);
+
+ useEffect(() => {
+ if (!isModelMenuOpen) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!modelMenuRef.current) return;
+ if (event.target instanceof Node && !modelMenuRef.current.contains(event.target)) {
+ setIsModelMenuOpen(false);
+ }
+ };
+
+ window.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ window.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isModelMenuOpen]);
+
+ useEffect(() => {
+ if (!isEditorMenuOpen) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!editorMenuRef.current) return;
+ if (
+ event.target instanceof Node &&
+ !editorMenuRef.current.contains(event.target)
+ ) {
+ setIsEditorMenuOpen(false);
+ }
+ };
+
+ window.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ window.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isEditorMenuOpen]);
+
+ // Cmd+O / Ctrl+O to open in last-used editor
+ useEffect(() => {
+ const handler = (e: globalThis.KeyboardEvent) => {
+ if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
+ if (api && activeProject) {
+ e.preventDefault();
+ void api.shell.openInEditor(activeProject.cwd, lastEditor);
+ }
+ }
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [api, activeProject, lastEditor]);
+
+ const openInEditor = (editorId: EditorId) => {
+ if (!api || !activeProject) return;
+ void api.shell.openInEditor(activeProject.cwd, editorId);
+ setLastEditor(editorId);
+ localStorage.setItem(LAST_EDITOR_KEY, editorId);
+ setIsEditorMenuOpen(false);
+ };
+
+ const ensureSession = async (): Promise => {
+ if (!api || !activeThread || !activeProject) return null;
+ if (activeThread.session && activeThread.session.status !== "closed") {
+ const sessionThreadId = activeThread.session.threadId ?? null;
+ const continuityState: SessionContinuityState =
+ activeThread.codexThreadId === null
+ ? "new"
+ : sessionThreadId === activeThread.codexThreadId
+ ? "resumed"
+ : "fallback_new";
+ return {
+ sessionId: activeThread.session.sessionId,
+ resolvedThreadId: sessionThreadId,
+ continuityState,
+ } satisfies EnsuredSessionInfo;
+ }
+
+ const priorCodexThreadId = activeThread.codexThreadId;
+ setIsConnecting(true);
+ try {
+ const session = await api.providers.startSession({
+ provider: "codex",
+ cwd: activeProject.cwd || undefined,
+ model: selectedModel || undefined,
+ resumeThreadId: priorCodexThreadId ?? undefined,
+ approvalPolicy: runtimeSessionConfig.approvalPolicy,
+ sandboxMode: runtimeSessionConfig.sandboxMode,
+ });
+ dispatch({
+ type: "UPDATE_SESSION",
+ threadId: activeThread.id,
+ session,
+ });
+ const resolvedThreadId = session.threadId ?? null;
+ const continuityState: SessionContinuityState =
+ priorCodexThreadId === null
+ ? "new"
+ : resolvedThreadId === priorCodexThreadId
+ ? "resumed"
+ : "fallback_new";
+ return {
+ sessionId: session.sessionId,
+ resolvedThreadId,
+ continuityState,
+ };
+ } catch (err) {
+ dispatch({
+ type: "SET_ERROR",
+ threadId: activeThread.id,
+ error: err instanceof Error ? err.message : "Failed to connect.",
+ });
+ return null;
+ } finally {
+ setIsConnecting(false);
+ }
+ };
+
+ const onSend = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!api || !activeThread || isSending || isConnecting) return;
+ const trimmed = prompt.trim();
+ if (!trimmed) return;
+
+ // Auto-title from first message
+ if (activeThread.messages.length === 0) {
+ const title = trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed;
+ dispatch({
+ type: "SET_THREAD_TITLE",
+ threadId: activeThread.id,
+ title,
+ });
+ }
+
+ dispatch({
+ type: "SET_ERROR",
+ threadId: activeThread.id,
+ error: null,
+ });
+ dispatch({
+ type: "PUSH_USER_MESSAGE",
+ threadId: activeThread.id,
+ id: crypto.randomUUID(),
+ text: trimmed,
+ });
+ const previousMessages = activeThread.messages;
+ setPrompt("");
+
+ const sessionInfo = await ensureSession();
+ if (!sessionInfo) return;
+
+ setIsSending(true);
+ try {
+ const shouldBootstrap =
+ previousMessages.length > 0 &&
+ (sessionInfo.continuityState === "new" ||
+ sessionInfo.continuityState === "fallback_new");
+ const input = shouldBootstrap
+ ? buildBootstrapInput(
+ previousMessages,
+ trimmed,
+ PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
+ ).text
+ : trimmed;
+ await api.providers.sendTurn({
+ sessionId: sessionInfo.sessionId,
+ input,
+ model: selectedModel || undefined,
+ effort: selectedEffort || undefined,
+ });
+ } catch (err) {
+ dispatch({
+ type: "SET_ERROR",
+ threadId: activeThread.id,
+ error: err instanceof Error ? err.message : "Failed to send message.",
+ });
+ } finally {
+ setIsSending(false);
+ }
+ };
+
+ const onInterrupt = async () => {
+ if (!api || !activeThread?.session) return;
+ await api.providers.interruptTurn({
+ sessionId: activeThread.session.sessionId,
+ turnId: activeThread.session.activeTurnId,
+ });
+ };
+
+ const onRespondToApproval = async (
+ requestId: string,
+ decision: ProviderApprovalDecision,
+ ) => {
+ if (!api || !activeThread?.session) return;
+
+ setRespondingRequestIds((existing) =>
+ existing.includes(requestId) ? existing : [...existing, requestId],
+ );
+ try {
+ await api.providers.respondToRequest({
+ sessionId: activeThread.session.sessionId,
+ requestId,
+ decision,
+ });
+ } catch (err) {
+ dispatch({
+ type: "SET_ERROR",
+ threadId: activeThread.id,
+ error:
+ err instanceof Error
+ ? err.message
+ : "Failed to submit approval decision.",
+ });
+ } finally {
+ setRespondingRequestIds((existing) =>
+ existing.filter((id) => id !== requestId),
+ );
+ }
+ };
+
+ const onModelSelect = (model: string) => {
+ if (!activeThread) return;
+ dispatch({
+ type: "SET_THREAD_MODEL",
+ threadId: activeThread.id,
+ model: resolveModelSlug(model),
+ });
+ setIsModelMenuOpen(false);
+ };
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ void onSend(e as unknown as FormEvent);
+ }
+ };
+
+ // Empty state: no active thread
+ if (!activeThread) {
+ return (
+
+ {isElectron &&
}
+
+
+
Select a thread or create a new one to get started.
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Top bar */}
+
+
+ {/* Error banner */}
+ {activeThread.error && (
+
+ {activeThread.error}
+
+ )}
+
+ {pendingApprovals.length > 0 && (
+
+ {pendingApprovals.map((approval) => {
+ const isResponding = respondingRequestIds.includes(
+ approval.requestId,
+ );
+ return (
+
+
+ {approval.requestKind === "command"
+ ? "Command approval requested"
+ : "File-change approval requested"}
+
+ {approval.detail && (
+
+ {approval.detail}
+
+ )}
+
+
+ void onRespondToApproval(approval.requestId, "accept")
+ }
+ >
+ Approve once
+
+
+ void onRespondToApproval(
+ approval.requestId,
+ "acceptForSession",
+ )
+ }
+ >
+ Always allow this session
+
+
+ void onRespondToApproval(approval.requestId, "decline")
+ }
+ >
+ Decline
+
+
+ void onRespondToApproval(approval.requestId, "cancel")
+ }
+ >
+ Cancel turn
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Messages */}
+
+ {activeThread.messages.length === 0 && !isWorking ? (
+
+
Send a message to start the conversation.
+
+ ) : (
+
+ {timelineEntries.map((timelineEntry, index) => {
+ if (
+ timelineEntry.kind === "work" &&
+ timelineEntries[index - 1]?.kind === "work"
+ ) {
+ return null;
+ }
+
+ const showCompletionDivider =
+ timelineEntry.kind === "message" &&
+ timelineEntry.message.role === "assistant" &&
+ completionDividerBeforeEntryId === timelineEntry.id;
+
+ if (timelineEntry.kind === "work") {
+ const groupedEntries = [timelineEntry.entry];
+ let cursor = index + 1;
+ while (cursor < timelineEntries.length) {
+ const nextEntry = timelineEntries[cursor];
+ if (!nextEntry || nextEntry.kind !== "work") break;
+ groupedEntries.push(nextEntry.entry);
+ cursor += 1;
+ }
+
+ const groupId = timelineEntry.id;
+ const isExpanded = expandedWorkGroups[groupId] ?? false;
+ const hasOverflow =
+ groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
+ const visibleEntries =
+ hasOverflow && !isExpanded
+ ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
+ : groupedEntries;
+ const hiddenCount = groupedEntries.length - visibleEntries.length;
+ const onlyToolEntries = groupedEntries.every(
+ (entry) => entry.tone === "tool",
+ );
+ const groupLabel = onlyToolEntries
+ ? groupedEntries.length === 1
+ ? "Tool call"
+ : `Tool calls (${groupedEntries.length})`
+ : groupedEntries.length === 1
+ ? "Work event"
+ : `Work log (${groupedEntries.length})`;
+
+ return (
+
+
+
+
+ {groupLabel}
+
+ {hasOverflow && (
+
+ setExpandedWorkGroups((existing) => ({
+ ...existing,
+ [groupId]: !existing[groupId],
+ }))
+ }
+ >
+ {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
+
+ )}
+
+
+ {visibleEntries.map((workEntry) => (
+
+
+
+ {workEntry.detail ? (
+ <>
+ {workEntry.label}
+
+ {workEntry.detail}
+
+ >
+ ) : (
+ workEntry.label
+ )}
+
+
+ ))}
+
+
+
+ );
+ }
+
+ if (timelineEntry.message.role === "user") {
+ return (
+
+
+
+
+ {timelineEntry.message.text}
+
+
+ {formatTimestamp(timelineEntry.message.createdAt)}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {showCompletionDivider && (
+
+
+
+ {completionSummary
+ ? `Response • ${completionSummary}`
+ : "Response"}
+
+
+
+ )}
+
+
+
+ {formatMessageMeta(
+ timelineEntry.message.createdAt,
+ timelineEntry.message.streaming
+ ? formatElapsed(
+ timelineEntry.message.createdAt,
+ nowIso,
+ )
+ : formatElapsed(
+ timelineEntry.message.createdAt,
+ assistantCompletionByItemId.get(
+ timelineEntry.message.id,
+ ),
+ ),
+ )}
+
+
+
+ );
+ })}
+ {isWorking && (
+
+ )}
+
+
+ )}
+
+
+ {/* Input bar */}
+
+
+
+ {/* Textarea area */}
+
+ setPrompt(e.target.value)}
+ onKeyDown={onKeyDown}
+ placeholder={
+ phase === "disconnected" ? "Ask for follow-up changes" : "Ask anything..."
+ }
+ disabled={isSending || isConnecting}
+ />
+
+
+ {/* Bottom toolbar */}
+
+
+ {/* Model picker */}
+
+
setIsModelMenuOpen((open) => !open)}
+ >
+ {selectedModel}
+
+
+
+
+ {isModelMenuOpen && (
+
+
Select model
+
+ {modelOptions.map((model) => {
+ const isSelected = model === selectedModel;
+ return (
+ onModelSelect(model)}
+ >
+ {model}
+
+ ✓
+
+
+ );
+ })}
+
+
+ )}
+
+
+ {/* Divider */}
+
+
+ {/* Reasoning effort */}
+
+ {selectedEffort.charAt(0).toUpperCase() + selectedEffort.slice(1)}
+ setSelectedEffort(event.target.value)}
+ >
+ {REASONING_OPTIONS.map((effort) => (
+
+ {effort}
+ {effort === DEFAULT_REASONING ? " (default)" : ""}
+
+ ))}
+
+
+
+
+
+
+ {/* Divider */}
+
+
+ {/* Runtime mode toggle */}
+
+ void handleRuntimeModeChange(
+ state.runtimeMode === "full-access"
+ ? "approval-required"
+ : "full-access",
+ )
+ }
+ title={
+ state.runtimeMode === "full-access"
+ ? "Full access — click to require approvals"
+ : "Approval required — click for full access"
+ }
+ >
+ {state.runtimeMode === "full-access" ? (
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+ {state.runtimeMode === "full-access"
+ ? "Full access"
+ : "Supervised"}
+
+
+
+
+ {/* Right side: send / stop button */}
+
+ {phase === "running" ? (
+
void onInterrupt()}
+ aria-label="Stop generation"
+ >
+
+
+
+
+ ) : (
+
+ {isConnecting || isSending ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6a546c848fab82d87987c852e64e556dc4442fb1
--- /dev/null
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -0,0 +1,53 @@
+import { isElectron } from "../env";
+import { useStore } from "../store";
+
+export default function DiffPanel() {
+ const { dispatch } = useStore();
+
+ return (
+
+ {/* Header */}
+
+
Uncommitted changes
+ dispatch({ type: "TOGGLE_DIFF" })}
+ >
+ ×
+
+
+
+ {/* Placeholder content */}
+
+
+
+
+
+ M
+
+ src/App.tsx
+
+
+
- const oldValue = "hello";
+
+ const newValue = "world";
+
+
+
+
+
+
+ A
+
+ src/utils.ts
+
+
+
+
+
+ Diff integration coming soon
+
+
+
+ );
+}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..82568b4f9fbd623b3800feabee7094b5559fc9ea
--- /dev/null
+++ b/apps/web/src/components/Sidebar.tsx
@@ -0,0 +1,390 @@
+import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { isElectron } from "../env";
+import { useTheme } from "../hooks/useTheme";
+import { DEFAULT_MODEL } from "../model-logic";
+import { readNativeApi } from "../session-logic";
+import { useStore } from "../store";
+import type { Project } from "../types";
+
+const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
+function formatRelativeTime(iso: string): string {
+ const diff = Date.now() - new Date(iso).getTime();
+ const minutes = Math.floor(diff / 60_000);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+function inferProjectName(cwd: string): string {
+ const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
+ const parts = normalized.split("/");
+ const last = parts[parts.length - 1];
+ return last && last.length > 0 ? last : "project";
+}
+
+function threadStatusLabel(
+ status: "connecting" | "ready" | "running" | "error" | "closed" | undefined,
+): "Working" | "Connecting" | null {
+ if (status === "running") return "Working";
+ if (status === "connecting") return "Connecting";
+ return null;
+}
+
+export default function Sidebar() {
+ const { state, dispatch } = useStore();
+ const api = useMemo(() => readNativeApi(), []);
+ const { theme, setTheme } = useTheme();
+ const [addingProject, setAddingProject] = useState(false);
+ const [newCwd, setNewCwd] = useState("");
+ const [isPickingFolder, setIsPickingFolder] = useState(false);
+ const [isAddingProject, setIsAddingProject] = useState(false);
+
+ const handleNewThread = useCallback(
+ (projectId: string) => {
+ dispatch({
+ type: "ADD_THREAD",
+ thread: {
+ id: crypto.randomUUID(),
+ codexThreadId: null,
+ projectId,
+ title: "New thread",
+ model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
+ session: null,
+ messages: [],
+ events: [],
+ error: null,
+ createdAt: new Date().toISOString(),
+ },
+ });
+ },
+ [dispatch, state.projects],
+ );
+
+ const focusMostRecentThreadForProject = useCallback(
+ (projectId: string) => {
+ const latestThread = state.threads
+ .filter((thread) => thread.projectId === projectId)
+ .toSorted((a, b) => {
+ const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ if (byDate !== 0) return byDate;
+ return b.id.localeCompare(a.id);
+ })[0];
+ if (!latestThread) return;
+
+ dispatch({
+ type: "SET_ACTIVE_THREAD",
+ threadId: latestThread.id,
+ });
+ },
+ [dispatch, state.threads],
+ );
+
+ const addProjectFromPath = useCallback(
+ async (rawCwd: string) => {
+ const cwd = rawCwd.trim();
+ if (!cwd || isAddingProject) return;
+
+ setIsAddingProject(true);
+ try {
+ if (isElectron && api) {
+ const result = await api.projects.add({ cwd });
+ const project: Project = {
+ id: result.project.id,
+ name: result.project.name,
+ cwd: result.project.cwd,
+ model: DEFAULT_MODEL,
+ expanded: true,
+ };
+ const existingById = state.projects.find((p) => p.id === project.id);
+ const existingByCwd = state.projects.find((p) => p.cwd === project.cwd);
+ if (!existingById && !existingByCwd) {
+ dispatch({ type: "ADD_PROJECT", project });
+ }
+ const resolvedProjectId = existingByCwd?.id ?? project.id;
+
+ if (result.created) {
+ handleNewThread(resolvedProjectId);
+ } else {
+ focusMostRecentThreadForProject(resolvedProjectId);
+ }
+ } else {
+ const existing = state.projects.find((project) => project.cwd === cwd);
+ if (existing) {
+ focusMostRecentThreadForProject(existing.id);
+ return;
+ }
+
+ const name = inferProjectName(cwd);
+ const project: Project = {
+ id: crypto.randomUUID(),
+ name,
+ cwd,
+ model: DEFAULT_MODEL,
+ expanded: true,
+ };
+ dispatch({ type: "ADD_PROJECT", project });
+ handleNewThread(project.id);
+ }
+ } finally {
+ setIsAddingProject(false);
+ setNewCwd("");
+ setAddingProject(false);
+ }
+ },
+ [
+ api,
+ dispatch,
+ focusMostRecentThreadForProject,
+ handleNewThread,
+ isAddingProject,
+ state.projects,
+ ],
+ );
+
+ const handleAddProject = () => {
+ void addProjectFromPath(newCwd);
+ };
+
+ const handlePickFolder = async () => {
+ if (!api || isPickingFolder) return;
+ setIsPickingFolder(true);
+ try {
+ const pickedPath = await api.dialogs.pickFolder();
+ if (!pickedPath) return;
+ await addProjectFromPath(pickedPath);
+ } finally {
+ setIsPickingFolder(false);
+ }
+ };
+
+ useEffect(() => {
+ const onWindowKeyDown = (event: KeyboardEvent) => {
+ const isNewThreadShortcut =
+ event.metaKey &&
+ event.shiftKey &&
+ !event.ctrlKey &&
+ !event.altKey &&
+ event.key.toLowerCase() === "o";
+ if (!isNewThreadShortcut) return;
+
+ const target = event.target;
+ if (target instanceof HTMLElement) {
+ const tagName = target.tagName;
+ if (
+ target.isContentEditable ||
+ tagName === "INPUT" ||
+ tagName === "TEXTAREA" ||
+ tagName === "SELECT"
+ ) {
+ return;
+ }
+ }
+
+ const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
+ const projectId = activeThread?.projectId ?? state.projects[0]?.id;
+ if (!projectId) return;
+
+ event.preventDefault();
+ handleNewThread(projectId);
+ };
+
+ window.addEventListener("keydown", onWindowKeyDown);
+ return () => {
+ window.removeEventListener("keydown", onWindowKeyDown);
+ };
+ }, [handleNewThread, state.activeThreadId, state.projects, state.threads]);
+
+ return (
+
+ {/* Drag region / traffic light space (Electron only) */}
+ {isElectron &&
}
+ {/* Branding */}
+
+
+ T3 Code
+
+ setTheme(THEME_CYCLE[theme])}
+ aria-label={`Theme: ${theme}`}
+ title={`Theme: ${theme}`}
+ >
+ {theme === "system" ? (
+
+ ) : theme === "light" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* New thread (global) */}
+
+ {
+ if (state.projects.length === 0) {
+ setAddingProject(true);
+ return;
+ }
+ const firstProject = state.projects[0];
+ if (firstProject) handleNewThread(firstProject.id);
+ }}
+ >
+ +
+ New thread
+
+
+
+ {/* Project list */}
+
+ {state.projects.map((project) => {
+ const threads = state.threads
+ .filter((t) => t.projectId === project.id)
+ .toSorted((a, b) => {
+ const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ if (byDate !== 0) return byDate;
+ return b.id.localeCompare(a.id);
+ });
+ return (
+
+ {/* Project header */}
+
dispatch({ type: "TOGGLE_PROJECT", projectId: project.id })}
+ >
+
+ {project.expanded ? "▼" : "▶"}
+
+
+ {project.name}
+
+ {threads.length}
+
+
+ {/* Threads */}
+ {project.expanded && (
+
+ {threads.map((thread) => {
+ const isActive = state.activeThreadId === thread.id;
+ const threadStatus = threadStatusLabel(thread.session?.status);
+ return (
+
+ dispatch({
+ type: "SET_ACTIVE_THREAD",
+ threadId: thread.id,
+ })
+ }
+ >
+
+ {threadStatus && (
+
+
+ {threadStatus}
+
+ )}
+ {thread.title}
+
+
+ {formatRelativeTime(thread.createdAt)}
+
+
+ );
+ })}
+
+ {/* New thread within project */}
+
handleNewThread(project.id)}
+ >
+ + New thread
+
+
+ )}
+
+ );
+ })}
+
+ {state.projects.length === 0 && !addingProject && (
+
+ No projects yet.
+
+ Add one to get started.
+
+ )}
+
+
+ {/* Add project form */}
+ {addingProject ? (
+
+
+ Add project
+
+
setNewCwd(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleAddProject();
+ if (e.key === "Escape") setAddingProject(false);
+ }}
+ />
+ {isElectron && api && (
+
void handlePickFolder()}
+ disabled={isPickingFolder || isAddingProject}
+ >
+ {isPickingFolder ? "Picking folder..." : "Browse for folder"}
+
+ )}
+
+
+ {isAddingProject ? "Adding..." : "Add"}
+
+ setAddingProject(false)}
+ >
+ Cancel
+
+
+
+ ) : (
+
+ setAddingProject(true)}
+ >
+ + Add project
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fb2e493cadaae65b6a6069812201dde42f97f41b
--- /dev/null
+++ b/apps/web/src/env.ts
@@ -0,0 +1,8 @@
+/**
+ * True when running inside the Electron preload bridge, false in a regular browser.
+ * The preload script sets window.nativeApi via contextBridge before any web-app
+ * code executes, so this is reliable at module load time.
+ */
+export const isElectron =
+ typeof window !== "undefined" &&
+ (window.desktopBridge !== undefined || window.nativeApi !== undefined);
diff --git a/apps/web/src/historyBootstrap.test.ts b/apps/web/src/historyBootstrap.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..eb3b6929d62188fcdb3c0a4d0b5cc95882a28f09
--- /dev/null
+++ b/apps/web/src/historyBootstrap.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest";
+
+import { buildBootstrapInput } from "./historyBootstrap";
+
+describe("buildBootstrapInput", () => {
+ it("includes full transcript when under budget", () => {
+ const result = buildBootstrapInput(
+ [
+ {
+ id: "u-1",
+ role: "user",
+ text: "hello",
+ createdAt: "2026-02-09T00:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: "a-1",
+ role: "assistant",
+ text: "world",
+ createdAt: "2026-02-09T00:00:01.000Z",
+ streaming: false,
+ },
+ ],
+ "what's next?",
+ 1_500,
+ );
+
+ expect(result.includedCount).toBe(2);
+ expect(result.omittedCount).toBe(0);
+ expect(result.truncated).toBe(false);
+ expect(result.text).toContain("USER:\nhello");
+ expect(result.text).toContain("ASSISTANT:\nworld");
+ expect(result.text).toContain("Latest user request (answer this now):");
+ expect(result.text).toContain("what's next?");
+ });
+
+ it("truncates older transcript messages when over budget", () => {
+ const result = buildBootstrapInput(
+ [
+ {
+ id: "u-1",
+ role: "user",
+ text: "first question with details",
+ createdAt: "2026-02-09T00:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: "a-1",
+ role: "assistant",
+ text: "first answer with details",
+ createdAt: "2026-02-09T00:00:01.000Z",
+ streaming: false,
+ },
+ {
+ id: "u-2",
+ role: "user",
+ text: "second question with details",
+ createdAt: "2026-02-09T00:00:02.000Z",
+ streaming: false,
+ },
+ ],
+ "final request",
+ 320,
+ );
+
+ expect(result.truncated).toBe(true);
+ expect(result.omittedCount).toBeGreaterThan(0);
+ expect(result.includedCount).toBeLessThan(3);
+ expect(result.text).toContain("omitted to stay within input limits");
+ expect(result.text.length).toBeLessThanOrEqual(320);
+ });
+
+ it("preserves the latest prompt when prompt-only fallback is required", () => {
+ const latestPrompt = "Please keep this exact latest prompt.";
+ const result = buildBootstrapInput(
+ [
+ {
+ id: "u-1",
+ role: "user",
+ text: "old context",
+ createdAt: "2026-02-09T00:00:00.000Z",
+ streaming: false,
+ },
+ ],
+ latestPrompt,
+ latestPrompt.length + 3,
+ );
+
+ expect(result.text).toBe(latestPrompt);
+ expect(result.includedCount).toBe(0);
+ expect(result.omittedCount).toBe(1);
+ expect(result.truncated).toBe(true);
+ });
+});
diff --git a/apps/web/src/historyBootstrap.ts b/apps/web/src/historyBootstrap.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6d0f504d33fa536cb8371bfaef8adb294f566a2a
--- /dev/null
+++ b/apps/web/src/historyBootstrap.ts
@@ -0,0 +1,120 @@
+import type { ChatMessage } from "./types";
+
+export interface BootstrapInputResult {
+ text: string;
+ includedCount: number;
+ omittedCount: number;
+ truncated: boolean;
+}
+
+const BOOTSTRAP_PREAMBLE =
+ "Continue this conversation using the transcript context below. The final section is the latest user request to answer now.";
+const TRANSCRIPT_HEADER = "Transcript context:";
+const LATEST_PROMPT_HEADER = "Latest user request (answer this now):";
+const OMITTED_SUMMARY = (count: number) =>
+ `[${count} earlier message(s) omitted to stay within input limits.]`;
+
+function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" {
+ return message.role === "assistant" ? "ASSISTANT" : "USER";
+}
+
+function buildMessageBlock(message: ChatMessage): string {
+ return `${messageRoleLabel(message)}:\n${message.text}`;
+}
+
+function finalizeWithPrompt(
+ transcriptBody: string,
+ latestPrompt: string,
+ maxChars: number,
+): string | null {
+ const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`;
+ return text.length <= maxChars ? text : null;
+}
+
+export function buildBootstrapInput(
+ previousMessages: ChatMessage[],
+ latestPrompt: string,
+ maxChars: number,
+): BootstrapInputResult {
+ const budget = Number.isFinite(maxChars)
+ ? Math.max(1, Math.floor(maxChars))
+ : 1;
+ const promptOnly =
+ latestPrompt.length <= budget
+ ? latestPrompt
+ : latestPrompt.slice(0, budget);
+
+ if (previousMessages.length === 0) {
+ return {
+ text: promptOnly,
+ includedCount: 0,
+ omittedCount: 0,
+ truncated: promptOnly.length !== latestPrompt.length,
+ };
+ }
+
+ const newestFirstBlocks: string[] = [];
+ for (let index = previousMessages.length - 1; index >= 0; index -= 1) {
+ const message = previousMessages[index];
+ if (!message) continue;
+ newestFirstBlocks.push(buildMessageBlock(message));
+ }
+
+ if (newestFirstBlocks.length === 0) {
+ return {
+ text: promptOnly,
+ includedCount: 0,
+ omittedCount: previousMessages.length,
+ truncated: true,
+ };
+ }
+
+ // Include a contiguous suffix from newest to oldest, then reverse to chronological.
+ let includedNewestFirst: string[] = [];
+ for (const block of newestFirstBlocks) {
+ const nextNewestFirst = [...includedNewestFirst, block];
+ const nextChronological = [...nextNewestFirst].reverse();
+ const omittedCount = newestFirstBlocks.length - nextChronological.length;
+ const transcriptBody =
+ omittedCount > 0
+ ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}`
+ : nextChronological.join("\n\n");
+ if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) {
+ break;
+ }
+ includedNewestFirst = nextNewestFirst;
+ }
+
+ let includedChronological = [...includedNewestFirst].reverse();
+ while (true) {
+ const omittedCount =
+ newestFirstBlocks.length - includedChronological.length;
+ const transcriptBody =
+ omittedCount > 0
+ ? includedChronological.length > 0
+ ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}`
+ : OMITTED_SUMMARY(omittedCount)
+ : includedChronological.join("\n\n");
+ const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget);
+ if (finalized) {
+ return {
+ text: finalized,
+ includedCount: includedChronological.length,
+ omittedCount,
+ truncated:
+ omittedCount > 0 || latestPrompt.length !== promptOnly.length,
+ };
+ }
+
+ if (includedChronological.length === 0) {
+ return {
+ text: promptOnly,
+ includedCount: 0,
+ omittedCount: previousMessages.length,
+ truncated: true,
+ };
+ }
+
+ includedChronological = includedChronological.slice(1);
+ }
+}
diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4099cdc91212557eb630cd9d673d0f22c1ae018c
--- /dev/null
+++ b/apps/web/src/hooks/useTheme.ts
@@ -0,0 +1,90 @@
+import { useCallback, useEffect, useSyncExternalStore } from "react";
+
+type Theme = "light" | "dark" | "system";
+
+const STORAGE_KEY = "t3code:theme";
+const MEDIA_QUERY = "(prefers-color-scheme: dark)";
+
+let listeners: Array<() => void> = [];
+function emitChange() {
+ for (const listener of listeners) listener();
+}
+
+function getSystemDark(): boolean {
+ return window.matchMedia(MEDIA_QUERY).matches;
+}
+
+function getStored(): Theme {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw === "light" || raw === "dark" || raw === "system") return raw;
+ return "system";
+}
+
+function applyTheme(theme: Theme, suppressTransitions = false) {
+ if (suppressTransitions) {
+ document.documentElement.classList.add("no-transitions");
+ }
+ const isDark = theme === "dark" || (theme === "system" && getSystemDark());
+ document.documentElement.classList.toggle("dark", isDark);
+ if (suppressTransitions) {
+ // Force a reflow so the no-transitions class takes effect before removal
+ document.documentElement.offsetHeight;
+ requestAnimationFrame(() => {
+ document.documentElement.classList.remove("no-transitions");
+ });
+ }
+}
+
+// Apply immediately on module load to prevent flash
+applyTheme(getStored());
+
+function getSnapshot(): Theme {
+ return getStored();
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.push(listener);
+
+ // Listen for system preference changes
+ const mq = window.matchMedia(MEDIA_QUERY);
+ const handleChange = () => {
+ if (getStored() === "system") applyTheme("system", true);
+ emitChange();
+ };
+ mq.addEventListener("change", handleChange);
+
+ // Listen for storage changes from other tabs
+ const handleStorage = (e: StorageEvent) => {
+ if (e.key === STORAGE_KEY) {
+ applyTheme(getStored(), true);
+ emitChange();
+ }
+ };
+ window.addEventListener("storage", handleStorage);
+
+ return () => {
+ listeners = listeners.filter((l) => l !== listener);
+ mq.removeEventListener("change", handleChange);
+ window.removeEventListener("storage", handleStorage);
+ };
+}
+
+export function useTheme() {
+ const theme = useSyncExternalStore(subscribe, getSnapshot);
+
+ const resolvedTheme: "light" | "dark" =
+ theme === "system" ? (getSystemDark() ? "dark" : "light") : theme;
+
+ const setTheme = useCallback((next: Theme) => {
+ localStorage.setItem(STORAGE_KEY, next);
+ applyTheme(next, true);
+ emitChange();
+ }, []);
+
+ // Keep DOM in sync on mount/change
+ useEffect(() => {
+ applyTheme(theme);
+ }, [theme]);
+
+ return { theme, setTheme, resolvedTheme } as const;
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..96a2093aef5d198d85d73f3c4ebce2654a77b0d0
--- /dev/null
+++ b/apps/web/src/index.css
@@ -0,0 +1,340 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark, .dark *));
+
+@theme inline {
+ --animate-skeleton: skeleton 2s -1s infinite linear;
+ --color-warning-foreground: var(--warning-foreground);
+ --color-warning: var(--warning);
+ --color-success-foreground: var(--success-foreground);
+ --color-success: var(--success);
+ --color-info-foreground: var(--info-foreground);
+ --color-info: var(--info);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ @keyframes skeleton {
+ to {
+ background-position: -200% 0;
+ }
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Suppress all transitions during theme changes */
+.no-transitions,
+.no-transitions *,
+.no-transitions *::before,
+.no-transitions *::after {
+ transition-duration: 0s !important;
+ animation-duration: 0s !important;
+}
+
+:root {
+ color-scheme: light;
+ --radius: 0.625rem;
+ --background: var(--color-white);
+ --foreground: var(--color-neutral-800);
+ --card: var(--color-white);
+ --card-foreground: var(--color-neutral-800);
+ --popover: var(--color-white);
+ --popover-foreground: var(--color-neutral-800);
+ --primary: oklch(0.488 0.217 264);
+ --primary-foreground: var(--color-white);
+ --secondary: --alpha(var(--color-black) / 4%);
+ --secondary-foreground: var(--color-neutral-800);
+ --muted: --alpha(var(--color-black) / 4%);
+ --muted-foreground: color-mix(
+ in srgb,
+ var(--color-neutral-500) 90%,
+ var(--color-black)
+ );
+ --accent: --alpha(var(--color-black) / 5%);
+ --accent-foreground: var(--color-neutral-800);
+ --destructive: var(--color-red-500);
+ --border: --alpha(var(--color-black) / 8%);
+ --input: --alpha(var(--color-black) / 10%);
+ --ring: oklch(0.488 0.217 264);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+ --destructive-foreground: var(--color-red-700);
+ --info: var(--color-blue-500);
+ --info-foreground: var(--color-blue-700);
+ --success: var(--color-emerald-500);
+ --success-foreground: var(--color-emerald-700);
+ --warning: var(--color-amber-500);
+ --warning-foreground: var(--color-amber-700);
+
+ @variant dark {
+ color-scheme: dark;
+ --background: color-mix(
+ in srgb,
+ var(--color-neutral-950) 95%,
+ var(--color-white)
+ );
+ --foreground: var(--color-neutral-100);
+ --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
+ --card-foreground: var(--color-neutral-100);
+ --popover: color-mix(in srgb, var(--background) 98%, var(--color-white));
+ --popover-foreground: var(--color-neutral-100);
+ --primary: oklch(0.588 0.217 264);
+ --primary-foreground: var(--color-white);
+ --secondary: --alpha(var(--color-white) / 4%);
+ --secondary-foreground: var(--color-neutral-100);
+ --muted: --alpha(var(--color-white) / 4%);
+ --muted-foreground: color-mix(
+ in srgb,
+ var(--color-neutral-500) 90%,
+ var(--color-white)
+ );
+ --accent: --alpha(var(--color-white) / 4%);
+ --accent-foreground: var(--color-neutral-100);
+ --destructive: color-mix(
+ in srgb,
+ var(--color-red-500) 90%,
+ var(--color-white)
+ );
+ --border: --alpha(var(--color-white) / 6%);
+ --input: --alpha(var(--color-white) / 8%);
+ --ring: oklch(0.588 0.217 264);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+ --destructive-foreground: var(--color-red-400);
+ --info: var(--color-blue-500);
+ --info-foreground: var(--color-blue-400);
+ --success: var(--color-emerald-500);
+ --success-foreground: var(--color-emerald-400);
+ --warning: var(--color-amber-500);
+ --warning-foreground: var(--color-amber-400);
+ }
+}
+
+body {
+ font-family:
+ "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+}
+
+body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0.035;
+ 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");
+ background-repeat: repeat;
+ background-size: 256px 256px;
+}
+
+pre,
+code,
+textarea,
+input {
+ font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
+}
+
+/* Window drag region (frameless titlebar) */
+.drag-region {
+ -webkit-app-region: drag;
+}
+
+.drag-region button,
+.drag-region input,
+.drag-region textarea,
+.drag-region select,
+.drag-region a {
+ -webkit-app-region: no-drag;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.15);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.25);
+}
+
+.dark ::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.dark ::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.18);
+}
+
+/* Reasoning select -- clickable label surface */
+label:has(> select#reasoning-effort) {
+ position: relative;
+}
+label:has(> select#reasoning-effort) select {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+ width: 100%;
+ height: 100%;
+}
+
+/* Chat markdown rendering */
+.chat-markdown {
+ overflow-wrap: anywhere;
+}
+
+.chat-markdown > :first-child {
+ margin-top: 0;
+}
+
+.chat-markdown > :last-child {
+ margin-bottom: 0;
+}
+
+.chat-markdown p,
+.chat-markdown ul,
+.chat-markdown ol,
+.chat-markdown blockquote,
+.chat-markdown pre,
+.chat-markdown table {
+ margin: 0.65rem 0;
+}
+
+.chat-markdown ul,
+.chat-markdown ol {
+ padding-left: 1.25rem;
+}
+
+.chat-markdown li + li {
+ margin-top: 0.25rem;
+}
+
+.chat-markdown a {
+ color: var(--info-foreground);
+ text-decoration: underline;
+ text-decoration-color: color-mix(
+ in srgb,
+ var(--info-foreground) 40%,
+ transparent
+ );
+}
+
+.chat-markdown a:hover {
+ opacity: 0.8;
+}
+
+.chat-markdown blockquote {
+ border-left: 2px solid var(--border);
+ padding-left: 0.8rem;
+ color: var(--muted-foreground);
+}
+
+.chat-markdown :not(pre) > code {
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ background: var(--muted);
+ padding: 0.1rem 0.35rem;
+ color: var(--foreground);
+}
+
+.chat-markdown pre {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: 0.75rem;
+ background: var(--muted);
+ padding: 0.8rem 0.9rem;
+}
+
+.chat-markdown pre code {
+ border: none;
+ background: transparent;
+ padding: 0;
+ line-height: 1.5;
+}
+
+.chat-markdown table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.chat-markdown th,
+.chat-markdown td {
+ border: 1px solid var(--border);
+ padding: 0.35rem 0.45rem;
+ text-align: left;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d73b7ad2d06c4f8d6373b59e8b3ee97c127cbe6
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "highlight.js/styles/github-dark.css";
+
+import App from "./App";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+ ,
+);
diff --git a/apps/web/src/model-logic.test.ts b/apps/web/src/model-logic.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..91222e0f8d2613e724b4d864a24a65e13d6f57bd
--- /dev/null
+++ b/apps/web/src/model-logic.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic";
+
+describe("normalizeModelSlug", () => {
+ it("maps 5.3 aliases to canonical codex slug", () => {
+ expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
+ expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
+ });
+
+ it("preserves non-aliased slugs", () => {
+ expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
+ expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
+ });
+
+ it("returns null for empty input", () => {
+ expect(normalizeModelSlug("")).toBeNull();
+ expect(normalizeModelSlug(" ")).toBeNull();
+ expect(normalizeModelSlug(null)).toBeNull();
+ expect(normalizeModelSlug(undefined)).toBeNull();
+ });
+});
+
+describe("resolveModelSlug", () => {
+ it("falls back to default for unknown models", () => {
+ expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
+ });
+
+ it("resolves supported hard-coded models", () => {
+ expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex");
+ expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
+ expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2");
+ });
+});
diff --git a/apps/web/src/model-logic.ts b/apps/web/src/model-logic.ts
new file mode 100644
index 0000000000000000000000000000000000000000..72f615b188d55c5d1728bc0369d29000b502e6e2
--- /dev/null
+++ b/apps/web/src/model-logic.ts
@@ -0,0 +1,9 @@
+export {
+ DEFAULT_MODEL,
+ MODEL_OPTIONS,
+ normalizeModelSlug,
+ resolveModelSlug,
+} from "@t3tools/contracts";
+
+export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
+export const DEFAULT_REASONING = "high";
diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5ed9a4900ba5ae86d7c347825bebc4b69df88578
--- /dev/null
+++ b/apps/web/src/persistenceSchema.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from "vitest";
+
+import { DEFAULT_MODEL } from "./model-logic";
+import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
+import type { Thread } from "./types";
+
+describe("hydratePersistedState", () => {
+ it("returns null for invalid payloads", () => {
+ expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull();
+ expect(hydratePersistedState("not-json", false)).toBeNull();
+ });
+
+ it("migrates the legacy default model to the current default", () => {
+ const payload = JSON.stringify({
+ projects: [
+ {
+ id: "p-1",
+ name: "Project",
+ cwd: "/tmp/project",
+ model: "gpt-5.2-codex",
+ expanded: true,
+ },
+ ],
+ threads: [
+ {
+ id: "t-1",
+ projectId: "p-1",
+ title: "Thread",
+ model: "gpt-5.2-codex",
+ messages: [
+ {
+ id: "m-1",
+ role: "assistant",
+ text: "Hello",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ streaming: true,
+ },
+ ],
+ createdAt: "2026-02-08T10:00:00.000Z",
+ },
+ ],
+ activeThreadId: "t-1",
+ });
+
+ const hydrated = hydratePersistedState(payload, true);
+ expect(hydrated).not.toBeNull();
+ expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
+ expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
+ expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
+ expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
+ expect(hydrated?.runtimeMode).toBe("full-access");
+ });
+
+ it("filters unknown project references and repairs active thread", () => {
+ const payload = JSON.stringify({
+ projects: [
+ {
+ id: "p-1",
+ name: "Project",
+ cwd: "/tmp/project",
+ model: "gpt-5.3-codex",
+ expanded: false,
+ },
+ ],
+ threads: [
+ {
+ id: "t-1",
+ projectId: "p-1",
+ title: "Valid thread",
+ model: "gpt-5.3-codex",
+ messages: [],
+ createdAt: "2026-02-08T10:00:00.000Z",
+ },
+ {
+ id: "t-2",
+ projectId: "p-missing",
+ title: "Dangling thread",
+ model: "gpt-5.3-codex",
+ messages: [],
+ createdAt: "2026-02-08T10:00:00.000Z",
+ },
+ ],
+ activeThreadId: "t-2",
+ });
+
+ const hydrated = hydratePersistedState(payload, false);
+ expect(hydrated).not.toBeNull();
+ expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
+ expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
+ expect(hydrated?.activeThreadId).toBe("t-1");
+ expect(hydrated?.runtimeMode).toBe("full-access");
+ });
+
+ it("hydrates runtime mode from v3 payload", () => {
+ const payload = JSON.stringify({
+ version: 3,
+ runtimeMode: "approval-required",
+ projects: [
+ {
+ id: "p-1",
+ name: "Project",
+ cwd: "/tmp/project",
+ model: "gpt-5.3-codex",
+ expanded: false,
+ },
+ ],
+ threads: [],
+ activeThreadId: null,
+ });
+
+ const hydrated = hydratePersistedState(payload, false);
+ expect(hydrated?.runtimeMode).toBe("approval-required");
+ });
+});
+
+describe("toPersistedState", () => {
+ it("writes v4 payload and strips non-persisted thread fields", () => {
+ const thread: Thread = {
+ id: "t-1",
+ codexThreadId: "thr_1",
+ projectId: "p-1",
+ title: "Thread",
+ model: "gpt-5.3-codex",
+ session: null,
+ messages: [
+ {
+ id: "m-1",
+ role: "user",
+ text: "Hi",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ streaming: false,
+ },
+ ],
+ events: [],
+ error: "boom",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ };
+
+ const persisted = toPersistedState({
+ projects: [
+ {
+ id: "p-1",
+ name: "Project",
+ cwd: "/tmp/project",
+ model: "gpt-5.3-codex",
+ expanded: true,
+ },
+ ],
+ threads: [thread],
+ activeThreadId: "t-1",
+ runtimeMode: "full-access",
+ });
+
+ expect(persisted.version).toBe(4);
+ expect(persisted.runtimeMode).toBe("full-access");
+ expect(persisted.threads[0]).toEqual({
+ id: "t-1",
+ codexThreadId: "thr_1",
+ projectId: "p-1",
+ title: "Thread",
+ model: "gpt-5.3-codex",
+ messages: thread.messages,
+ createdAt: thread.createdAt,
+ });
+ const persistedThread = persisted.threads[0];
+ expect(persistedThread).toBeDefined();
+ if (!persistedThread) return;
+
+ expect("error" in persistedThread).toBe(false);
+ expect("session" in persistedThread).toBe(false);
+ });
+});
diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts
new file mode 100644
index 0000000000000000000000000000000000000000..e6b4983a21353f7e865bc9c7fdcddf346524bd7c
--- /dev/null
+++ b/apps/web/src/persistenceSchema.ts
@@ -0,0 +1,177 @@
+import { z } from "zod";
+
+import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
+import {
+ DEFAULT_RUNTIME_MODE,
+ type Project,
+ type RuntimeMode,
+ type Thread,
+} from "./types";
+
+const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
+
+const persistedProjectSchema = z.object({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ cwd: z.string().min(1),
+ model: z.string().min(1),
+ expanded: z.boolean(),
+});
+
+const persistedMessageSchema = z.object({
+ id: z.string().min(1),
+ role: z.enum(["user", "assistant"]),
+ text: z.string(),
+ createdAt: z.string().min(1),
+ streaming: z.boolean(),
+});
+
+const persistedThreadSchema = z.object({
+ id: z.string().min(1),
+ codexThreadId: z.string().min(1).nullable().default(null),
+ projectId: z.string().min(1),
+ title: z.string().min(1),
+ model: z.string().min(1),
+ messages: z.array(persistedMessageSchema),
+ createdAt: z.string().min(1),
+});
+
+const persistedStateBodySchema = z.object({
+ projects: z.array(persistedProjectSchema),
+ threads: z.array(persistedThreadSchema),
+ activeThreadId: z.string().min(1).nullable(),
+});
+
+const runtimeModeSchema = z.enum(["approval-required", "full-access"]);
+
+export const persistedStateV1Schema = persistedStateBodySchema.extend({
+ version: z.literal(1).optional(),
+});
+
+export const persistedStateV2Schema = persistedStateBodySchema.extend({
+ version: z.literal(2).optional(),
+});
+
+export const persistedStateV3Schema = persistedStateBodySchema.extend({
+ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
+ version: z.literal(3).optional(),
+});
+
+export const persistedStateV4Schema = persistedStateBodySchema.extend({
+ runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
+ version: z.literal(4).optional(),
+});
+
+const persistedStateSchema = z.union([
+ persistedStateV4Schema,
+ persistedStateV3Schema,
+ persistedStateV2Schema,
+ persistedStateV1Schema,
+]);
+
+export interface PersistedStoreSnapshot {
+ projects: Project[];
+ threads: Thread[];
+ activeThreadId: string | null;
+ runtimeMode: RuntimeMode;
+}
+
+function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string {
+ if (!isLegacyPayload) {
+ return model;
+ }
+
+ return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model;
+}
+
+function hydrateProject(
+ project: z.infer,
+ isLegacyPayload: boolean,
+): Project {
+ return {
+ ...project,
+ model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)),
+ };
+}
+
+function hydrateThread(
+ thread: z.infer,
+ isLegacyPayload: boolean,
+): Thread {
+ return {
+ id: thread.id,
+ codexThreadId: thread.codexThreadId,
+ projectId: thread.projectId,
+ title: thread.title,
+ model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
+ session: null,
+ messages: thread.messages.map((message) => ({
+ ...message,
+ streaming: false,
+ })),
+ events: [],
+ error: null,
+ createdAt: thread.createdAt,
+ };
+}
+
+export function hydratePersistedState(
+ raw: string,
+ isLegacyPayload: boolean,
+): PersistedStoreSnapshot | null {
+ let parsedJson: unknown;
+ try {
+ parsedJson = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+
+ const parsedState = persistedStateSchema.safeParse(parsedJson);
+ if (!parsedState.success) {
+ return null;
+ }
+
+ const projects = parsedState.data.projects.map((project) =>
+ hydrateProject(project, isLegacyPayload),
+ );
+ const projectIds = new Set(projects.map((project) => project.id));
+ const threads = parsedState.data.threads
+ .map((thread) => hydrateThread(thread, isLegacyPayload))
+ .filter((thread) => projectIds.has(thread.projectId));
+ const hasActiveThread = Boolean(
+ parsedState.data.activeThreadId &&
+ threads.some((thread) => thread.id === parsedState.data.activeThreadId),
+ );
+
+ return {
+ projects,
+ threads,
+ activeThreadId: hasActiveThread
+ ? parsedState.data.activeThreadId
+ : (threads[0]?.id ?? null),
+ runtimeMode:
+ "runtimeMode" in parsedState.data
+ ? parsedState.data.runtimeMode
+ : DEFAULT_RUNTIME_MODE,
+ };
+}
+
+export function toPersistedState(
+ state: PersistedStoreSnapshot,
+): z.infer {
+ return {
+ version: 4,
+ projects: state.projects,
+ threads: state.threads.map((thread) => ({
+ id: thread.id,
+ codexThreadId: thread.codexThreadId,
+ projectId: thread.projectId,
+ title: thread.title,
+ model: thread.model,
+ messages: thread.messages,
+ createdAt: thread.createdAt,
+ })),
+ activeThreadId: state.activeThreadId,
+ runtimeMode: state.runtimeMode,
+ };
+}
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..40fea5d77a7994e30bb5a5904b783478511013f2
--- /dev/null
+++ b/apps/web/src/session-logic.test.ts
@@ -0,0 +1,632 @@
+import { describe, expect, it } from "vitest";
+
+import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
+import {
+ type WorkLogEntry,
+ applyEventToMessages,
+ deriveTimelineEntries,
+ deriveWorkLogEntries,
+ evolveSession,
+} from "./session-logic";
+import type { ChatMessage } from "./types";
+
+function makeEvent(overrides: Partial): ProviderEvent {
+ return {
+ id: "evt-1",
+ kind: "notification",
+ provider: "codex",
+ sessionId: "sess-1",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ method: "noop",
+ ...overrides,
+ };
+}
+
+function makeSession(overrides: Partial = {}): ProviderSession {
+ return {
+ sessionId: "sess-1",
+ provider: "codex",
+ status: "ready",
+ createdAt: "2026-02-08T09:59:00.000Z",
+ updatedAt: "2026-02-08T09:59:00.000Z",
+ ...overrides,
+ };
+}
+
+describe("deriveTimelineEntries", () => {
+ it("interleaves messages and work entries by timestamp", () => {
+ const messages: ChatMessage[] = [
+ {
+ id: "m-user",
+ role: "user",
+ text: "Hi",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: "m-assistant",
+ role: "assistant",
+ text: "Hello",
+ createdAt: "2026-02-08T10:05:00.000Z",
+ streaming: false,
+ },
+ ];
+ const workEntries: WorkLogEntry[] = [
+ {
+ id: "w-1",
+ label: "Tool call",
+ createdAt: "2026-02-08T10:02:00.000Z",
+ tone: "tool",
+ },
+ {
+ id: "w-2",
+ label: "Preamble",
+ createdAt: "2026-02-08T10:03:00.000Z",
+ tone: "thinking",
+ },
+ ];
+
+ const timeline = deriveTimelineEntries(messages, workEntries);
+
+ expect(timeline.map((entry) => entry.id)).toEqual([
+ "message:m-user",
+ "work:w-1",
+ "work:w-2",
+ "message:m-assistant",
+ ]);
+ });
+
+ it("prefers work entries when timestamps are equal", () => {
+ const messages: ChatMessage[] = [
+ {
+ id: "m-1",
+ role: "assistant",
+ text: "Done",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ streaming: false,
+ },
+ ];
+ const workEntries: WorkLogEntry[] = [
+ {
+ id: "w-1",
+ label: "Tool call",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ tone: "tool",
+ },
+ ];
+
+ const timeline = deriveTimelineEntries(messages, workEntries);
+
+ expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]);
+ });
+});
+
+describe("deriveWorkLogEntries", () => {
+ it("drops preamble/work events from the visible work log", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-1",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ payload: { item: { type: "preamble", text: "thinking" } },
+ }),
+ makeEvent({
+ id: "evt-2",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:01.000Z",
+ payload: { item: { type: "work", text: "planning" } },
+ }),
+ makeEvent({
+ id: "evt-3",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:02.000Z",
+ payload: { item: { type: "tool_call", command: "ls -la" } },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.label).toBe("Tool call");
+ });
+
+ it("does not surface successful turn completion as a work-log row", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-start",
+ method: "turn/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ }),
+ makeEvent({
+ id: "evt-complete",
+ method: "turn/completed",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:10.000Z",
+ payload: { turn: { id: "turn-1", status: "completed" } },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries).toHaveLength(0);
+ });
+
+ it("shows failed turn completion in the work log", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-start",
+ method: "turn/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ }),
+ makeEvent({
+ id: "evt-complete",
+ method: "turn/completed",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:10.000Z",
+ payload: {
+ turn: {
+ id: "turn-1",
+ status: "failed",
+ error: { message: "sandbox denied" },
+ },
+ },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.label).toBe("Turn failed");
+ expect(entries[0]?.detail).toBe("sandbox denied");
+ });
+
+ it("hides reasoning and agent-message noise from the visible work log", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-turn-start",
+ method: "turn/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ }),
+ makeEvent({
+ id: "evt-summary-part",
+ method: "item/reasoning/summaryPartAdded",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:01.000Z",
+ }),
+ makeEvent({
+ id: "evt-summary-delta",
+ method: "item/reasoning/summaryTextDelta",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:02.000Z",
+ }),
+ makeEvent({
+ id: "evt-agent-start",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:03.000Z",
+ payload: {
+ item: { id: "item-msg", type: "agentMessage", text: "Working..." },
+ },
+ }),
+ makeEvent({
+ id: "evt-agent-complete",
+ method: "item/completed",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:04.000Z",
+ payload: {
+ item: {
+ id: "item-msg",
+ type: "agentMessage",
+ text: "Done response",
+ },
+ },
+ }),
+ makeEvent({
+ id: "evt-tool-start",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:05.000Z",
+ payload: {
+ item: { id: "item-tool", type: "tool_call", command: "ls -la" },
+ },
+ }),
+ makeEvent({
+ id: "evt-tool-complete",
+ method: "item/completed",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:06.000Z",
+ payload: {
+ item: { id: "item-tool", type: "tool_call", command: "ls -la" },
+ },
+ }),
+ makeEvent({
+ id: "evt-turn-complete",
+ method: "turn/completed",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:08.000Z",
+ payload: { turn: { id: "turn-1", status: "completed" } },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]);
+ });
+
+ it("coalesces command start/completed lifecycle into one entry", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-command-start",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:01.000Z",
+ payload: {
+ item: {
+ id: "item-command",
+ type: "command_execution",
+ command: "git status --short",
+ },
+ },
+ }),
+ makeEvent({
+ id: "evt-command-complete",
+ method: "item/completed",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:02.000Z",
+ payload: {
+ item: {
+ id: "item-command",
+ type: "command_execution",
+ command: "git status --short",
+ },
+ },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.label).toBe("Command run");
+ expect(entries[0]?.detail).toBe("git status --short");
+ });
+
+ it("preserves full tool-call detail text without data truncation", () => {
+ const longCommand =
+ '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"';
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-tool-start",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:05.000Z",
+ payload: {
+ item: { id: "item-tool", type: "tool_call", command: longCommand },
+ },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries[0]?.detail).toBe(longCommand);
+ });
+
+ it("hides generic tool-call rows that have no identifying detail", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-tool-start",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:05.000Z",
+ payload: {
+ item: { id: "item-tool", type: "tool_call" },
+ },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries).toHaveLength(0);
+ });
+
+ it("includes collab tool-call rows with tool name detail", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-collab-start",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:05.000Z",
+ payload: {
+ item: {
+ id: "item-collab",
+ type: "collabAgentToolCall",
+ tool: "spawnAgent",
+ },
+ },
+ }),
+ ],
+ "turn-1",
+ );
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.label).toBe("Tool call");
+ expect(entries[0]?.detail).toBe("spawnAgent");
+ });
+
+ it("can derive work-log entries across all turns when not scoped", () => {
+ const entries = deriveWorkLogEntries(
+ [
+ makeEvent({
+ id: "evt-turn-1-tool",
+ method: "item/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:00:01.000Z",
+ payload: { item: { type: "tool_call", command: "ls -la" } },
+ }),
+ makeEvent({
+ id: "evt-turn-2-tool",
+ method: "item/started",
+ turnId: "turn-2",
+ createdAt: "2026-02-08T10:00:02.000Z",
+ payload: { item: { type: "tool_call", command: "pwd" } },
+ }),
+ ],
+ undefined,
+ );
+
+ expect(entries).toHaveLength(2);
+ expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]);
+ });
+});
+
+describe("evolveSession", () => {
+ it("updates thread id when thread starts", () => {
+ const previous = makeSession();
+ const next = evolveSession(
+ previous,
+ makeEvent({
+ method: "thread/started",
+ createdAt: "2026-02-08T10:01:00.000Z",
+ payload: { thread: { id: "thread-1" } },
+ }),
+ );
+
+ expect(next.threadId).toBe("thread-1");
+ expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z");
+ });
+
+ it("moves to running and records active turn on turn start", () => {
+ const previous = makeSession();
+ const next = evolveSession(
+ previous,
+ makeEvent({
+ method: "turn/started",
+ turnId: "turn-1",
+ createdAt: "2026-02-08T10:02:00.000Z",
+ }),
+ );
+
+ expect(next.status).toBe("running");
+ expect(next.activeTurnId).toBe("turn-1");
+ expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z");
+ });
+
+ it("returns to ready and clears active turn on successful completion", () => {
+ const previous = makeSession({
+ status: "running",
+ activeTurnId: "turn-1",
+ lastError: "older error",
+ });
+ const next = evolveSession(
+ previous,
+ makeEvent({
+ method: "turn/completed",
+ createdAt: "2026-02-08T10:03:00.000Z",
+ payload: { turn: { id: "turn-1", status: "completed" } },
+ }),
+ );
+
+ expect(next.status).toBe("ready");
+ expect(next.activeTurnId).toBeUndefined();
+ expect(next.lastError).toBe("older error");
+ });
+
+ it("marks session as error when turn fails", () => {
+ const previous = makeSession({
+ status: "running",
+ activeTurnId: "turn-1",
+ });
+ const next = evolveSession(
+ previous,
+ makeEvent({
+ method: "turn/completed",
+ createdAt: "2026-02-08T10:03:00.000Z",
+ payload: {
+ turn: { id: "turn-1", status: "failed", error: { message: "boom" } },
+ },
+ }),
+ );
+
+ expect(next.status).toBe("error");
+ expect(next.activeTurnId).toBeUndefined();
+ expect(next.lastError).toBe("boom");
+ });
+
+ it("moves to error on runtime error events", () => {
+ const previous = makeSession();
+ const next = evolveSession(
+ previous,
+ makeEvent({
+ kind: "error",
+ method: "runtime/error",
+ createdAt: "2026-02-08T10:04:00.000Z",
+ message: "runtime failure",
+ }),
+ );
+
+ expect(next.status).toBe("error");
+ expect(next.lastError).toBe("runtime failure");
+ });
+
+ it("closes session on close lifecycle events", () => {
+ const previous = makeSession({
+ status: "running",
+ activeTurnId: "turn-1",
+ });
+ const next = evolveSession(
+ previous,
+ makeEvent({
+ method: "session/closed",
+ createdAt: "2026-02-08T10:05:00.000Z",
+ message: "closed",
+ }),
+ );
+
+ expect(next.status).toBe("closed");
+ expect(next.activeTurnId).toBeUndefined();
+ expect(next.lastError).toBe("closed");
+ });
+});
+
+describe("applyEventToMessages", () => {
+ it("handles start/delta/completed flow for assistant messages", () => {
+ const activeAssistantItemRef = { current: null as string | null };
+ const started = applyEventToMessages(
+ [],
+ makeEvent({
+ method: "item/started",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ payload: {
+ item: {
+ id: "item-1",
+ type: "agentMessage",
+ text: "Hello",
+ },
+ },
+ }),
+ activeAssistantItemRef,
+ );
+
+ expect(started).toEqual([
+ {
+ id: "item-1",
+ role: "assistant",
+ text: "Hello",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ streaming: true,
+ },
+ ]);
+ expect(activeAssistantItemRef.current).toBe("item-1");
+
+ const withDelta = applyEventToMessages(
+ started,
+ makeEvent({
+ method: "item/agentMessage/delta",
+ createdAt: "2026-02-08T10:00:01.000Z",
+ itemId: "item-1",
+ textDelta: " world",
+ }),
+ activeAssistantItemRef,
+ );
+ expect(withDelta[0]?.text).toBe("Hello world");
+ expect(withDelta[0]?.streaming).toBe(true);
+
+ const completed = applyEventToMessages(
+ withDelta,
+ makeEvent({
+ method: "item/completed",
+ createdAt: "2026-02-08T10:00:02.000Z",
+ payload: {
+ item: {
+ id: "item-1",
+ type: "agentMessage",
+ text: "Hello world!",
+ },
+ },
+ }),
+ activeAssistantItemRef,
+ );
+ expect(completed[0]?.text).toBe("Hello world!");
+ expect(completed[0]?.streaming).toBe(false);
+ expect(activeAssistantItemRef.current).toBeNull();
+ });
+
+ it("supports out-of-order delta before started/completed", () => {
+ const activeAssistantItemRef = { current: null as string | null };
+ const withDelta = applyEventToMessages(
+ [],
+ makeEvent({
+ method: "item/agentMessage/delta",
+ createdAt: "2026-02-08T10:00:01.000Z",
+ itemId: "item-2",
+ textDelta: "Partial",
+ }),
+ activeAssistantItemRef,
+ );
+ expect(withDelta[0]?.id).toBe("item-2");
+ expect(withDelta[0]?.text).toBe("Partial");
+ expect(withDelta[0]?.streaming).toBe(true);
+ expect(activeAssistantItemRef.current).toBe("item-2");
+
+ const completed = applyEventToMessages(
+ withDelta,
+ makeEvent({
+ method: "item/completed",
+ createdAt: "2026-02-08T10:00:02.000Z",
+ payload: {
+ item: {
+ id: "item-2",
+ type: "agentMessage",
+ text: "Partial + final",
+ },
+ },
+ }),
+ activeAssistantItemRef,
+ );
+
+ expect(completed[0]?.text).toBe("Partial + final");
+ expect(completed[0]?.streaming).toBe(false);
+ });
+
+ it("clears all streaming flags when turn completes", () => {
+ const previous: ChatMessage[] = [
+ {
+ id: "m-user",
+ role: "user",
+ text: "Hi",
+ createdAt: "2026-02-08T10:00:00.000Z",
+ streaming: false,
+ },
+ {
+ id: "m-assistant",
+ role: "assistant",
+ text: "Typing",
+ createdAt: "2026-02-08T10:00:01.000Z",
+ streaming: true,
+ },
+ ];
+
+ const next = applyEventToMessages(
+ previous,
+ makeEvent({
+ method: "turn/completed",
+ }),
+ { current: null },
+ );
+
+ expect(next.every((entry) => entry.streaming === false)).toBe(true);
+ });
+});
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9f55647070b8564f842977764261d0aa2469dc28
--- /dev/null
+++ b/apps/web/src/session-logic.ts
@@ -0,0 +1,636 @@
+import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts";
+import type { ChatMessage, SessionPhase } from "./types";
+import { createWsNativeApi } from "./wsNativeApi";
+
+export const PROVIDER_OPTIONS: Array<{
+ value: ProviderKind;
+ label: string;
+ available: boolean;
+}> = [
+ { value: "codex", label: "Codex", available: true },
+ { value: "claudeCode", label: "Claude Code (soon)", available: false },
+];
+
+let cachedApi: NativeApi | undefined;
+
+export function readNativeApi(): NativeApi | undefined {
+ if (typeof window === "undefined") return undefined;
+ if (cachedApi) return cachedApi;
+
+ // Prefer Electron preload bridge if available
+ if (window.nativeApi) {
+ cachedApi = window.nativeApi;
+ return cachedApi;
+ }
+
+ // Fall back to WebSocket transport
+ cachedApi = createWsNativeApi();
+ return cachedApi;
+}
+
+export function asObject(value: unknown): Record | undefined {
+ if (!value || typeof value !== "object") return undefined;
+ return value as Record;
+}
+
+export function asString(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+export function formatTimestamp(isoDate: string): string {
+ return new Intl.DateTimeFormat(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ }).format(new Date(isoDate));
+}
+
+export function formatDuration(durationMs: number): string {
+ if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
+ if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
+ if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`;
+ if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`;
+
+ const minutes = Math.floor(durationMs / 60_000);
+ const seconds = Math.round((durationMs % 60_000) / 1_000);
+ if (seconds === 0) return `${minutes}m`;
+ if (seconds === 60) return `${minutes + 1}m`;
+ return `${minutes}m ${seconds}s`;
+}
+
+export function formatElapsed(startIso: string, endIso: string | undefined): string | null {
+ if (!endIso) return null;
+
+ const startedAt = Date.parse(startIso);
+ const endedAt = Date.parse(endIso);
+ if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) {
+ return null;
+ }
+
+ return formatDuration(endedAt - startedAt);
+}
+
+export interface WorkLogEntry {
+ id: string;
+ createdAt: string;
+ label: string;
+ detail?: string;
+ tone: "thinking" | "tool" | "info" | "error";
+}
+
+export type TimelineEntry =
+ | {
+ id: string;
+ kind: "message";
+ createdAt: string;
+ message: ChatMessage;
+ }
+ | {
+ id: string;
+ kind: "work";
+ createdAt: string;
+ entry: WorkLogEntry;
+ };
+
+function normalizeDetail(value: string | undefined): string | undefined {
+ if (!value) return undefined;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
+function normalizeItemType(raw: string | undefined): string {
+ if (!raw) return "item";
+ return raw
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/[._/-]/g, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ .toLowerCase();
+}
+
+function shouldDropItemType(type: string): boolean {
+ if (
+ type.includes("preamble") ||
+ type.includes("reasoning") ||
+ type.includes("thought")
+ ) {
+ return true;
+ }
+
+ return type === "work" || type.startsWith("work ");
+}
+
+function shouldShowItemLifecycle(type: string): boolean {
+ return (
+ type.includes("tool") ||
+ type.includes("command") ||
+ type.includes("file change")
+ );
+}
+
+function shouldDropMethod(method: string): boolean {
+ return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method);
+}
+
+function itemTypeMeta(type: string): {
+ label: string;
+ tone: WorkLogEntry["tone"];
+} {
+ if (type.includes("command")) {
+ return { label: "Command run", tone: "tool" };
+ }
+ if (type.includes("file change")) {
+ return { label: "File change", tone: "tool" };
+ }
+ if (type.includes("tool")) {
+ return { label: "Tool call", tone: "tool" };
+ }
+ return { label: "Work item", tone: "info" };
+}
+
+interface ItemLifecycleCandidate {
+ id: string;
+ itemId?: string;
+ createdAt: string;
+ label: string;
+ detail?: string;
+ tone: WorkLogEntry["tone"];
+ phase: "started" | "completed";
+}
+
+function extractDetail(
+ payload: Record | undefined,
+ item: Record | undefined,
+): string | undefined {
+ const candidates = [
+ asString(item?.command),
+ asString(item?.tool),
+ asString(item?.name),
+ asString(item?.title),
+ asString(item?.summary),
+ asString(item?.text),
+ asString(item?.prompt),
+ asString(payload?.message),
+ asString(payload?.prompt),
+ asString(payload?.command),
+ ];
+
+ for (const candidate of candidates) {
+ const detail = normalizeDetail(candidate);
+ if (detail) return detail;
+ }
+ return undefined;
+}
+
+function lifecycleCandidateFromItemEvent(
+ event: ProviderEvent,
+): ItemLifecycleCandidate | null {
+ const payload = asObject(event.payload);
+ const item = asObject(payload?.item);
+ const normalizedType = normalizeItemType(asString(item?.type));
+ if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) {
+ return null;
+ }
+ if (!shouldShowItemLifecycle(normalizedType)) {
+ return null;
+ }
+
+ const meta = itemTypeMeta(normalizedType);
+ const isStarted = event.method === "item/started";
+ const isCompleted = event.method === "item/completed";
+ if (!isStarted && !isCompleted) {
+ return null;
+ }
+
+ const detail = extractDetail(payload, item);
+ const itemId = event.itemId ?? asString(item?.id);
+ return {
+ id: event.id,
+ ...(itemId ? { itemId } : {}),
+ createdAt: event.createdAt,
+ label: meta.label,
+ ...(detail ? { detail } : {}),
+ tone: meta.tone,
+ phase: isCompleted ? "completed" : "started",
+ };
+}
+
+function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
+ if (event.kind !== "request") return null;
+ if (shouldDropMethod(event.method)) return null;
+
+ if (event.method.includes("commandExecution")) {
+ return {
+ id: event.id,
+ createdAt: event.createdAt,
+ label: "Command approval requested",
+ tone: "tool",
+ };
+ }
+
+ if (event.method.includes("fileChange")) {
+ return {
+ id: event.id,
+ createdAt: event.createdAt,
+ label: "File-change approval requested",
+ tone: "tool",
+ };
+ }
+
+ if (event.method.includes("requestUserInput")) {
+ return {
+ id: event.id,
+ createdAt: event.createdAt,
+ label: "Tool requested user input",
+ tone: "tool",
+ };
+ }
+
+ return {
+ id: event.id,
+ createdAt: event.createdAt,
+ label: `Request: ${event.method}`,
+ tone: "info",
+ };
+}
+
+function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
+ if (event.kind !== "notification") return null;
+ if (shouldDropMethod(event.method)) return null;
+ if (event.method === "item/agentMessage/delta") return null;
+ if (event.method === "turn/started") {
+ return null;
+ }
+ if (event.method === "thread/started") return null;
+
+ if (event.method === "turn/completed") {
+ const payload = asObject(event.payload);
+ const turn = asObject(payload?.turn);
+ const status = asString(turn?.status);
+ if (status !== "failed") {
+ return null;
+ }
+ const turnError = asObject(turn?.error);
+ const turnErrorMessage = asString(turnError?.message);
+ const turnErrorDetail = normalizeDetail(turnErrorMessage);
+
+ return {
+ id: event.id,
+ createdAt: event.createdAt,
+ label: "Turn failed",
+ ...(turnErrorDetail ? { detail: turnErrorDetail } : {}),
+ tone: "error",
+ };
+ }
+
+ if (event.method.startsWith("item/")) return null;
+
+ return null;
+}
+
+function entryFromError(event: ProviderEvent): WorkLogEntry | null {
+ if (event.kind !== "error") return null;
+ if (shouldDropMethod(event.method)) return null;
+ const detail = normalizeDetail(event.message);
+
+ return {
+ id: event.id,
+ createdAt: event.createdAt,
+ label: "Runtime error",
+ ...(detail ? { detail } : {}),
+ tone: "error",
+ };
+}
+
+function eventTurnId(event: ProviderEvent): string | undefined {
+ const payload = asObject(event.payload);
+ const turn = asObject(payload?.turn);
+ return event.turnId ?? asString(turn?.id);
+}
+
+export function deriveWorkLogEntries(
+ events: ProviderEvent[],
+ turnId: string | undefined,
+): WorkLogEntry[] {
+ const ordered = [...events].toReversed();
+ const entries: WorkLogEntry[] = [];
+ const turnStartedAtIso = turnId
+ ? ordered.find((event) => {
+ if (event.method !== "turn/started") return false;
+ return eventTurnId(event) === turnId;
+ })?.createdAt
+ : undefined;
+ const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN;
+
+ const shouldIncludeEvent = (event: ProviderEvent): boolean => {
+ if (!turnId) return true;
+ const scopedTurnId = eventTurnId(event);
+ if (scopedTurnId && scopedTurnId !== turnId) {
+ return false;
+ }
+
+ if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {
+ const eventAt = Date.parse(event.createdAt);
+ if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
+ return false;
+ }
+ }
+
+ return true;
+ };
+
+ const completedLifecycleItemIds = new Set();
+ for (const event of ordered) {
+ if (!shouldIncludeEvent(event)) continue;
+ const candidate = lifecycleCandidateFromItemEvent(event);
+ if (candidate?.phase === "completed" && candidate.itemId) {
+ completedLifecycleItemIds.add(candidate.itemId);
+ }
+ }
+
+ for (const event of ordered) {
+ if (!shouldIncludeEvent(event)) continue;
+
+ const lifecycleCandidate = lifecycleCandidateFromItemEvent(event);
+ if (lifecycleCandidate) {
+ if (
+ lifecycleCandidate.phase === "started" &&
+ lifecycleCandidate.itemId &&
+ completedLifecycleItemIds.has(lifecycleCandidate.itemId)
+ ) {
+ continue;
+ }
+ if (
+ lifecycleCandidate.label === "Tool call" &&
+ !lifecycleCandidate.detail
+ ) {
+ continue;
+ }
+
+ entries.push({
+ id: lifecycleCandidate.id,
+ createdAt: lifecycleCandidate.createdAt,
+ label: lifecycleCandidate.label,
+ ...(lifecycleCandidate.detail
+ ? { detail: lifecycleCandidate.detail }
+ : {}),
+ tone: lifecycleCandidate.tone,
+ });
+ continue;
+ }
+
+ const fromRequest = entryFromRequest(event);
+ if (fromRequest) {
+ entries.push(fromRequest);
+ continue;
+ }
+
+ const fromNotification = entryFromNotification(event);
+ if (fromNotification) {
+ entries.push(fromNotification);
+ continue;
+ }
+
+ const fromError = entryFromError(event);
+ if (fromError) {
+ entries.push(fromError);
+ }
+ }
+
+ return entries;
+}
+
+function toTimestamp(isoDate: string): number {
+ const parsed = Date.parse(isoDate);
+ return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
+}
+
+export function deriveTimelineEntries(
+ messages: ChatMessage[],
+ workEntries: WorkLogEntry[],
+): TimelineEntry[] {
+ const timeline: TimelineEntry[] = [];
+ let messageIndex = 0;
+ let workIndex = 0;
+
+ while (messageIndex < messages.length || workIndex < workEntries.length) {
+ const message = messages[messageIndex];
+ const workEntry = workEntries[workIndex];
+
+ if (!message && workEntry) {
+ timeline.push({
+ id: `work:${workEntry.id}`,
+ kind: "work",
+ createdAt: workEntry.createdAt,
+ entry: workEntry,
+ });
+ workIndex += 1;
+ continue;
+ }
+
+ if (!workEntry && message) {
+ timeline.push({
+ id: `message:${message.id}`,
+ kind: "message",
+ createdAt: message.createdAt,
+ message,
+ });
+ messageIndex += 1;
+ continue;
+ }
+
+ if (!message || !workEntry) {
+ break;
+ }
+
+ const messageAt = toTimestamp(message.createdAt);
+ const workAt = toTimestamp(workEntry.createdAt);
+
+ if (workAt <= messageAt) {
+ timeline.push({
+ id: `work:${workEntry.id}`,
+ kind: "work",
+ createdAt: workEntry.createdAt,
+ entry: workEntry,
+ });
+ workIndex += 1;
+ continue;
+ }
+
+ timeline.push({
+ id: `message:${message.id}`,
+ kind: "message",
+ createdAt: message.createdAt,
+ message,
+ });
+ messageIndex += 1;
+ }
+
+ return timeline;
+}
+
+export function derivePhase(session: ProviderSession | null): SessionPhase {
+ if (!session || session.status === "closed") return "disconnected";
+ if (session.status === "connecting") return "connecting";
+ if (session.status === "running") return "running";
+ return "ready";
+}
+
+export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession {
+ const payload = asObject(event.payload);
+
+ if (event.method === "thread/started") {
+ const thread = asObject(payload?.thread);
+ return {
+ ...previous,
+ threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId,
+ updatedAt: event.createdAt,
+ };
+ }
+
+ if (event.method === "turn/started") {
+ const turn = asObject(payload?.turn);
+ return {
+ ...previous,
+ status: "running",
+ activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId,
+ updatedAt: event.createdAt,
+ };
+ }
+
+ if (event.method === "turn/completed") {
+ const turn = asObject(payload?.turn);
+ const status = asString(turn?.status);
+ const turnError = asObject(turn?.error);
+ return {
+ ...previous,
+ status: status === "failed" ? "error" : "ready",
+ activeTurnId: undefined,
+ lastError: asString(turnError?.message) ?? previous.lastError,
+ updatedAt: event.createdAt,
+ };
+ }
+
+ if (event.kind === "error") {
+ return {
+ ...previous,
+ status: "error",
+ lastError: event.message ?? previous.lastError,
+ updatedAt: event.createdAt,
+ };
+ }
+
+ if (event.method === "session/closed" || event.method === "session/exited") {
+ return {
+ ...previous,
+ status: "closed",
+ activeTurnId: undefined,
+ lastError: event.message ?? previous.lastError,
+ updatedAt: event.createdAt,
+ };
+ }
+
+ return { ...previous, updatedAt: event.createdAt };
+}
+
+export function applyEventToMessages(
+ previous: ChatMessage[],
+ event: ProviderEvent,
+ activeAssistantItemRef: { current: string | null },
+): ChatMessage[] {
+ const payload = asObject(event.payload);
+
+ if (event.method === "item/started") {
+ const item = asObject(payload?.item);
+ if (asString(item?.type) !== "agentMessage") return previous;
+ const itemId = asString(item?.id);
+ if (!itemId) return previous;
+
+ activeAssistantItemRef.current = itemId;
+ const seedText = asString(item?.text) ?? "";
+ const filtered = previous.filter((entry) => entry.id !== itemId);
+ return [
+ ...filtered,
+ {
+ id: itemId,
+ role: "assistant",
+ text: seedText,
+ createdAt: event.createdAt,
+ streaming: true,
+ },
+ ];
+ }
+
+ if (event.method === "item/agentMessage/delta") {
+ const itemId = event.itemId ?? asString(payload?.itemId);
+ const delta = event.textDelta ?? asString(payload?.delta) ?? "";
+ if (!itemId || !delta) return previous;
+
+ const existingIndex = previous.findIndex((entry) => entry.id === itemId);
+ if (existingIndex === -1) {
+ activeAssistantItemRef.current = itemId;
+ return [
+ ...previous,
+ {
+ id: itemId,
+ role: "assistant",
+ text: delta,
+ createdAt: event.createdAt,
+ streaming: true,
+ },
+ ];
+ }
+
+ const updated = [...previous];
+ const existing = updated[existingIndex];
+ if (!existing) return previous;
+ updated[existingIndex] = {
+ ...existing,
+ text: `${existing.text}${delta}`,
+ streaming: true,
+ };
+ return updated;
+ }
+
+ if (event.method === "item/completed") {
+ const item = asObject(payload?.item);
+ if (asString(item?.type) !== "agentMessage") return previous;
+ const itemId = asString(item?.id);
+ if (!itemId) return previous;
+
+ const fullText = asString(item?.text);
+ const existingIndex = previous.findIndex((entry) => entry.id === itemId);
+ if (existingIndex === -1) {
+ return [
+ ...previous,
+ {
+ id: itemId,
+ role: "assistant",
+ text: fullText ?? "",
+ createdAt: event.createdAt,
+ streaming: false,
+ },
+ ];
+ }
+
+ const updated = [...previous];
+ const existing = updated[existingIndex];
+ if (!existing) return previous;
+ updated[existingIndex] = {
+ ...existing,
+ text: fullText ?? existing.text,
+ streaming: false,
+ };
+
+ if (activeAssistantItemRef.current === itemId) {
+ activeAssistantItemRef.current = null;
+ }
+ return updated;
+ }
+
+ if (event.method === "turn/completed") {
+ return previous.map((entry) => ({ ...entry, streaming: false }));
+ }
+
+ return previous;
+}
diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c10bfe8e7951423616b87004d28cd74d6a06fa74
--- /dev/null
+++ b/apps/web/src/store.test.ts
@@ -0,0 +1,188 @@
+import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
+import { describe, expect, it } from "vitest";
+
+import { type AppState, reducer } from "./store";
+import type { Thread } from "./types";
+
+function makeSession(overrides: Partial = {}): ProviderSession {
+ return {
+ sessionId: "sess-1",
+ provider: "codex",
+ status: "ready",
+ createdAt: "2026-02-09T00:00:00.000Z",
+ updatedAt: "2026-02-09T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function makeEvent(overrides: Partial = {}): ProviderEvent {
+ return {
+ id: "evt-1",
+ kind: "notification",
+ provider: "codex",
+ sessionId: "sess-1",
+ createdAt: "2026-02-09T00:00:01.000Z",
+ method: "thread/started",
+ ...overrides,
+ };
+}
+
+function makeThread(overrides: Partial = {}): Thread {
+ return {
+ id: "thread-local-1",
+ codexThreadId: null,
+ projectId: "project-1",
+ title: "Thread",
+ model: "gpt-5.3-codex",
+ session: makeSession(),
+ messages: [],
+ events: [],
+ error: null,
+ createdAt: "2026-02-09T00:00:00.000Z",
+ ...overrides,
+ };
+}
+
+function makeState(thread: Thread): AppState {
+ return {
+ projects: [
+ {
+ id: "project-1",
+ name: "Project",
+ cwd: "/tmp/project",
+ model: "gpt-5.3-codex",
+ expanded: true,
+ },
+ ],
+ threads: [thread],
+ activeThreadId: thread.id,
+ runtimeMode: "full-access",
+ diffOpen: false,
+ };
+}
+
+describe("store reducer thread continuity", () => {
+ it("stores codexThreadId from UPDATE_SESSION", () => {
+ const state = makeState(
+ makeThread({
+ session: null,
+ }),
+ );
+ const next = reducer(state, {
+ type: "UPDATE_SESSION",
+ threadId: "thread-local-1",
+ session: makeSession({ threadId: "thr_123" }),
+ });
+
+ expect(next.threads[0]?.codexThreadId).toBe("thr_123");
+ });
+
+ it("backfills codexThreadId from routed provider events", () => {
+ const state = makeState(makeThread({ codexThreadId: null }));
+ const next = reducer(state, {
+ type: "APPLY_EVENT",
+ event: makeEvent({
+ method: "thread/started",
+ payload: { thread: { id: "thr_backfilled" } },
+ }),
+ activeAssistantItemRef: { current: null },
+ });
+
+ expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled");
+ });
+
+ it("ignores events from a foreign thread within the same session", () => {
+ const state = makeState(makeThread({ codexThreadId: "thr_expected" }));
+ const next = reducer(state, {
+ type: "APPLY_EVENT",
+ event: makeEvent({
+ method: "turn/started",
+ threadId: "thr_unexpected",
+ payload: { turn: { id: "turn-1" } },
+ }),
+ activeAssistantItemRef: { current: null },
+ });
+
+ expect(next).toBe(state);
+ });
+
+ it("rebases thread identity on thread/started during connect", () => {
+ const state = makeState(
+ makeThread({
+ codexThreadId: "thr_old",
+ session: makeSession({
+ status: "connecting",
+ threadId: "thr_old",
+ }),
+ }),
+ );
+ const next = reducer(state, {
+ type: "APPLY_EVENT",
+ event: makeEvent({
+ method: "thread/started",
+ threadId: "thr_new",
+ payload: { thread: { id: "thr_new" } },
+ }),
+ activeAssistantItemRef: { current: null },
+ });
+
+ expect(next.threads[0]?.codexThreadId).toBe("thr_new");
+ expect(next.threads[0]?.session?.threadId).toBe("thr_new");
+ });
+
+ it("reconciles project ids by cwd when syncing backend projects", () => {
+ const state: AppState = {
+ projects: [
+ {
+ id: "project-old-a",
+ name: "A",
+ cwd: "/tmp/a",
+ model: "gpt-5.3-codex",
+ expanded: false,
+ },
+ {
+ id: "project-old-b",
+ name: "B",
+ cwd: "/tmp/b",
+ model: "gpt-5.3-codex",
+ expanded: true,
+ },
+ ],
+ threads: [
+ makeThread({
+ id: "thread-a",
+ projectId: "project-old-a",
+ }),
+ makeThread({
+ id: "thread-b",
+ projectId: "project-old-b",
+ }),
+ ],
+ activeThreadId: "thread-b",
+ runtimeMode: "full-access",
+ diffOpen: false,
+ };
+
+ const next = reducer(state, {
+ type: "SYNC_PROJECTS",
+ projects: [
+ {
+ id: "project-new-a",
+ name: "A",
+ cwd: "/tmp/a",
+ model: "gpt-5.3-codex",
+ expanded: true,
+ },
+ ],
+ });
+
+ expect(next.projects).toHaveLength(1);
+ expect(next.projects[0]?.id).toBe("project-new-a");
+ // Preserve existing project UI preferences by cwd
+ expect(next.projects[0]?.expanded).toBe(false);
+ expect(next.threads).toHaveLength(1);
+ expect(next.threads[0]?.id).toBe("thread-a");
+ expect(next.threads[0]?.projectId).toBe("project-new-a");
+ expect(next.activeThreadId).toBe("thread-a");
+ });
+});
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f9b075b89e015391082b5c7e8139591613f8aa54
--- /dev/null
+++ b/apps/web/src/store.ts
@@ -0,0 +1,402 @@
+import {
+ type Dispatch,
+ type ReactNode,
+ createContext,
+ createElement,
+ useContext,
+ useEffect,
+ useReducer,
+} from "react";
+
+import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
+import { resolveModelSlug } from "./model-logic";
+import { hydratePersistedState, toPersistedState } from "./persistenceSchema";
+import { applyEventToMessages, asObject, asString, evolveSession } from "./session-logic";
+import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
+
+// ── Actions ──────────────────────────────────────────────────────────
+
+type Action =
+ | { type: "ADD_PROJECT"; project: Project }
+ | { type: "SYNC_PROJECTS"; projects: Project[] }
+ | { type: "TOGGLE_PROJECT"; projectId: string }
+ | { type: "ADD_THREAD"; thread: Thread }
+ | { type: "SET_ACTIVE_THREAD"; threadId: string }
+ | { type: "TOGGLE_DIFF" }
+ | {
+ type: "APPLY_EVENT";
+ event: ProviderEvent;
+ activeAssistantItemRef: { current: string | null };
+ }
+ | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
+ | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }
+ | { type: "SET_ERROR"; threadId: string; error: string | null }
+ | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
+ | { type: "SET_THREAD_MODEL"; threadId: string; model: string }
+ | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
+
+// ── State ────────────────────────────────────────────────────────────
+
+export interface AppState {
+ projects: Project[];
+ threads: Thread[];
+ activeThreadId: string | null;
+ runtimeMode: RuntimeMode;
+ diffOpen: boolean;
+}
+
+const PERSISTED_STATE_KEY = "t3code:renderer-state:v4";
+const LEGACY_PERSISTED_STATE_KEYS = [
+ "t3code:renderer-state:v3",
+ "codething:renderer-state:v4",
+ "codething:renderer-state:v3",
+ "codething:renderer-state:v2",
+ "codething:renderer-state:v1",
+] as const;
+
+const initialState: AppState = {
+ projects: [],
+ threads: [],
+ activeThreadId: null,
+ runtimeMode: DEFAULT_RUNTIME_MODE,
+ diffOpen: false,
+};
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+function readPersistedState(): AppState {
+ if (typeof window === "undefined") return initialState;
+
+ try {
+ const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
+ const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;
+ const rawLegacyV3 = window.localStorage.getItem(legacyV3Key);
+ const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);
+ const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);
+ const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1;
+ if (!raw) return initialState;
+ const hydrated = hydratePersistedState(
+ raw,
+ !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1),
+ );
+ if (!hydrated) return initialState;
+
+ return { ...hydrated, diffOpen: false };
+ } catch {
+ return initialState;
+ }
+}
+
+function persistState(state: AppState): void {
+ if (typeof window === "undefined") return;
+
+ try {
+ window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state)));
+ for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) {
+ window.localStorage.removeItem(legacyKey);
+ }
+ } catch {
+ // Ignore quota/storage errors to avoid breaking chat UX.
+ }
+}
+
+function updateThread(
+ threads: Thread[],
+ threadId: string,
+ updater: (t: Thread) => Thread,
+): Thread[] {
+ return threads.map((t) => (t.id === threadId ? updater(t) : t));
+}
+
+function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined {
+ return threads.find((t) => t.session?.sessionId === sessionId);
+}
+
+function getEventTurnId(event: ProviderEvent): string | undefined {
+ if (event.turnId) return event.turnId;
+ const payload = asObject(event.payload);
+ const turn = asObject(payload?.turn);
+ return asString(turn?.id);
+}
+
+function getEventThreadId(event: ProviderEvent): string | undefined {
+ if (event.threadId) return event.threadId;
+ const payload = asObject(event.payload);
+ const payloadThread = asObject(payload?.thread);
+ const payloadMessage = asObject(payload?.msg);
+ return (
+ asString(payload?.threadId) ??
+ asString(payloadThread?.id) ??
+ asString(payload?.conversationId) ??
+ asString(payload?.thread_id) ??
+ asString(payloadMessage?.thread_id)
+ );
+}
+
+function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean {
+ const eventThreadId = getEventThreadId(event);
+ if (!eventThreadId) {
+ return false;
+ }
+
+ const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId;
+ if (!expectedThreadId || eventThreadId === expectedThreadId) {
+ return false;
+ }
+
+ // During connect, accept a thread/started notification as an identity rebind.
+ if (event.method === "thread/started" && thread.session?.status === "connecting") {
+ return false;
+ }
+
+ return true;
+}
+
+function durationMs(startIso: string, endIso: string): number | undefined {
+ const start = Date.parse(startIso);
+ const end = Date.parse(endIso);
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
+ return undefined;
+ }
+
+ return end - start;
+}
+
+function updateTurnFields(thread: Thread, event: ProviderEvent): Partial {
+ if (event.method === "turn/started") {
+ return {
+ latestTurnId: getEventTurnId(event) ?? thread.latestTurnId,
+ latestTurnStartedAt: event.createdAt,
+ latestTurnCompletedAt: undefined,
+ latestTurnDurationMs: undefined,
+ };
+ }
+
+ if (event.method === "turn/completed") {
+ const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId;
+ const startedAt =
+ completedTurnId && completedTurnId === thread.latestTurnId
+ ? thread.latestTurnStartedAt
+ : undefined;
+ const elapsed =
+ startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined;
+
+ return {
+ latestTurnId: completedTurnId ?? thread.latestTurnId,
+ latestTurnCompletedAt: event.createdAt,
+ latestTurnDurationMs: elapsed,
+ };
+ }
+
+ return {};
+}
+
+// ── Reducer ──────────────────────────────────────────────────────────
+
+export function reducer(state: AppState, action: Action): AppState {
+ switch (action.type) {
+ case "ADD_PROJECT":
+ if (state.projects.some((project) => project.cwd === action.project.cwd)) {
+ return state;
+ }
+ return {
+ ...state,
+ projects: [
+ ...state.projects,
+ {
+ ...action.project,
+ model: resolveModelSlug(action.project.model),
+ },
+ ],
+ };
+
+ case "SYNC_PROJECTS": {
+ const previousByCwd = new Map(
+ state.projects.map((project) => [project.cwd, project] as const),
+ );
+ const nextProjects = action.projects.map((project) => {
+ const previous = previousByCwd.get(project.cwd);
+ return {
+ ...project,
+ model: resolveModelSlug(previous?.model ?? project.model),
+ expanded: previous?.expanded ?? project.expanded,
+ };
+ });
+ const previousProjectById = new Map(
+ state.projects.map((project) => [project.id, project] as const),
+ );
+ const nextProjectIdByCwd = new Map(
+ nextProjects.map((project) => [project.cwd, project.id] as const),
+ );
+ const nextThreads = state.threads
+ .map((thread) => {
+ const previousProject = previousProjectById.get(thread.projectId);
+ if (!previousProject) return null;
+ const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd);
+ if (!mappedProjectId) return null;
+ return {
+ ...thread,
+ projectId: mappedProjectId,
+ };
+ })
+ .filter((thread): thread is Thread => thread !== null);
+ const activeThreadId = nextThreads.some((thread) => thread.id === state.activeThreadId)
+ ? state.activeThreadId
+ : (nextThreads[0]?.id ?? null);
+
+ return {
+ ...state,
+ projects: nextProjects,
+ threads: nextThreads,
+ activeThreadId,
+ };
+ }
+
+ case "TOGGLE_PROJECT":
+ return {
+ ...state,
+ projects: state.projects.map((p) =>
+ p.id === action.projectId ? { ...p, expanded: !p.expanded } : p,
+ ),
+ };
+
+ case "ADD_THREAD":
+ return {
+ ...state,
+ threads: [
+ ...state.threads,
+ {
+ ...action.thread,
+ model: resolveModelSlug(action.thread.model),
+ },
+ ],
+ activeThreadId: action.thread.id,
+ };
+
+ case "SET_ACTIVE_THREAD":
+ return { ...state, activeThreadId: action.threadId };
+
+ case "TOGGLE_DIFF":
+ return { ...state, diffOpen: !state.diffOpen };
+
+ case "APPLY_EVENT": {
+ const { event, activeAssistantItemRef } = action;
+ const target = findThreadBySessionId(state.threads, event.sessionId);
+ if (!target) return state;
+ if (shouldIgnoreForeignThreadEvent(target, event)) return state;
+
+ return {
+ ...state,
+ threads: updateThread(state.threads, target.id, (t) => ({
+ ...t,
+ ...(() => {
+ const eventThreadId = getEventThreadId(event);
+ const shouldRebindIdentity =
+ event.method === "thread/started" && t.session?.status === "connecting";
+ return {
+ codexThreadId: shouldRebindIdentity
+ ? (eventThreadId ?? t.codexThreadId)
+ : (t.codexThreadId ?? eventThreadId ?? null),
+ error: event.kind === "error" && event.message ? event.message : t.error,
+ };
+ })(),
+ session: t.session ? evolveSession(t.session, event) : t.session,
+ messages: applyEventToMessages(t.messages, event, activeAssistantItemRef),
+ events: [event, ...t.events],
+ ...updateTurnFields(t, event),
+ })),
+ };
+ }
+
+ case "UPDATE_SESSION":
+ return {
+ ...state,
+ threads: updateThread(state.threads, action.threadId, (t) => ({
+ ...t,
+ session: action.session,
+ codexThreadId: action.session.threadId ?? t.codexThreadId,
+ events: [],
+ error: null,
+ latestTurnId: undefined,
+ latestTurnStartedAt: undefined,
+ latestTurnCompletedAt: undefined,
+ latestTurnDurationMs: undefined,
+ })),
+ };
+
+ case "PUSH_USER_MESSAGE":
+ return {
+ ...state,
+ threads: updateThread(state.threads, action.threadId, (t) => ({
+ ...t,
+ messages: [
+ ...t.messages,
+ {
+ id: action.id,
+ role: "user" as const,
+ text: action.text,
+ createdAt: new Date().toISOString(),
+ streaming: false,
+ },
+ ],
+ })),
+ };
+
+ case "SET_ERROR":
+ return {
+ ...state,
+ threads: updateThread(state.threads, action.threadId, (t) => ({
+ ...t,
+ error: action.error,
+ })),
+ };
+
+ case "SET_THREAD_TITLE":
+ return {
+ ...state,
+ threads: updateThread(state.threads, action.threadId, (t) => ({
+ ...t,
+ title: action.title,
+ })),
+ };
+
+ case "SET_THREAD_MODEL":
+ return {
+ ...state,
+ threads: updateThread(state.threads, action.threadId, (t) => ({
+ ...t,
+ model: resolveModelSlug(action.model),
+ })),
+ };
+
+ case "SET_RUNTIME_MODE":
+ return {
+ ...state,
+ runtimeMode: action.mode,
+ };
+
+ default:
+ return state;
+ }
+}
+
+// ── Context ──────────────────────────────────────────────────────────
+
+const StoreContext = createContext<{
+ state: AppState;
+ dispatch: Dispatch;
+}>({ state: initialState, dispatch: () => {} });
+
+export function StoreProvider({ children }: { children: ReactNode }) {
+ const [state, dispatch] = useReducer(reducer, undefined, readPersistedState);
+
+ useEffect(() => {
+ persistState(state);
+ }, [state]);
+
+ return createElement(StoreContext.Provider, { value: { state, dispatch } }, children);
+}
+
+export function useStore() {
+ return useContext(StoreContext);
+}
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ebe3731d121be92b304c7e0e4c050b604802e482
--- /dev/null
+++ b/apps/web/src/types.ts
@@ -0,0 +1,38 @@
+import type { ProviderEvent, ProviderSession } from "@t3tools/contracts";
+
+export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
+export type RuntimeMode = "approval-required" | "full-access";
+export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
+
+export interface ChatMessage {
+ id: string;
+ role: "user" | "assistant";
+ text: string;
+ createdAt: string;
+ streaming: boolean;
+}
+
+export interface Project {
+ id: string;
+ name: string;
+ cwd: string;
+ model: string;
+ expanded: boolean;
+}
+
+export interface Thread {
+ id: string;
+ codexThreadId: string | null;
+ projectId: string;
+ title: string;
+ model: string;
+ session: ProviderSession | null;
+ messages: ChatMessage[];
+ events: ProviderEvent[];
+ error: string | null;
+ createdAt: string;
+ latestTurnId?: string | undefined;
+ latestTurnStartedAt?: string | undefined;
+ latestTurnCompletedAt?: string | undefined;
+ latestTurnDurationMs?: number | undefined;
+}
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0d83bb7e8c8421139cfeaffd58bbdc30e71f93d1
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1,15 @@
+///
+
+import type { NativeApi } from "@t3tools/contracts";
+
+interface DesktopBridge {
+ getWsUrl: () => string | null;
+ pickFolder: () => Promise;
+}
+
+declare global {
+ interface Window {
+ nativeApi?: NativeApi;
+ desktopBridge?: DesktopBridge;
+ }
+}
diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1ed5d5d2a1f6c47712e22ef4e4f8f22083232405
--- /dev/null
+++ b/apps/web/src/wsNativeApi.ts
@@ -0,0 +1,102 @@
+import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts";
+
+import { WsTransport } from "./wsTransport";
+
+let instance: { api: NativeApi; transport: WsTransport } | null = null;
+const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>();
+let lastWelcome: WsWelcomePayload | null = null;
+
+/**
+ * Subscribe to the server welcome message. If a welcome was already received
+ * before this call, the listener fires synchronously with the cached payload.
+ * This avoids the race between WebSocket connect and React effect registration.
+ */
+export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): () => void {
+ welcomeListeners.add(listener);
+
+ // Replay cached welcome for late subscribers
+ if (lastWelcome) {
+ try {
+ listener(lastWelcome);
+ } catch {
+ // Swallow listener errors
+ }
+ }
+
+ return () => {
+ welcomeListeners.delete(listener);
+ };
+}
+
+export function createWsNativeApi(): NativeApi {
+ if (instance) return instance.api;
+
+ const transport = new WsTransport();
+
+ // Listen for server welcome and forward to registered listeners.
+ // Also cache it so late subscribers (React effects) get it immediately.
+ transport.subscribe(WS_CHANNELS.serverWelcome, (data) => {
+ const payload = data as WsWelcomePayload;
+ lastWelcome = payload;
+ for (const listener of welcomeListeners) {
+ try {
+ listener(payload);
+ } catch {
+ // Swallow listener errors
+ }
+ }
+ });
+
+ const api: NativeApi = {
+ todos: {
+ list: async () => [],
+ add: async () => [],
+ toggle: async () => [],
+ remove: async () => [],
+ },
+ dialogs: {
+ pickFolder: async () => {
+ if (!window.desktopBridge) return null;
+ return window.desktopBridge.pickFolder();
+ },
+ },
+ terminal: {
+ run: async () => ({
+ stdout: "",
+ stderr: "Terminal not available in web mode",
+ code: 1,
+ signal: null,
+ timedOut: false,
+ }),
+ },
+ agent: {
+ spawn: async () => "",
+ kill: async () => {},
+ write: async () => {},
+ onOutput: () => () => {},
+ onExit: () => () => {},
+ },
+ providers: {
+ startSession: (input) => transport.request(WS_METHODS.providersStartSession, input),
+ sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input),
+ interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input),
+ respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input),
+ stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input),
+ listSessions: () => transport.request(WS_METHODS.providersListSessions),
+ onEvent: (callback) =>
+ transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void),
+ },
+ projects: {
+ list: () => transport.request(WS_METHODS.projectsList),
+ add: (input) => transport.request(WS_METHODS.projectsAdd, input),
+ remove: (input) => transport.request(WS_METHODS.projectsRemove, input),
+ },
+ shell: {
+ openInEditor: (cwd, editor) =>
+ transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }),
+ },
+ };
+
+ instance = { api, transport };
+ return api;
+}
diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts
new file mode 100644
index 0000000000000000000000000000000000000000..20a40f8f05e7aec884ef53c4da1614ae390b69f4
--- /dev/null
+++ b/apps/web/src/wsTransport.ts
@@ -0,0 +1,197 @@
+import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts";
+
+type PushListener = (data: unknown) => void;
+
+interface PendingRequest {
+ resolve: (result: unknown) => void;
+ reject: (error: Error) => void;
+ timeout: ReturnType;
+}
+
+const REQUEST_TIMEOUT_MS = 60_000;
+const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
+
+export class WsTransport {
+ private ws: WebSocket | null = null;
+ private nextId = 1;
+ private readonly pending = new Map();
+ private readonly listeners = new Map>();
+ private reconnectAttempt = 0;
+ private reconnectTimer: ReturnType | null = null;
+ private disposed = false;
+ private readonly url: string;
+
+ constructor(url?: string) {
+ const bridgeUrl = window.desktopBridge?.getWsUrl();
+ // In dev mode, VITE_WS_URL points to the server's WebSocket endpoint.
+ // In production, the page is served by the WS server on the same host:port.
+ const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
+ this.url =
+ url ??
+ (bridgeUrl && bridgeUrl.length > 0
+ ? bridgeUrl
+ : envUrl && envUrl.length > 0
+ ? envUrl
+ : `ws://${window.location.hostname}:${window.location.port}`);
+ this.connect();
+ }
+
+ async request(method: string, params?: unknown): Promise {
+ const id = String(this.nextId++);
+ const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) };
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ this.pending.delete(id);
+ reject(new Error(`Request timed out: ${method}`));
+ }, REQUEST_TIMEOUT_MS);
+
+ this.pending.set(id, {
+ resolve: resolve as (result: unknown) => void,
+ reject,
+ timeout,
+ });
+
+ this.send(message);
+ });
+ }
+
+ subscribe(channel: string, listener: PushListener): () => void {
+ let channelListeners = this.listeners.get(channel);
+ if (!channelListeners) {
+ channelListeners = new Set();
+ this.listeners.set(channel, channelListeners);
+ }
+ channelListeners.add(listener);
+
+ return () => {
+ channelListeners!.delete(listener);
+ if (channelListeners!.size === 0) {
+ this.listeners.delete(channel);
+ }
+ };
+ }
+
+ dispose() {
+ this.disposed = true;
+ if (this.reconnectTimer !== null) {
+ clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = null;
+ }
+ for (const pending of this.pending.values()) {
+ clearTimeout(pending.timeout);
+ pending.reject(new Error("Transport disposed"));
+ }
+ this.pending.clear();
+ this.ws?.close();
+ this.ws = null;
+ }
+
+ private connect() {
+ if (this.disposed) return;
+
+ const ws = new WebSocket(this.url);
+
+ ws.addEventListener("open", () => {
+ this.ws = ws;
+ this.reconnectAttempt = 0;
+ });
+
+ ws.addEventListener("message", (event) => {
+ this.handleMessage(event.data);
+ });
+
+ ws.addEventListener("close", () => {
+ this.ws = null;
+ this.scheduleReconnect();
+ });
+
+ ws.addEventListener("error", () => {
+ // close event will fire after error
+ });
+ }
+
+ private handleMessage(raw: unknown) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(String(raw));
+ } catch {
+ return;
+ }
+
+ if (!parsed || typeof parsed !== "object") return;
+
+ const message = parsed as Record;
+
+ // Push event
+ if (message.type === "push") {
+ const push = message as unknown as WsPush;
+ const channelListeners = this.listeners.get(push.channel);
+ if (channelListeners) {
+ for (const listener of channelListeners) {
+ try {
+ listener(push.data);
+ } catch {
+ // Swallow listener errors
+ }
+ }
+ }
+ return;
+ }
+
+ // Response to a request
+ if (typeof message.id === "string") {
+ const response = message as unknown as WsResponse;
+ const pending = this.pending.get(response.id);
+ if (!pending) return;
+
+ clearTimeout(pending.timeout);
+ this.pending.delete(response.id);
+
+ if (response.error) {
+ pending.reject(new Error(response.error.message));
+ } else {
+ pending.resolve(response.result);
+ }
+ }
+ }
+
+ private send(message: WsRequest) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(message));
+ return;
+ }
+
+ // If not connected, wait for connection
+ const waitForOpen = () => {
+ const check = setInterval(() => {
+ if (this.disposed) {
+ clearInterval(check);
+ return;
+ }
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ clearInterval(check);
+ this.ws.send(JSON.stringify(message));
+ }
+ }, 50);
+
+ // Give up after timeout (the pending request will time out on its own)
+ setTimeout(() => clearInterval(check), REQUEST_TIMEOUT_MS);
+ };
+ waitForOpen();
+ }
+
+ private scheduleReconnect() {
+ if (this.disposed) return;
+
+ const delay =
+ RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] ??
+ RECONNECT_DELAYS_MS[0]!;
+
+ this.reconnectAttempt++;
+ this.reconnectTimer = setTimeout(() => {
+ this.reconnectTimer = null;
+ this.connect();
+ }, delay);
+ }
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..86307ececcae55ed61c43377c9935faefe54e810
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "jsx": "react-jsx",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b46780ec59fefc24642d2e3765f8de857136c14f
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,28 @@
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [
+ react({
+ babel: {
+ plugins: [["babel-plugin-react-compiler", { target: "19" }]],
+ },
+ }),
+ tailwindcss(),
+ ],
+ define: {
+ // In dev mode, tell the web app where the WebSocket server lives
+ "import.meta.env.VITE_WS_URL": JSON.stringify(
+ process.env.VITE_WS_URL ?? "",
+ ),
+ },
+ server: {
+ port: Number(process.env.PORT ?? 5173),
+ strictPort: true,
+ },
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ },
+});
diff --git a/bun.lock b/bun.lock
index 481602d8e8402d3696ff3d3990d6f77cd33999bd..673257984661685dc1a4238f3d6e8e410b6d9a5c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -28,8 +28,28 @@
"wait-on": "^8.0.2",
},
},
- "apps/renderer": {
- "name": "@t3tools/renderer",
+ "apps/server": {
+ "name": "t3",
+ "version": "0.1.0",
+ "bin": {
+ "t3": "./dist/index.js",
+ },
+ "dependencies": {
+ "open": "^10.1.0",
+ "ws": "^8.18.0",
+ },
+ "devDependencies": {
+ "@t3tools/contracts": "workspace:*",
+ "@t3tools/web": "workspace:*",
+ "@types/node": "^22.10.2",
+ "@types/ws": "^8.5.13",
+ "tsup": "^8.3.5",
+ "tsx": "^4.19.0",
+ "typescript": "^5.7.3",
+ },
+ },
+ "apps/web": {
+ "name": "@t3tools/web",
"version": "0.0.0",
"dependencies": {
"@t3tools/contracts": "workspace:*",
@@ -53,26 +73,6 @@
"vite": "^6.0.5",
},
},
- "apps/server": {
- "name": "t3",
- "version": "0.1.0",
- "bin": {
- "t3": "./dist/index.js",
- },
- "dependencies": {
- "open": "^10.1.0",
- "ws": "^8.18.0",
- },
- "devDependencies": {
- "@t3tools/contracts": "workspace:*",
- "@t3tools/renderer": "workspace:*",
- "@types/node": "^22.10.2",
- "@types/ws": "^8.5.13",
- "tsup": "^8.3.5",
- "tsx": "^4.19.0",
- "typescript": "^5.7.3",
- },
- },
"packages/contracts": {
"name": "@t3tools/contracts",
"version": "0.0.0",
@@ -294,7 +294,7 @@
"@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"],
- "@t3tools/renderer": ["@t3tools/renderer@workspace:apps/renderer"],
+ "@t3tools/web": ["@t3tools/web@workspace:apps/web"],
"@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=="],
diff --git a/package.json b/package.json
index a3429a0284d816ba9ed968ccd1e46c96d8a81c1d..27a799018f55d5073da6794ed065411ab591e4dd 100644
--- a/package.json
+++ b/package.json
@@ -6,14 +6,14 @@
"packages/*"
],
"scripts": {
- "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",
+ "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",
"dev:server": "bun run build:contracts && bun run --cwd apps/server dev",
- "dev:web": "VITE_WS_URL=ws://localhost:3773 bun run --cwd apps/renderer dev",
+ "dev:web": "VITE_WS_URL=ws://localhost:3773 bun run --cwd apps/web dev",
"dev:desktop": "bun run --cwd apps/desktop dev",
"start": "node apps/server/dist/index.js",
"start:desktop": "bun run --cwd apps/desktop start",
"build": "turbo run build",
- "build:desktop": "bun run build:contracts && bun run --cwd apps/renderer build && bun run --cwd apps/server build && bun run --cwd apps/desktop build",
+ "build:desktop": "bun run build:contracts && bun run --cwd apps/web build && bun run --cwd apps/server build && bun run --cwd apps/desktop build",
"typecheck": "turbo run typecheck",
"lint": "oxlint --report-unused-disable-directives",
"test": "turbo run test",