authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 14:26:24-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 14:26:24-08:00
log807e3d18dec858c9694a595deaea4f9ac588db5f
treec926654e81418cae6b15529bbce4d882ce031d01
parent198cc58377fd87ff39b8a37464dd8cb1a77beb0b

Add react-query, refactor BranchToolbar async state, and fix stale deps

- Add @tanstack/react-query and wire up QueryClientProvider in App.tsx - Refactor BranchToolbar to use useQuery for branch listing and useMutation for checkout, create branch, and git init, replacing manual useState/useCallback/useEffect async patterns - Fix branch sync effect and ChatView envMode effect to use stable primitive deps instead of full object references, preventing unnecessary re-runs on unrelated store dispatches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

26 files changed, 235 insertions(+), 433 deletions(-)

AGENTS.md+7
......@@ -1,11 +1,13 @@
11# CLAUDE.md
22
33## Project Snapshot
4
45T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon).
56
67This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
78
89## Core Priorities
10
9111. Performance first.
10122. Reliability first.
11133. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).
......@@ -13,23 +15,28 @@ This repository is a VERY EARLY WIP. Proposing sweeping changes that improve lon
1315If a tradeoff is required, choose correctness and robustness over short-term convenience.
1416
1517## Package Roles
18
1619- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
1720- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
1821- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.
1922
2023## Codex App Server (Important)
24
2125T3 Code is currently Codex-first. The server starts `codex app-server` (JSON-RPC over stdio) per provider session, then streams structured events to the browser through WebSocket push messages.
2226
2327How we use it in this codebase:
28
2429- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.
2530- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.
2631- WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`.
2732- Web app consumes provider event streams via WebSocket push on channel `providers.event`.
2833
2934Docs:
35
3036- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server
3137
3238## Reference Repos
39
3340- Open-source Codex repo: https://github.com/openai/codex
3441- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor
3542
apps/desktop/turbo.jsonc+6-6
......@@ -4,21 +4,21 @@
44 "tasks": {
55 "build": {
66 "dependsOn": ["^build"],
7 "outputs": ["dist-electron/**"]
7 "outputs": ["dist-electron/**"],
88 },
99 "dev": {
1010 "dependsOn": ["t3#build"],
11 "persistent": true
11 "persistent": true,
1212 },
1313 "start": {
1414 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
1515 "cache": false,
16 "persistent": true
16 "persistent": true,
1717 },
1818 "smoke-test": {
1919 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
2020 "cache": false,
21 "outputs": []
22 }
23 }
21 "outputs": [],
22 },
23 },
2424}
apps/server/src/codexAppServerManager.test.ts+2-6
......@@ -57,17 +57,13 @@ describe("normalizeCodexModelSlug", () => {
5757describe("isRecoverableThreadResumeError", () => {
5858 it("matches not-found resume errors", () => {
5959 expect(
60 isRecoverableThreadResumeError(
61 new Error("thread/resume failed: thread not found"),
62 ),
60 isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")),
6361 ).toBe(true);
6462 });
6563
6664 it("ignores non-resume errors", () => {
6765 expect(
68 isRecoverableThreadResumeError(
69 new Error("thread/start failed: permission denied"),
70 ),
66 isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")),
7167 ).toBe(false);
7268 });
7369
apps/server/src/codexAppServerManager.ts+11-33
......@@ -26,9 +26,7 @@ interface PendingRequest {
2626interface PendingApprovalRequest {
2727 requestId: string;
2828 jsonRpcId: string | number;
29 method:
30 | "item/commandExecution/requestApproval"
31 | "item/fileChange/requestApproval";
29 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
3230 requestKind: ProviderRequestKind;
3331 threadId?: string;
3432 turnId?: string;
......@@ -122,16 +120,12 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } |
122120}
123121
124122export function isRecoverableThreadResumeError(error: unknown): boolean {
125 const message = (
126 error instanceof Error ? error.message : String(error)
127 ).toLowerCase();
123 const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
128124 if (!message.includes("thread/resume")) {
129125 return false;
130126 }
131127
132 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) =>
133 message.includes(snippet),
134 );
128 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
135129}
136130
137131export interface CodexAppServerManagerEvents {
......@@ -209,14 +203,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
209203 if (input.resumeThreadId) {
210204 try {
211205 threadOpenMethod = "thread/resume";
212 threadOpenResponse = await this.sendRequest(
213 context,
214 "thread/resume",
215 {
216 ...sessionOverrides,
217 threadId: input.resumeThreadId,
218 },
219 );
206 threadOpenResponse = await this.sendRequest(context, "thread/resume", {
207 ...sessionOverrides,
208 threadId: input.resumeThreadId,
209 });
220210 } catch (error) {
221211 if (!isRecoverableThreadResumeError(error)) {
222212 throw error;
......@@ -228,19 +218,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
228218 "session/threadResumeFallback",
229219 `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`,
230220 );
231 threadOpenResponse = await this.sendRequest(
232 context,
233 "thread/start",
234 threadStartParams,
235 );
221 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
236222 }
237223 } else {
238224 threadOpenMethod = "thread/start";
239 threadOpenResponse = await this.sendRequest(
240 context,
241 "thread/start",
242 threadStartParams,
243 );
225 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
244226 }
245227
246228 const threadOpenRecord = this.readObject(threadOpenResponse);
......@@ -248,9 +230,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
248230 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??
249231 this.readString(threadOpenRecord, "threadId");
250232 if (!threadId) {
251 throw new Error(
252 `${threadOpenMethod} response did not include a thread id.`,
253 );
233 throw new Error(`${threadOpenMethod} response did not include a thread id.`);
254234 }
255235
256236 this.updateSession(context, {
......@@ -732,9 +712,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
732712 };
733713 }
734714
735 private requestKindForMethod(
736 method: string,
737 ): ProviderRequestKind | undefined {
715 private requestKindForMethod(method: string): ProviderRequestKind | undefined {
738716 if (method === "item/commandExecution/requestApproval") {
739717 return "command";
740718 }
apps/server/src/git.ts+2-10
......@@ -16,11 +16,7 @@ import type {
1616} from "@t3tools/contracts";
1717
1818/** Spawn git directly with an argv array — no shell, no quoting needed. */
19function runGit(
20 args: string[],
21 cwd: string,
22 timeoutMs = 30_000,
23): Promise<TerminalCommandResult> {
19function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
2420 return new Promise((resolve, reject) => {
2521 const child = spawn("git", args, {
2622 cwd,
......@@ -127,11 +123,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
127123 }
128124
129125 // Resolve the real default branch from the remote
130 const defaultRef = await runGit(
131 ["symbolic-ref", "refs/remotes/origin/HEAD"],
132 input.cwd,
133 5_000,
134 );
126 const defaultRef = await runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000);
135127 const defaultBranch =
136128 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;
137129
apps/server/src/providerManager.test.ts+1-4
......@@ -16,10 +16,7 @@ describe("ProviderManager", () => {
1616 method: string;
1717 threadId: string;
1818 }) => void;
19 threadLogStreams: Map<
20 string,
21 { writableEnded: boolean; destroyed: boolean }
22 >;
19 threadLogStreams: Map<string, { writableEnded: boolean; destroyed: boolean }>;
2320 };
2421 internals.onCodexEvent({
2522 id: "evt-1",
apps/server/src/providerManager.ts+2-9
......@@ -87,11 +87,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
8787 throw new Error(`Unknown provider session: ${input.sessionId}`);
8888 }
8989
90 await this.codex.respondToRequest(
91 input.sessionId,
92 input.requestId,
93 input.decision,
94 );
90 await this.codex.respondToRequest(input.sessionId, input.requestId, input.decision);
9591 }
9692
9793 stopSession(raw: ProviderStopSessionInput): void {
......@@ -151,10 +147,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
151147
152148 private resolveThreadId(event: ProviderEvent): string | undefined {
153149 const fromPayload = this.readThreadIdFromPayload(event.payload);
154 const threadId =
155 event.threadId ??
156 fromPayload ??
157 this.sessionThreadIds.get(event.sessionId);
150 const threadId = event.threadId ?? fromPayload ?? this.sessionThreadIds.get(event.sessionId);
158151
159152 if (threadId) {
160153 this.sessionThreadIds.set(event.sessionId, threadId);
apps/server/src/wsServer.ts+4-1
......@@ -369,7 +369,10 @@ export function createServer(options: ServerOptions) {
369369 const isServerNotRunningError = (error: unknown): boolean => {
370370 if (!(error instanceof Error)) return false;
371371 const maybeCode = (error as NodeJS.ErrnoException).code;
372 return maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running");
372 return (
373 maybeCode === "ERR_SERVER_NOT_RUNNING" ||
374 error.message.toLowerCase().includes("not running")
375 );
373376 };
374377
375378 const closeWebSocketServer = new Promise<void>((resolve, reject) => {
apps/server/turbo.jsonc+3-3
......@@ -5,7 +5,7 @@
55 "start": {
66 "dependsOn": ["build"],
77 "cache": false,
8 "persistent": true
9 }
10 }
8 "persistent": true,
9 },
10 },
1111}
apps/web/index.html+4-1
......@@ -5,7 +5,10 @@
55 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
66 <link rel="preconnect" href="https://fonts.googleapis.com" />
77 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8 <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" />
8 <link
9 href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap"
10 rel="stylesheet"
11 />
912 <title>T3 Code</title>
1013 </head>
1114 <body>
apps/web/package.json+1
......@@ -12,6 +12,7 @@
1212 },
1313 "dependencies": {
1414 "@t3tools/contracts": "workspace:*",
15 "@tanstack/react-query": "^5.90.0",
1516 "highlight.js": "^11.11.1",
1617 "lucide-react": "^0.563.0",
1718 "react": "^19.0.0",
apps/web/src/App.tsx+8-3
......@@ -1,3 +1,4 @@
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
12import { useEffect, useMemo, useRef } from "react";
23
34import ChatView from "./components/ChatView";
......@@ -168,10 +169,14 @@ function Layout() {
168169 );
169170}
170171
172const queryClient = new QueryClient();
173
171174export default function App() {
172175 return (
173 <StoreProvider>
174 <Layout />
175 </StoreProvider>
176 <QueryClientProvider client={queryClient}>
177 <StoreProvider>
178 <Layout />
179 </StoreProvider>
180 </QueryClientProvider>
176181 );
177182}
apps/web/src/components/BranchToolbar.tsx+89-108
......@@ -1,5 +1,6 @@
11import type { GitBranch } from "@t3tools/contracts";
2import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useEffect, useMemo, useRef, useState } from "react";
34
45import { readNativeApi } from "../session-logic";
56import { useStore } from "../store";
......@@ -10,76 +11,85 @@ interface BranchToolbarProps {
1011 envLocked: boolean;
1112}
1213
13export default function BranchToolbar({
14 envMode,
15 onEnvModeChange,
16 envLocked,
17}: BranchToolbarProps) {
14export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
1815 const { state, dispatch } = useStore();
1916 const api = useMemo(() => readNativeApi(), []);
17 const queryClient = useQueryClient();
2018
21 const [branches, setBranches] = useState<GitBranch[]>([]);
22 const [isRepo, setIsRepo] = useState(false);
23 const [isLoadingBranches, setIsLoadingBranches] = useState(false);
2419 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
2520 const [isCreatingBranch, setIsCreatingBranch] = useState(false);
2621 const [newBranchName, setNewBranchName] = useState("");
27 const [isInitializingRepo, setIsInitializingRepo] = useState(false);
2822 const branchMenuRef = useRef<HTMLDivElement>(null);
2923
3024 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
3125 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);
32 const branchCwd = activeThread?.worktreePath ?? activeProject?.cwd;
26 const activeThreadId = activeThread?.id;
27 const activeThreadBranch = activeThread?.branch ?? null;
28 const activeWorktreePath = activeThread?.worktreePath ?? null;
29 const branchCwd = activeWorktreePath ?? activeProject?.cwd;
3330
34 const loadBranches = useCallback(async () => {
35 if (!api || !branchCwd) return;
36 setIsLoadingBranches(true);
37 try {
38 const result = await api.git.listBranches({ cwd: branchCwd });
39 setIsRepo(result.isRepo);
40 setBranches(result.branches);
41 } catch (error) {
42 setIsRepo(false);
43 setBranches([]);
44 if (activeThread) {
45 dispatch({
46 type: "SET_ERROR",
47 threadId: activeThread.id,
48 error:
49 error instanceof Error ? error.message : "Failed to load git branches.",
50 });
51 }
52 } finally {
53 setIsLoadingBranches(false);
54 }
55 }, [activeThread, api, branchCwd, dispatch]);
31 // ── Queries ───────────────────────────────────────────────────────────
5632
57 useEffect(() => {
58 void loadBranches();
59 }, [loadBranches]);
33 const branchesQuery = useQuery({
34 queryKey: ["git", "branches", branchCwd],
35 queryFn: () => api!.git.listBranches({ cwd: branchCwd! }),
36 enabled: !!api && !!branchCwd,
37 });
38
39 const branches = branchesQuery.data?.branches ?? [];
40 const isRepo = branchesQuery.data?.isRepo ?? false;
41
42 // ── Mutations ─────────────────────────────────────────────────────────
43
44 const checkoutMutation = useMutation({
45 mutationFn: (branch: string) => api!.git.checkout({ cwd: branchCwd!, branch }),
46 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
47 onError: (error) =>
48 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."),
49 });
50
51 const createBranchMutation = useMutation({
52 mutationFn: (branch: string) =>
53 api!.git
54 .createBranch({ cwd: branchCwd!, branch })
55 .then(() => api!.git.checkout({ cwd: branchCwd!, branch })),
56 onSuccess: () => queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] }),
57 onError: (error) =>
58 setThreadError(error instanceof Error ? error.message : "Failed to create branch."),
59 });
60
61 const initMutation = useMutation({
62 mutationFn: () => api!.git.init({ cwd: branchCwd! }),
63 onSuccess: () => {
64 setThreadError(null);
65 queryClient.invalidateQueries({ queryKey: ["git", "branches", branchCwd] });
66 },
67 onError: (error) =>
68 setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo."),
69 });
70
71 // ── Effects ───────────────────────────────────────────────────────────
6072
6173 // Keep thread branch synced to git current branch for local threads.
74 const queryBranches = branchesQuery.data?.branches;
6275 useEffect(() => {
63 if (!activeThread || activeThread.worktreePath) return;
64 const current = branches.find((branch) => branch.current);
76 if (!activeThreadId || activeWorktreePath) return;
77 const current = queryBranches?.find((branch) => branch.current);
6578 if (!current) return;
66 if (current.name === activeThread.branch) return;
79 if (current.name === activeThreadBranch) return;
6780 dispatch({
6881 type: "SET_THREAD_BRANCH",
69 threadId: activeThread.id,
82 threadId: activeThreadId,
7083 branch: current.name,
7184 worktreePath: null,
7285 });
73 }, [activeThread, branches, dispatch]);
86 }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]);
7487
7588 useEffect(() => {
7689 if (!isBranchMenuOpen) return;
7790 const handleClickOutside = (event: MouseEvent) => {
7891 if (!branchMenuRef.current) return;
79 if (
80 event.target instanceof Node &&
81 !branchMenuRef.current.contains(event.target)
82 ) {
92 if (event.target instanceof Node && !branchMenuRef.current.contains(event.target)) {
8393 setIsBranchMenuOpen(false);
8494 }
8595 };
......@@ -89,79 +99,50 @@ export default function BranchToolbar({
8999 };
90100 }, [isBranchMenuOpen]);
91101
102 // ── Helpers ───────────────────────────────────────────────────────────
103
92104 const setThreadError = (error: string | null) => {
93 if (!activeThread) return;
94 dispatch({
95 type: "SET_ERROR",
96 threadId: activeThread.id,
97 error,
98 });
105 if (!activeThreadId) return;
106 dispatch({ type: "SET_ERROR", threadId: activeThreadId, error });
99107 };
100108
101109 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
102 if (!activeThread) return;
103 dispatch({
104 type: "SET_THREAD_BRANCH",
105 threadId: activeThread.id,
106 branch,
107 worktreePath,
108 });
109 };
110
111 const initializeRepo = async () => {
112 if (!api || !branchCwd || !activeThread || isInitializingRepo) return;
113 setIsInitializingRepo(true);
114 try {
115 await api.git.init({ cwd: branchCwd });
116 setThreadError(null);
117 await loadBranches();
118 } catch (error) {
119 setThreadError(error instanceof Error ? error.message : "Failed to initialize git repo.");
120 } finally {
121 setIsInitializingRepo(false);
122 }
110 if (!activeThreadId) return;
111 dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });
123112 };
124113
125 const selectBranch = async (branch: GitBranch) => {
126 if (!api || !activeThread || !branchCwd) return;
114 const selectBranch = (branch: GitBranch) => {
115 if (!api || !activeThreadId || !branchCwd) return;
127116
128117 // For new worktree mode, selecting a branch picks the base branch.
129 if (envMode === "worktree" && !envLocked && !activeThread.worktreePath) {
118 if (envMode === "worktree" && !envLocked && !activeWorktreePath) {
130119 setThreadError(null);
131120 setThreadBranch(branch.name, null);
132121 setIsBranchMenuOpen(false);
133122 return;
134123 }
135124
136 try {
137 await api.git.checkout({
138 cwd: branchCwd,
139 branch: branch.name,
140 });
141 setThreadError(null);
142 setThreadBranch(branch.name, activeThread.worktreePath);
143 setIsBranchMenuOpen(false);
144 await loadBranches();
145 } catch (error) {
146 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
147 }
125 checkoutMutation.mutate(branch.name, {
126 onSuccess: () => {
127 setThreadError(null);
128 setThreadBranch(branch.name, activeWorktreePath);
129 setIsBranchMenuOpen(false);
130 },
131 });
148132 };
149133
150 const createBranch = async () => {
134 const createBranch = () => {
151135 const name = newBranchName.trim();
152 if (!api || !activeThread || !branchCwd || !name) return;
153 try {
154 await api.git.createBranch({ cwd: branchCwd, branch: name });
155 await api.git.checkout({ cwd: branchCwd, branch: name });
156 setThreadError(null);
157 setThreadBranch(name, activeThread.worktreePath);
158 setNewBranchName("");
159 setIsCreatingBranch(false);
160 setIsBranchMenuOpen(false);
161 await loadBranches();
162 } catch (error) {
163 setThreadError(error instanceof Error ? error.message : "Failed to create branch.");
164 }
136 if (!api || !activeThreadId || !branchCwd || !name) return;
137 createBranchMutation.mutate(name, {
138 onSuccess: () => {
139 setThreadError(null);
140 setThreadBranch(name, activeWorktreePath);
141 setNewBranchName("");
142 setIsCreatingBranch(false);
143 setIsBranchMenuOpen(false);
144 },
145 });
165146 };
166147
167148 if (!activeThread || !activeProject) return null;
......@@ -192,10 +173,10 @@ export default function BranchToolbar({
192173 <button
193174 type="button"
194175 className="inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-[12px] text-muted-foreground/70 transition-colors duration-150 hover:bg-accent hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-50"
195 disabled={!branchCwd || isInitializingRepo}
196 onClick={() => void initializeRepo()}
176 disabled={!branchCwd || initMutation.isPending}
177 onClick={() => initMutation.mutate()}
197178 >
198 {isInitializingRepo ? "Initializing…" : "Initialize git"}
179 {initMutation.isPending ? "Initializing\u2026" : "Initialize git"}
199180 </button>
200181 ) : (
201182 <div className="relative" ref={branchMenuRef}>
......@@ -203,7 +184,7 @@ export default function BranchToolbar({
203184 type="button"
204185 className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] text-muted-foreground/60 transition-colors duration-150 hover:bg-accent/50 hover:text-muted-foreground/80"
205186 onClick={() => setIsBranchMenuOpen((open) => !open)}
206 disabled={isLoadingBranches}
187 disabled={branchesQuery.isLoading}
207188 >
208189 <span className="max-w-[240px] truncate font-mono">
209190 {activeThread.branch
......@@ -246,7 +227,7 @@ export default function BranchToolbar({
246227 ? "bg-accent text-foreground"
247228 : "text-foreground/90 hover:bg-accent/50"
248229 }`}
249 onClick={() => void selectBranch(branch)}
230 onClick={() => selectBranch(branch)}
250231 >
251232 <span className="truncate">{branch.name}</span>
252233 {(branch.current || branch.isDefault) && (
......@@ -266,7 +247,7 @@ export default function BranchToolbar({
266247 className="flex items-center gap-1 px-1"
267248 onSubmit={(event) => {
268249 event.preventDefault();
269 void createBranch();
250 createBranch();
270251 }}
271252 >
272253 <input
......@@ -286,7 +267,7 @@ export default function BranchToolbar({
286267 />
287268 <button
288269 type="submit"
289 disabled={!newBranchName.trim()}
270 disabled={!newBranchName.trim() || createBranchMutation.isPending}
290271 className="rounded-lg px-2 py-1.5 text-xs text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground/80 disabled:opacity-30"
291272 >
292273 Create
apps/web/src/components/ChatView.tsx+49-139
......@@ -92,23 +92,17 @@ function approvalDetail(event: ProviderEvent): string | undefined {
9292 return asString(payload?.reason);
9393}
9494
95function derivePendingApprovals(
96 events: ProviderEvent[],
97): PendingApprovalCard[] {
95function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] {
9896 const pending = new Map<string, PendingApprovalCard>();
9997 const ordered = [...events].toReversed();
10098
10199 for (const event of ordered) {
102 if (
103 event.method === "session/closed" ||
104 event.method === "session/exited"
105 ) {
100 if (event.method === "session/closed" || event.method === "session/exited") {
106101 pending.clear();
107102 continue;
108103 }
109104
110 const requestId =
111 event.requestId ?? asString(asRecord(event.payload)?.requestId);
105 const requestId = event.requestId ?? asString(asRecord(event.payload)?.requestId);
112106 if (!requestId) continue;
113107
114108 if (
......@@ -143,20 +137,13 @@ export default function ChatView() {
143137 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
144138 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
145139 const stored = localStorage.getItem(LAST_EDITOR_KEY);
146 return EDITORS.some((e) => e.id === stored)
147 ? (stored as EditorId)
148 : EDITORS[0].id;
140 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
149141 });
150 const [selectedEffort, setSelectedEffort] =
151 useState<string>(DEFAULT_REASONING);
142 const [selectedEffort, setSelectedEffort] = useState<string>(DEFAULT_REASONING);
152143 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
153144 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
154 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>(
155 [],
156 );
157 const [expandedWorkGroups, setExpandedWorkGroups] = useState<
158 Record<string, boolean>
159 >({});
145 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
146 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
160147 const [nowTick, setNowTick] = useState(() => Date.now());
161148 const messagesEndRef = useRef<HTMLDivElement>(null);
162149 const textareaRef = useRef<HTMLTextAreaElement>(null);
......@@ -177,11 +164,7 @@ export default function ChatView() {
177164 [activeThread?.events],
178165 );
179166 const latestTurnWorkEntries = useMemo(
180 () =>
181 deriveWorkLogEntries(
182 activeThread?.events ?? [],
183 activeThread?.latestTurnId,
184 ),
167 () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId),
185168 [activeThread?.events, activeThread?.latestTurnId],
186169 );
187170 const pendingApprovals = useMemo(
......@@ -269,22 +252,18 @@ export default function ChatView() {
269252 } as const);
270253 const envLocked = Boolean(
271254 activeThread &&
272 (activeThread.messages.length > 0 ||
273 (activeThread.session !== null && activeThread.session.status !== "closed")),
255 (activeThread.messages.length > 0 ||
256 (activeThread.session !== null && activeThread.session.status !== "closed")),
274257 );
275258
276 const handleRuntimeModeChange = async (
277 mode: "approval-required" | "full-access",
278 ) => {
259 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
279260 if (mode === state.runtimeMode) return;
280261 dispatch({ type: "SET_RUNTIME_MODE", mode });
281262 if (!api) return;
282263
283264 const sessionIds = state.threads
284265 .map((t) => t.session)
285 .filter(
286 (s): s is NonNullable<typeof s> => s !== null && s.status !== "closed",
287 )
266 .filter((s): s is NonNullable<typeof s> => s !== null && s.status !== "closed")
288267 .map((s) => s.sessionId);
289268
290269 if (sessionIds.length === 0) return;
......@@ -292,9 +271,7 @@ export default function ChatView() {
292271 setIsSwitchingRuntimeMode(true);
293272 try {
294273 await Promise.all(
295 sessionIds.map((id) =>
296 api.providers.stopSession({ sessionId: id }).catch(() => undefined),
297 ),
274 sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)),
298275 );
299276 } finally {
300277 setIsSwitchingRuntimeMode(false);
......@@ -316,10 +293,11 @@ export default function ChatView() {
316293 setExpandedWorkGroups({});
317294 }, [activeThread?.id]);
318295
296 const activeWorktreePath = activeThread?.worktreePath;
319297 useEffect(() => {
320 if (!activeThread) return;
321 setEnvMode(activeThread.worktreePath ? "worktree" : "local");
322 }, [activeThread?.id, activeThread?.worktreePath]);
298 if (!activeThread?.id) return;
299 setEnvMode(activeWorktreePath ? "worktree" : "local");
300 }, [activeThread?.id, activeWorktreePath]);
323301
324302 // Auto-resize textarea
325303 useEffect(() => {
......@@ -360,10 +338,7 @@ export default function ChatView() {
360338
361339 const handleClickOutside = (event: MouseEvent) => {
362340 if (!editorMenuRef.current) return;
363 if (
364 event.target instanceof Node &&
365 !editorMenuRef.current.contains(event.target)
366 ) {
341 if (event.target instanceof Node && !editorMenuRef.current.contains(event.target)) {
367342 setIsEditorMenuOpen(false);
368343 }
369344 };
......@@ -398,9 +373,7 @@ export default function ChatView() {
398373 setIsEditorMenuOpen(false);
399374 };
400375
401 const ensureSession = async (
402 cwdOverride?: string,
403 ): Promise<EnsuredSessionInfo | null> => {
376 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
404377 if (!api || !activeThread || !activeProject) return null;
405378 if (activeThread.session && activeThread.session.status !== "closed") {
406379 const sessionThreadId = activeThread.session.threadId ?? null;
......@@ -527,14 +500,9 @@ export default function ChatView() {
527500 try {
528501 const shouldBootstrap =
529502 previousMessages.length > 0 &&
530 (sessionInfo.continuityState === "new" ||
531 sessionInfo.continuityState === "fallback_new");
503 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
532504 const input = shouldBootstrap
533 ? buildBootstrapInput(
534 previousMessages,
535 trimmed,
536 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
537 ).text
505 ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text
538506 : trimmed;
539507 await api.providers.sendTurn({
540508 sessionId: sessionInfo.sessionId,
......@@ -561,10 +529,7 @@ export default function ChatView() {
561529 });
562530 };
563531
564 const onRespondToApproval = async (
565 requestId: string,
566 decision: ProviderApprovalDecision,
567 ) => {
532 const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => {
568533 if (!api || !activeThread?.session) return;
569534
570535 setRespondingRequestIds((existing) =>
......@@ -580,15 +545,10 @@ export default function ChatView() {
580545 dispatch({
581546 type: "SET_ERROR",
582547 threadId: activeThread.id,
583 error:
584 err instanceof Error
585 ? err.message
586 : "Failed to submit approval decision.",
548 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
587549 });
588550 } finally {
589 setRespondingRequestIds((existing) =>
590 existing.filter((id) => id !== requestId),
591 );
551 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
592552 }
593553 };
594554
......@@ -626,11 +586,11 @@ export default function ChatView() {
626586 return (
627587 <div className="flex flex-1 flex-col bg-background">
628588 {/* Top bar */}
629 <header className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}>
589 <header
590 className={`flex items-center justify-between border-b border-border px-5 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}
591 >
630592 <div className="flex items-center gap-3">
631 <h2 className="text-sm font-medium text-foreground">
632 {activeThread.title}
633 </h2>
593 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>
634594 {activeProject && (
635595 <span className="rounded-full bg-accent px-2 py-0.5 text-[10px] text-muted-foreground/50">
636596 {activeProject.name}
......@@ -666,9 +626,7 @@ export default function ChatView() {
666626 {editorLabel(editor)}
667627 {editor.id === lastEditor && (
668628 <kbd className="ml-auto text-[9px] text-muted-foreground/40">
669 {navigator.platform.includes("Mac")
670 ? "\u2318O"
671 : "Ctrl+O"}
629 {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
672630 </kbd>
673631 )}
674632 </button>
......@@ -702,9 +660,7 @@ export default function ChatView() {
702660 {pendingApprovals.length > 0 && (
703661 <div className="mx-4 mt-3 space-y-2">
704662 {pendingApprovals.map((approval) => {
705 const isResponding = respondingRequestIds.includes(
706 approval.requestId,
707 );
663 const isResponding = respondingRequestIds.includes(approval.requestId);
708664 return (
709665 <div
710666 key={approval.requestId}
......@@ -728,9 +684,7 @@ export default function ChatView() {
728684 type="button"
729685 className="rounded-md border border-border bg-accent px-2 py-1 text-[11px] text-foreground transition-colors duration-150 hover:bg-accent/80 disabled:cursor-not-allowed disabled:opacity-50"
730686 disabled={isResponding}
731 onClick={() =>
732 void onRespondToApproval(approval.requestId, "accept")
733 }
687 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
734688 >
735689 Approve once
736690 </button>
......@@ -738,12 +692,7 @@ export default function ChatView() {
738692 type="button"
739693 className="rounded-md border border-sky-300/30 bg-sky-500/[0.15] px-2 py-1 text-[11px] text-sky-100 transition-colors duration-150 hover:bg-sky-500/[0.22] disabled:cursor-not-allowed disabled:opacity-50"
740694 disabled={isResponding}
741 onClick={() =>
742 void onRespondToApproval(
743 approval.requestId,
744 "acceptForSession",
745 )
746 }
695 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
747696 >
748697 Always allow this session
749698 </button>
......@@ -751,9 +700,7 @@ export default function ChatView() {
751700 type="button"
752701 className="rounded-md border border-border px-2 py-1 text-[11px] text-foreground/90 transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
753702 disabled={isResponding}
754 onClick={() =>
755 void onRespondToApproval(approval.requestId, "decline")
756 }
703 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
757704 >
758705 Decline
759706 </button>
......@@ -761,9 +708,7 @@ export default function ChatView() {
761708 type="button"
762709 className="rounded-md border border-rose-300/30 bg-rose-500/[0.12] px-2 py-1 text-[11px] text-rose-100 transition-colors duration-150 hover:bg-rose-500/[0.2] disabled:cursor-not-allowed disabled:opacity-50"
763710 disabled={isResponding}
764 onClick={() =>
765 void onRespondToApproval(approval.requestId, "cancel")
766 }
711 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
767712 >
768713 Cancel turn
769714 </button>
......@@ -778,15 +723,14 @@ export default function ChatView() {
778723 <div className="flex-1 overflow-y-auto px-5 py-4">
779724 {activeThread.messages.length === 0 && !isWorking ? (
780725 <div className="flex h-full items-center justify-center">
781 <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p>
726 <p className="text-sm text-muted-foreground/30">
727 Send a message to start the conversation.
728 </p>
782729 </div>
783730 ) : (
784731 <div className="mx-auto max-w-3xl space-y-4">
785732 {timelineEntries.map((timelineEntry, index) => {
786 if (
787 timelineEntry.kind === "work" &&
788 timelineEntries[index - 1]?.kind === "work"
789 ) {
733 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
790734 return null;
791735 }
792736
......@@ -807,16 +751,13 @@ export default function ChatView() {
807751
808752 const groupId = timelineEntry.id;
809753 const isExpanded = expandedWorkGroups[groupId] ?? false;
810 const hasOverflow =
811 groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
754 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
812755 const visibleEntries =
813756 hasOverflow && !isExpanded
814757 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
815758 : groupedEntries;
816759 const hiddenCount = groupedEntries.length - visibleEntries.length;
817 const onlyToolEntries = groupedEntries.every(
818 (entry) => entry.tone === "tool",
819 );
760 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
820761 const groupLabel = onlyToolEntries
821762 ? groupedEntries.length === 1
822763 ? "Tool call"
......@@ -902,9 +843,7 @@ export default function ChatView() {
902843 <div className="my-3 flex items-center gap-3">
903844 <span className="h-px flex-1 bg-border" />
904845 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
905 {completionSummary
906 ? `Response • ${completionSummary}`
907 : "Response"}
846 {completionSummary ? `Response • ${completionSummary}` : "Response"}
908847 </span>
909848 <span className="h-px flex-1 bg-border" />
910849 </div>
......@@ -913,24 +852,17 @@ export default function ChatView() {
913852 <ChatMarkdown
914853 text={
915854 timelineEntry.message.text ||
916 (timelineEntry.message.streaming
917 ? ""
918 : "(empty response)")
855 (timelineEntry.message.streaming ? "" : "(empty response)")
919856 }
920857 />
921858 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
922859 {formatMessageMeta(
923860 timelineEntry.message.createdAt,
924861 timelineEntry.message.streaming
925 ? formatElapsed(
926 timelineEntry.message.createdAt,
927 nowIso,
928 )
862 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
929863 : formatElapsed(
930864 timelineEntry.message.createdAt,
931 assistantCompletionByItemId.get(
932 timelineEntry.message.id,
933 ),
865 assistantCompletionByItemId.get(timelineEntry.message.id),
934866 ),
935867 )}
936868 </p>
......@@ -1086,9 +1018,7 @@ export default function ChatView() {
10861018 disabled={isSwitchingRuntimeMode}
10871019 onClick={() =>
10881020 void handleRuntimeModeChange(
1089 state.runtimeMode === "full-access"
1090 ? "approval-required"
1091 : "full-access",
1021 state.runtimeMode === "full-access" ? "approval-required" : "full-access",
10921022 )
10931023 }
10941024 title={
......@@ -1098,13 +1028,7 @@ export default function ChatView() {
10981028 }
10991029 >
11001030 {state.runtimeMode === "full-access" ? (
1101 <svg
1102 width="14"
1103 height="14"
1104 viewBox="0 0 14 14"
1105 fill="none"
1106 aria-hidden="true"
1107 >
1031 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
11081032 <rect
11091033 x="2"
11101034 y="5.5"
......@@ -1122,13 +1046,7 @@ export default function ChatView() {
11221046 />
11231047 </svg>
11241048 ) : (
1125 <svg
1126 width="14"
1127 height="14"
1128 viewBox="0 0 14 14"
1129 fill="none"
1130 aria-hidden="true"
1131 >
1049 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
11321050 <rect
11331051 x="2"
11341052 y="5.5"
......@@ -1146,11 +1064,7 @@ export default function ChatView() {
11461064 />
11471065 </svg>
11481066 )}
1149 <span>
1150 {state.runtimeMode === "full-access"
1151 ? "Full access"
1152 : "Supervised"}
1153 </span>
1067 <span>{state.runtimeMode === "full-access" ? "Full access" : "Supervised"}</span>
11541068 </button>
11551069 </div>
11561070
......@@ -1226,11 +1140,7 @@ export default function ChatView() {
12261140 </form>
12271141 </div>
12281142
1229 <BranchToolbar
1230 envMode={envMode}
1231 onEnvModeChange={setEnvMode}
1232 envLocked={envLocked}
1233 />
1143 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
12341144 </div>
12351145 );
12361146}
apps/web/src/components/DiffPanel.tsx+3-1
......@@ -7,7 +7,9 @@ export default function DiffPanel() {
77 return (
88 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
99 {/* Header */}
10 <div className={`flex items-center justify-between border-b border-border px-4 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}>
10 <div
11 className={`flex items-center justify-between border-b border-border px-4 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}
12 >
1113 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>
1214 <button
1315 type="button"
apps/web/src/historyBootstrap.ts+4-11
......@@ -36,13 +36,8 @@ export function buildBootstrapInput(
3636 latestPrompt: string,
3737 maxChars: number,
3838): BootstrapInputResult {
39 const budget = Number.isFinite(maxChars)
40 ? Math.max(1, Math.floor(maxChars))
41 : 1;
42 const promptOnly =
43 latestPrompt.length <= budget
44 ? latestPrompt
45 : latestPrompt.slice(0, budget);
39 const budget = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : 1;
40 const promptOnly = latestPrompt.length <= budget ? latestPrompt : latestPrompt.slice(0, budget);
4641
4742 if (previousMessages.length === 0) {
4843 return {
......@@ -87,8 +82,7 @@ export function buildBootstrapInput(
8782
8883 let includedChronological = [...includedNewestFirst].reverse();
8984 while (true) {
90 const omittedCount =
91 newestFirstBlocks.length - includedChronological.length;
85 const omittedCount = newestFirstBlocks.length - includedChronological.length;
9286 const transcriptBody =
9387 omittedCount > 0
9488 ? includedChronological.length > 0
......@@ -101,8 +95,7 @@ export function buildBootstrapInput(
10195 text: finalized,
10296 includedCount: includedChronological.length,
10397 omittedCount,
104 truncated:
105 omittedCount > 0 || latestPrompt.length !== promptOnly.length,
98 truncated: omittedCount > 0 || latestPrompt.length !== promptOnly.length,
10699 };
107100 }
108101
apps/web/src/index.css+11-26
......@@ -88,11 +88,7 @@
8888 --secondary: --alpha(var(--color-black) / 4%);
8989 --secondary-foreground: var(--color-neutral-800);
9090 --muted: --alpha(var(--color-black) / 4%);
91 --muted-foreground: color-mix(
92 in srgb,
93 var(--color-neutral-500) 90%,
94 var(--color-black)
95 );
91 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
9692 --accent: --alpha(var(--color-black) / 5%);
9793 --accent-foreground: var(--color-neutral-800);
9894 --destructive: var(--color-red-500);
......@@ -122,11 +118,7 @@
122118
123119 @variant dark {
124120 color-scheme: dark;
125 --background: color-mix(
126 in srgb,
127 var(--color-neutral-950) 95%,
128 var(--color-white)
129 );
121 --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white));
130122 --foreground: var(--color-neutral-100);
131123 --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
132124 --card-foreground: var(--color-neutral-100);
......@@ -137,18 +129,10 @@
137129 --secondary: --alpha(var(--color-white) / 4%);
138130 --secondary-foreground: var(--color-neutral-100);
139131 --muted: --alpha(var(--color-white) / 4%);
140 --muted-foreground: color-mix(
141 in srgb,
142 var(--color-neutral-500) 90%,
143 var(--color-white)
144 );
132 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));
145133 --accent: --alpha(var(--color-white) / 4%);
146134 --accent-foreground: var(--color-neutral-100);
147 --destructive: color-mix(
148 in srgb,
149 var(--color-red-500) 90%,
150 var(--color-white)
151 );
135 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
152136 --border: --alpha(var(--color-white) / 6%);
153137 --input: --alpha(var(--color-white) / 8%);
154138 --ring: oklch(0.588 0.217 264);
......@@ -177,7 +161,12 @@
177161
178162body {
179163 font-family:
180 "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
164 "DM Sans",
165 -apple-system,
166 BlinkMacSystemFont,
167 "Segoe UI",
168 system-ui,
169 sans-serif;
181170 margin: 0;
182171 padding: 0;
183172 min-height: 100vh;
......@@ -287,11 +276,7 @@ label:has(> select#reasoning-effort) select {
287276.chat-markdown a {
288277 color: var(--info-foreground);
289278 text-decoration: underline;
290 text-decoration-color: color-mix(
291 in srgb,
292 var(--info-foreground) 40%,
293 transparent
294 );
279 text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent);
295280}
296281
297282.chat-markdown a:hover {
apps/web/src/persistenceSchema.ts+3-12
......@@ -1,12 +1,7 @@
11import { z } from "zod";
22
33import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import {
5 DEFAULT_RUNTIME_MODE,
6 type Project,
7 type RuntimeMode,
8 type Thread,
9} from "./types";
4import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
105
116const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
127
......@@ -150,13 +145,9 @@ export function hydratePersistedState(
150145 return {
151146 projects,
152147 threads,
153 activeThreadId: hasActiveThread
154 ? parsedState.data.activeThreadId
155 : (threads[0]?.id ?? null),
148 activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null),
156149 runtimeMode:
157 "runtimeMode" in parsedState.data
158 ? parsedState.data.runtimeMode
159 : DEFAULT_RUNTIME_MODE,
150 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,
160151 };
161152}
162153
apps/web/src/session-logic.ts+5-20
......@@ -109,11 +109,7 @@ function normalizeItemType(raw: string | undefined): string {
109109}
110110
111111function shouldDropItemType(type: string): boolean {
112 if (
113 type.includes("preamble") ||
114 type.includes("reasoning") ||
115 type.includes("thought")
116 ) {
112 if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) {
117113 return true;
118114 }
119115
......@@ -121,11 +117,7 @@ function shouldDropItemType(type: string): boolean {
121117}
122118
123119function shouldShowItemLifecycle(type: string): boolean {
124 return (
125 type.includes("tool") ||
126 type.includes("command") ||
127 type.includes("file change")
128 );
120 return type.includes("tool") || type.includes("command") || type.includes("file change");
129121}
130122
131123function shouldDropMethod(method: string): boolean {
......@@ -182,9 +174,7 @@ function extractDetail(
182174 return undefined;
183175}
184176
185function lifecycleCandidateFromItemEvent(
186 event: ProviderEvent,
187): ItemLifecycleCandidate | null {
177function lifecycleCandidateFromItemEvent(event: ProviderEvent): ItemLifecycleCandidate | null {
188178 const payload = asObject(event.payload);
189179 const item = asObject(payload?.item);
190180 const normalizedType = normalizeItemType(asString(item?.type));
......@@ -360,10 +350,7 @@ export function deriveWorkLogEntries(
360350 ) {
361351 continue;
362352 }
363 if (
364 lifecycleCandidate.label === "Tool call" &&
365 !lifecycleCandidate.detail
366 ) {
353 if (lifecycleCandidate.label === "Tool call" && !lifecycleCandidate.detail) {
367354 continue;
368355 }
369356
......@@ -371,9 +358,7 @@ export function deriveWorkLogEntries(
371358 id: lifecycleCandidate.id,
372359 createdAt: lifecycleCandidate.createdAt,
373360 label: lifecycleCandidate.label,
374 ...(lifecycleCandidate.detail
375 ? { detail: lifecycleCandidate.detail }
376 : {}),
361 ...(lifecycleCandidate.detail ? { detail: lifecycleCandidate.detail } : {}),
377362 tone: lifecycleCandidate.tone,
378363 });
379364 continue;
apps/web/src/store.ts+1-3
......@@ -395,9 +395,7 @@ export function reducer(state: AppState, action: Action): AppState {
395395 case "DELETE_THREAD": {
396396 const threads = state.threads.filter((t) => t.id !== action.threadId);
397397 const activeThreadId =
398 state.activeThreadId === action.threadId
399 ? (threads[0]?.id ?? null)
400 : state.activeThreadId;
398 state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;
401399 return { ...state, threads, activeThreadId };
402400 }
403401
apps/web/src/wsNativeApi.ts+2-4
......@@ -98,10 +98,8 @@ export function createWsNativeApi(): NativeApi {
9898 },
9999 git: {
100100 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
101 createWorktree: (input) =>
102 transport.request(WS_METHODS.gitCreateWorktree, input),
103 removeWorktree: (input) =>
104 transport.request(WS_METHODS.gitRemoveWorktree, input),
101 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
102 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
105103 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
106104 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
107105 init: (input) => transport.request(WS_METHODS.gitInit, input),
apps/web/turbo.jsonc+3-3
......@@ -3,7 +3,7 @@
33 "extends": ["//"],
44 "tasks": {
55 "dev": {
6 "persistent": true
7 }
8 }
6 "persistent": true,
7 },
8 },
99}
apps/web/vite.config.ts+1-3
......@@ -13,9 +13,7 @@ export default defineConfig({
1313 ],
1414 define: {
1515 // In dev mode, tell the web app where the WebSocket server lives
16 "import.meta.env.VITE_WS_URL": JSON.stringify(
17 process.env.VITE_WS_URL ?? "",
18 ),
16 "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
1917 },
2018 server: {
2119 port: Number(process.env.PORT ?? 5173),
bun.lock+5
......@@ -51,6 +51,7 @@
5151 "version": "0.0.0",
5252 "dependencies": {
5353 "@t3tools/contracts": "workspace:*",
54 "@tanstack/react-query": "^5.90.0",
5455 "highlight.js": "^11.11.1",
5556 "lucide-react": "^0.563.0",
5657 "react": "^19.0.0",
......@@ -364,6 +365,10 @@
364365
365366 "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
366367
368 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
369
370 "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
371
367372 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
368373
369374 "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
packages/contracts/src/git.ts+2-6
......@@ -51,13 +51,9 @@ export const gitWorktreeSchema = z.object({
5151
5252export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;
5353export type GitBranch = z.infer<typeof gitBranchSchema>;
54export type GitCreateWorktreeInput = z.infer<
55 typeof gitCreateWorktreeInputSchema
56>;
54export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>;
5755export type GitWorktree = z.infer<typeof gitWorktreeSchema>;
58export type GitRemoveWorktreeInput = z.infer<
59 typeof gitRemoveWorktreeInputSchema
60>;
56export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>;
6157export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>;
6258export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>;
6359export type GitInitInput = z.infer<typeof gitInitInputSchema>;
packages/contracts/src/provider.ts+6-21
......@@ -83,12 +83,7 @@ export const providerRespondToRequestInputSchema = z.object({
8383 decision: providerApprovalDecisionSchema,
8484});
8585
86export const providerEventKindSchema = z.enum([
87 "session",
88 "notification",
89 "request",
90 "error",
91]);
86export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]);
9287
9388export const providerEventSchema = z.object({
9489 id: z.string().min(1),
......@@ -111,24 +106,14 @@ export type ProviderKind = z.infer<typeof providerKindSchema>;
111106export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>;
112107export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;
113108export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>;
114export type ProviderApprovalDecision = z.infer<
115 typeof providerApprovalDecisionSchema
116>;
109export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSchema>;
117110export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
118111export type ProviderSession = z.infer<typeof providerSessionSchema>;
119112export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
120113export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
121export type ProviderTurnStartResult = z.infer<
122 typeof providerTurnStartResultSchema
123>;
124export type ProviderInterruptTurnInput = z.input<
125 typeof providerInterruptTurnInputSchema
126>;
127export type ProviderStopSessionInput = z.input<
128 typeof providerStopSessionInputSchema
129>;
130export type ProviderRespondToRequestInput = z.input<
131 typeof providerRespondToRequestInputSchema
132>;
114export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
115export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;
116export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>;
117export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
133118export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
134119export type ProviderEvent = z.infer<typeof providerEventSchema>;