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 @@...@@ -1,11 +1,13 @@
1# CLAUDE.md1# CLAUDE.md
22
3## Project Snapshot3## Project Snapshot
4
4T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon).5T3 Code is a minimal web GUI for using code agents like Codex and Claude Code (coming soon).
56
6This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.7This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged.
78
8## Core Priorities9## Core Priorities
10
91. Performance first.111. Performance first.
102. Reliability first.122. Reliability first.
113. Keep behavior predictable under load and during failures (session restarts, reconnects, partial streams).133. 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...@@ -13,23 +15,28 @@ This repository is a VERY EARLY WIP. Proposing sweeping changes that improve lon
13If a tradeoff is required, choose correctness and robustness over short-term convenience.15If a tradeoff is required, choose correctness and robustness over short-term convenience.
1416
15## Package Roles17## Package Roles
18
16- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.19- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
17- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.20- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket.
18- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.21- `packages/contracts`: Shared Zod schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types.
1922
20## Codex App Server (Important)23## Codex App Server (Important)
24
21T3 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.25T3 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
23How we use it in this codebase:27How we use it in this codebase:
28
24- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.29- Session startup/resume and turn lifecycle are brokered in `apps/server/src/codexAppServerManager.ts`.
25- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.30- Provider dispatch and thread event logging are coordinated in `apps/server/src/providerManager.ts`.
26- WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`.31- WebSocket server routes NativeApi methods in `apps/server/src/wsServer.ts`.
27- Web app consumes provider event streams via WebSocket push on channel `providers.event`.32- Web app consumes provider event streams via WebSocket push on channel `providers.event`.
2833
29Docs:34Docs:
35
30- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server36- Codex App Server docs: https://developers.openai.com/codex/sdk/#app-server
3137
32## Reference Repos38## Reference Repos
39
33- Open-source Codex repo: https://github.com/openai/codex40- Open-source Codex repo: https://github.com/openai/codex
34- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor41- Codex-Monitor (Tauri, feature-complete, strong reference implementation): https://github.com/Dimillian/CodexMonitor
3542
apps/desktop/turbo.jsonc+6-6
...@@ -4,21 +4,21 @@...@@ -4,21 +4,21 @@
4 "tasks": {4 "tasks": {
5 "build": {5 "build": {
6 "dependsOn": ["^build"],6 "dependsOn": ["^build"],
7 "outputs": ["dist-electron/**"]7 "outputs": ["dist-electron/**"],
8 },8 },
9 "dev": {9 "dev": {
10 "dependsOn": ["t3#build"],10 "dependsOn": ["t3#build"],
11 "persistent": true11 "persistent": true,
12 },12 },
13 "start": {13 "start": {
14 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],14 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
15 "cache": false,15 "cache": false,
16 "persistent": true16 "persistent": true,
17 },17 },
18 "smoke-test": {18 "smoke-test": {
19 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],19 "dependsOn": ["build", "@t3tools/web#build", "t3#build"],
20 "cache": false,20 "cache": false,
21 "outputs": []21 "outputs": [],
22 }22 },
23 }23 },
24}24}
apps/server/src/codexAppServerManager.test.ts+2-6
...@@ -57,17 +57,13 @@ describe("normalizeCodexModelSlug", () => {...@@ -57,17 +57,13 @@ describe("normalizeCodexModelSlug", () => {
57describe("isRecoverableThreadResumeError", () => {57describe("isRecoverableThreadResumeError", () => {
58 it("matches not-found resume errors", () => {58 it("matches not-found resume errors", () => {
59 expect(59 expect(
60 isRecoverableThreadResumeError(60 isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")),
61 new Error("thread/resume failed: thread not found"),
62 ),
63 ).toBe(true);61 ).toBe(true);
64 });62 });
6563
66 it("ignores non-resume errors", () => {64 it("ignores non-resume errors", () => {
67 expect(65 expect(
68 isRecoverableThreadResumeError(66 isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")),
69 new Error("thread/start failed: permission denied"),
70 ),
71 ).toBe(false);67 ).toBe(false);
72 });68 });
7369
apps/server/src/codexAppServerManager.ts+11-33
...@@ -26,9 +26,7 @@ interface PendingRequest {...@@ -26,9 +26,7 @@ interface PendingRequest {
26interface PendingApprovalRequest {26interface PendingApprovalRequest {
27 requestId: string;27 requestId: string;
28 jsonRpcId: string | number;28 jsonRpcId: string | number;
29 method:29 method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval";
30 | "item/commandExecution/requestApproval"
31 | "item/fileChange/requestApproval";
32 requestKind: ProviderRequestKind;30 requestKind: ProviderRequestKind;
33 threadId?: string;31 threadId?: string;
34 turnId?: string;32 turnId?: string;
...@@ -122,16 +120,12 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } |...@@ -122,16 +120,12 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } |
122}120}
123121
124export function isRecoverableThreadResumeError(error: unknown): boolean {122export function isRecoverableThreadResumeError(error: unknown): boolean {
125 const message = (123 const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
126 error instanceof Error ? error.message : String(error)
127 ).toLowerCase();
128 if (!message.includes("thread/resume")) {124 if (!message.includes("thread/resume")) {
129 return false;125 return false;
130 }126 }
131127
132 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) =>128 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
133 message.includes(snippet),
134 );
135}129}
136130
137export interface CodexAppServerManagerEvents {131export interface CodexAppServerManagerEvents {
...@@ -209,14 +203,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -209,14 +203,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
209 if (input.resumeThreadId) {203 if (input.resumeThreadId) {
210 try {204 try {
211 threadOpenMethod = "thread/resume";205 threadOpenMethod = "thread/resume";
212 threadOpenResponse = await this.sendRequest(206 threadOpenResponse = await this.sendRequest(context, "thread/resume", {
213 context,207 ...sessionOverrides,
214 "thread/resume",208 threadId: input.resumeThreadId,
215 {209 });
216 ...sessionOverrides,
217 threadId: input.resumeThreadId,
218 },
219 );
220 } catch (error) {210 } catch (error) {
221 if (!isRecoverableThreadResumeError(error)) {211 if (!isRecoverableThreadResumeError(error)) {
222 throw error;212 throw error;
...@@ -228,19 +218,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -228,19 +218,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
228 "session/threadResumeFallback",218 "session/threadResumeFallback",
229 `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`,219 `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`,
230 );220 );
231 threadOpenResponse = await this.sendRequest(221 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
232 context,
233 "thread/start",
234 threadStartParams,
235 );
236 }222 }
237 } else {223 } else {
238 threadOpenMethod = "thread/start";224 threadOpenMethod = "thread/start";
239 threadOpenResponse = await this.sendRequest(225 threadOpenResponse = await this.sendRequest(context, "thread/start", threadStartParams);
240 context,
241 "thread/start",
242 threadStartParams,
243 );
244 }226 }
245227
246 const threadOpenRecord = this.readObject(threadOpenResponse);228 const threadOpenRecord = this.readObject(threadOpenResponse);
...@@ -248,9 +230,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -248,9 +230,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
248 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??230 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??
249 this.readString(threadOpenRecord, "threadId");231 this.readString(threadOpenRecord, "threadId");
250 if (!threadId) {232 if (!threadId) {
251 throw new Error(233 throw new Error(`${threadOpenMethod} response did not include a thread id.`);
252 `${threadOpenMethod} response did not include a thread id.`,
253 );
254 }234 }
255235
256 this.updateSession(context, {236 this.updateSession(context, {
...@@ -732,9 +712,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -732,9 +712,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
732 };712 };
733 }713 }
734714
735 private requestKindForMethod(715 private requestKindForMethod(method: string): ProviderRequestKind | undefined {
736 method: string,
737 ): ProviderRequestKind | undefined {
738 if (method === "item/commandExecution/requestApproval") {716 if (method === "item/commandExecution/requestApproval") {
739 return "command";717 return "command";
740 }718 }
apps/server/src/git.ts+2-10
...@@ -16,11 +16,7 @@ import type {...@@ -16,11 +16,7 @@ import type {
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
1717
18/** Spawn git directly with an argv array — no shell, no quoting needed. */18/** Spawn git directly with an argv array — no shell, no quoting needed. */
19function runGit(19function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
20 args: string[],
21 cwd: string,
22 timeoutMs = 30_000,
23): Promise<TerminalCommandResult> {
24 return new Promise((resolve, reject) => {20 return new Promise((resolve, reject) => {
25 const child = spawn("git", args, {21 const child = spawn("git", args, {
26 cwd,22 cwd,
...@@ -127,11 +123,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL...@@ -127,11 +123,7 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
127 }123 }
128124
129 // Resolve the real default branch from the remote125 // Resolve the real default branch from the remote
130 const defaultRef = await runGit(126 const defaultRef = await runGit(["symbolic-ref", "refs/remotes/origin/HEAD"], input.cwd, 5_000);
131 ["symbolic-ref", "refs/remotes/origin/HEAD"],
132 input.cwd,
133 5_000,
134 );
135 const defaultBranch =127 const defaultBranch =
136 defaultRef.code === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null;128 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", () => {...@@ -16,10 +16,7 @@ describe("ProviderManager", () => {
16 method: string;16 method: string;
17 threadId: string;17 threadId: string;
18 }) => void;18 }) => void;
19 threadLogStreams: Map<19 threadLogStreams: Map<string, { writableEnded: boolean; destroyed: boolean }>;
20 string,
21 { writableEnded: boolean; destroyed: boolean }
22 >;
23 };20 };
24 internals.onCodexEvent({21 internals.onCodexEvent({
25 id: "evt-1",22 id: "evt-1",
apps/server/src/providerManager.ts+2-9
...@@ -87,11 +87,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -87,11 +87,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
87 throw new Error(`Unknown provider session: ${input.sessionId}`);87 throw new Error(`Unknown provider session: ${input.sessionId}`);
88 }88 }
8989
90 await this.codex.respondToRequest(90 await this.codex.respondToRequest(input.sessionId, input.requestId, input.decision);
91 input.sessionId,
92 input.requestId,
93 input.decision,
94 );
95 }91 }
9692
97 stopSession(raw: ProviderStopSessionInput): void {93 stopSession(raw: ProviderStopSessionInput): void {
...@@ -151,10 +147,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -151,10 +147,7 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
151147
152 private resolveThreadId(event: ProviderEvent): string | undefined {148 private resolveThreadId(event: ProviderEvent): string | undefined {
153 const fromPayload = this.readThreadIdFromPayload(event.payload);149 const fromPayload = this.readThreadIdFromPayload(event.payload);
154 const threadId =150 const threadId = event.threadId ?? fromPayload ?? this.sessionThreadIds.get(event.sessionId);
155 event.threadId ??
156 fromPayload ??
157 this.sessionThreadIds.get(event.sessionId);
158151
159 if (threadId) {152 if (threadId) {
160 this.sessionThreadIds.set(event.sessionId, threadId);153 this.sessionThreadIds.set(event.sessionId, threadId);
apps/server/src/wsServer.ts+4-1
...@@ -369,7 +369,10 @@ export function createServer(options: ServerOptions) {...@@ -369,7 +369,10 @@ export function createServer(options: ServerOptions) {
369 const isServerNotRunningError = (error: unknown): boolean => {369 const isServerNotRunningError = (error: unknown): boolean => {
370 if (!(error instanceof Error)) return false;370 if (!(error instanceof Error)) return false;
371 const maybeCode = (error as NodeJS.ErrnoException).code;371 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 );
373 };376 };
374377
375 const closeWebSocketServer = new Promise<void>((resolve, reject) => {378 const closeWebSocketServer = new Promise<void>((resolve, reject) => {
apps/server/turbo.jsonc+3-3
...@@ -5,7 +5,7 @@...@@ -5,7 +5,7 @@
5 "start": {5 "start": {
6 "dependsOn": ["build"],6 "dependsOn": ["build"],
7 "cache": false,7 "cache": false,
8 "persistent": true8 "persistent": true,
9 }9 },
10 }10 },
11}11}
apps/web/index.html+4-1
...@@ -5,7 +5,10 @@...@@ -5,7 +5,10 @@
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <link rel="preconnect" href="https://fonts.googleapis.com" />6 <link rel="preconnect" href="https://fonts.googleapis.com" />
7 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />7 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8 <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" />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 />
9 <title>T3 Code</title>12 <title>T3 Code</title>
10 </head>13 </head>
11 <body>14 <body>
apps/web/package.json+1
...@@ -12,6 +12,7 @@...@@ -12,6 +12,7 @@
12 },12 },
13 "dependencies": {13 "dependencies": {
14 "@t3tools/contracts": "workspace:*",14 "@t3tools/contracts": "workspace:*",
15 "@tanstack/react-query": "^5.90.0",
15 "highlight.js": "^11.11.1",16 "highlight.js": "^11.11.1",
16 "lucide-react": "^0.563.0",17 "lucide-react": "^0.563.0",
17 "react": "^19.0.0",18 "react": "^19.0.0",
apps/web/src/App.tsx+8-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
1import { useEffect, useMemo, useRef } from "react";2import { useEffect, useMemo, useRef } from "react";
23
3import ChatView from "./components/ChatView";4import ChatView from "./components/ChatView";
...@@ -168,10 +169,14 @@ function Layout() {...@@ -168,10 +169,14 @@ function Layout() {
168 );169 );
169}170}
170171
172const queryClient = new QueryClient();
173
171export default function App() {174export default function App() {
172 return (175 return (
173 <StoreProvider>176 <QueryClientProvider client={queryClient}>
174 <Layout />177 <StoreProvider>
175 </StoreProvider>178 <Layout />
179 </StoreProvider>
180 </QueryClientProvider>
176 );181 );
177}182}
apps/web/src/components/BranchToolbar.tsx+89-108
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type { GitBranch } from "@t3tools/contracts";1import 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
4import { readNativeApi } from "../session-logic";5import { readNativeApi } from "../session-logic";
5import { useStore } from "../store";6import { useStore } from "../store";
...@@ -10,76 +11,85 @@ interface BranchToolbarProps {...@@ -10,76 +11,85 @@ interface BranchToolbarProps {
10 envLocked: boolean;11 envLocked: boolean;
11}12}
1213
13export default function BranchToolbar({14export default function BranchToolbar({ envMode, onEnvModeChange, envLocked }: BranchToolbarProps) {
14 envMode,
15 onEnvModeChange,
16 envLocked,
17}: BranchToolbarProps) {
18 const { state, dispatch } = useStore();15 const { state, dispatch } = useStore();
19 const api = useMemo(() => readNativeApi(), []);16 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);
24 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);19 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
25 const [isCreatingBranch, setIsCreatingBranch] = useState(false);20 const [isCreatingBranch, setIsCreatingBranch] = useState(false);
26 const [newBranchName, setNewBranchName] = useState("");21 const [newBranchName, setNewBranchName] = useState("");
27 const [isInitializingRepo, setIsInitializingRepo] = useState(false);
28 const branchMenuRef = useRef<HTMLDivElement>(null);22 const branchMenuRef = useRef<HTMLDivElement>(null);
2923
30 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);24 const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId);
31 const activeProject = state.projects.find((project) => project.id === activeThread?.projectId);25 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 () => {31 // ── Queries ───────────────────────────────────────────────────────────
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]);
5632
57 useEffect(() => {33 const branchesQuery = useQuery({
58 void loadBranches();34 queryKey: ["git", "branches", branchCwd],
59 }, [loadBranches]);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
61 // Keep thread branch synced to git current branch for local threads.73 // Keep thread branch synced to git current branch for local threads.
74 const queryBranches = branchesQuery.data?.branches;
62 useEffect(() => {75 useEffect(() => {
63 if (!activeThread || activeThread.worktreePath) return;76 if (!activeThreadId || activeWorktreePath) return;
64 const current = branches.find((branch) => branch.current);77 const current = queryBranches?.find((branch) => branch.current);
65 if (!current) return;78 if (!current) return;
66 if (current.name === activeThread.branch) return;79 if (current.name === activeThreadBranch) return;
67 dispatch({80 dispatch({
68 type: "SET_THREAD_BRANCH",81 type: "SET_THREAD_BRANCH",
69 threadId: activeThread.id,82 threadId: activeThreadId,
70 branch: current.name,83 branch: current.name,
71 worktreePath: null,84 worktreePath: null,
72 });85 });
73 }, [activeThread, branches, dispatch]);86 }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]);
7487
75 useEffect(() => {88 useEffect(() => {
76 if (!isBranchMenuOpen) return;89 if (!isBranchMenuOpen) return;
77 const handleClickOutside = (event: MouseEvent) => {90 const handleClickOutside = (event: MouseEvent) => {
78 if (!branchMenuRef.current) return;91 if (!branchMenuRef.current) return;
79 if (92 if (event.target instanceof Node && !branchMenuRef.current.contains(event.target)) {
80 event.target instanceof Node &&
81 !branchMenuRef.current.contains(event.target)
82 ) {
83 setIsBranchMenuOpen(false);93 setIsBranchMenuOpen(false);
84 }94 }
85 };95 };
...@@ -89,79 +99,50 @@ export default function BranchToolbar({...@@ -89,79 +99,50 @@ export default function BranchToolbar({
89 };99 };
90 }, [isBranchMenuOpen]);100 }, [isBranchMenuOpen]);
91101
102 // ── Helpers ───────────────────────────────────────────────────────────
103
92 const setThreadError = (error: string | null) => {104 const setThreadError = (error: string | null) => {
93 if (!activeThread) return;105 if (!activeThreadId) return;
94 dispatch({106 dispatch({ type: "SET_ERROR", threadId: activeThreadId, error });
95 type: "SET_ERROR",
96 threadId: activeThread.id,
97 error,
98 });
99 };107 };
100108
101 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {109 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
102 if (!activeThread) return;110 if (!activeThreadId) return;
103 dispatch({111 dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });
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 }
123 };112 };
124113
125 const selectBranch = async (branch: GitBranch) => {114 const selectBranch = (branch: GitBranch) => {
126 if (!api || !activeThread || !branchCwd) return;115 if (!api || !activeThreadId || !branchCwd) return;
127116
128 // For new worktree mode, selecting a branch picks the base branch.117 // For new worktree mode, selecting a branch picks the base branch.
129 if (envMode === "worktree" && !envLocked && !activeThread.worktreePath) {118 if (envMode === "worktree" && !envLocked && !activeWorktreePath) {
130 setThreadError(null);119 setThreadError(null);
131 setThreadBranch(branch.name, null);120 setThreadBranch(branch.name, null);
132 setIsBranchMenuOpen(false);121 setIsBranchMenuOpen(false);
133 return;122 return;
134 }123 }
135124
136 try {125 checkoutMutation.mutate(branch.name, {
137 await api.git.checkout({126 onSuccess: () => {
138 cwd: branchCwd,127 setThreadError(null);
139 branch: branch.name,128 setThreadBranch(branch.name, activeWorktreePath);
140 });129 setIsBranchMenuOpen(false);
141 setThreadError(null);130 },
142 setThreadBranch(branch.name, activeThread.worktreePath);131 });
143 setIsBranchMenuOpen(false);
144 await loadBranches();
145 } catch (error) {
146 setThreadError(error instanceof Error ? error.message : "Failed to checkout branch.");
147 }
148 };132 };
149133
150 const createBranch = async () => {134 const createBranch = () => {
151 const name = newBranchName.trim();135 const name = newBranchName.trim();
152 if (!api || !activeThread || !branchCwd || !name) return;136 if (!api || !activeThreadId || !branchCwd || !name) return;
153 try {137 createBranchMutation.mutate(name, {
154 await api.git.createBranch({ cwd: branchCwd, branch: name });138 onSuccess: () => {
155 await api.git.checkout({ cwd: branchCwd, branch: name });139 setThreadError(null);
156 setThreadError(null);140 setThreadBranch(name, activeWorktreePath);
157 setThreadBranch(name, activeThread.worktreePath);141 setNewBranchName("");
158 setNewBranchName("");142 setIsCreatingBranch(false);
159 setIsCreatingBranch(false);143 setIsBranchMenuOpen(false);
160 setIsBranchMenuOpen(false);144 },
161 await loadBranches();145 });
162 } catch (error) {
163 setThreadError(error instanceof Error ? error.message : "Failed to create branch.");
164 }
165 };146 };
166147
167 if (!activeThread || !activeProject) return null;148 if (!activeThread || !activeProject) return null;
...@@ -192,10 +173,10 @@ export default function BranchToolbar({...@@ -192,10 +173,10 @@ export default function BranchToolbar({
192 <button173 <button
193 type="button"174 type="button"
194 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"175 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}176 disabled={!branchCwd || initMutation.isPending}
196 onClick={() => void initializeRepo()}177 onClick={() => initMutation.mutate()}
197 >178 >
198 {isInitializingRepo ? "Initializing…" : "Initialize git"}179 {initMutation.isPending ? "Initializing\u2026" : "Initialize git"}
199 </button>180 </button>
200 ) : (181 ) : (
201 <div className="relative" ref={branchMenuRef}>182 <div className="relative" ref={branchMenuRef}>
...@@ -203,7 +184,7 @@ export default function BranchToolbar({...@@ -203,7 +184,7 @@ export default function BranchToolbar({
203 type="button"184 type="button"
204 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"185 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"
205 onClick={() => setIsBranchMenuOpen((open) => !open)}186 onClick={() => setIsBranchMenuOpen((open) => !open)}
206 disabled={isLoadingBranches}187 disabled={branchesQuery.isLoading}
207 >188 >
208 <span className="max-w-[240px] truncate font-mono">189 <span className="max-w-[240px] truncate font-mono">
209 {activeThread.branch190 {activeThread.branch
...@@ -246,7 +227,7 @@ export default function BranchToolbar({...@@ -246,7 +227,7 @@ export default function BranchToolbar({
246 ? "bg-accent text-foreground"227 ? "bg-accent text-foreground"
247 : "text-foreground/90 hover:bg-accent/50"228 : "text-foreground/90 hover:bg-accent/50"
248 }`}229 }`}
249 onClick={() => void selectBranch(branch)}230 onClick={() => selectBranch(branch)}
250 >231 >
251 <span className="truncate">{branch.name}</span>232 <span className="truncate">{branch.name}</span>
252 {(branch.current || branch.isDefault) && (233 {(branch.current || branch.isDefault) && (
...@@ -266,7 +247,7 @@ export default function BranchToolbar({...@@ -266,7 +247,7 @@ export default function BranchToolbar({
266 className="flex items-center gap-1 px-1"247 className="flex items-center gap-1 px-1"
267 onSubmit={(event) => {248 onSubmit={(event) => {
268 event.preventDefault();249 event.preventDefault();
269 void createBranch();250 createBranch();
270 }}251 }}
271 >252 >
272 <input253 <input
...@@ -286,7 +267,7 @@ export default function BranchToolbar({...@@ -286,7 +267,7 @@ export default function BranchToolbar({
286 />267 />
287 <button268 <button
288 type="submit"269 type="submit"
289 disabled={!newBranchName.trim()}270 disabled={!newBranchName.trim() || createBranchMutation.isPending}
290 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"271 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"
291 >272 >
292 Create273 Create
apps/web/src/components/ChatView.tsx+49-139
...@@ -92,23 +92,17 @@ function approvalDetail(event: ProviderEvent): string | undefined {...@@ -92,23 +92,17 @@ function approvalDetail(event: ProviderEvent): string | undefined {
92 return asString(payload?.reason);92 return asString(payload?.reason);
93}93}
9494
95function derivePendingApprovals(95function derivePendingApprovals(events: ProviderEvent[]): PendingApprovalCard[] {
96 events: ProviderEvent[],
97): PendingApprovalCard[] {
98 const pending = new Map<string, PendingApprovalCard>();96 const pending = new Map<string, PendingApprovalCard>();
99 const ordered = [...events].toReversed();97 const ordered = [...events].toReversed();
10098
101 for (const event of ordered) {99 for (const event of ordered) {
102 if (100 if (event.method === "session/closed" || event.method === "session/exited") {
103 event.method === "session/closed" ||
104 event.method === "session/exited"
105 ) {
106 pending.clear();101 pending.clear();
107 continue;102 continue;
108 }103 }
109104
110 const requestId =105 const requestId = event.requestId ?? asString(asRecord(event.payload)?.requestId);
111 event.requestId ?? asString(asRecord(event.payload)?.requestId);
112 if (!requestId) continue;106 if (!requestId) continue;
113107
114 if (108 if (
...@@ -143,20 +137,13 @@ export default function ChatView() {...@@ -143,20 +137,13 @@ export default function ChatView() {
143 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);137 const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false);
144 const [lastEditor, setLastEditor] = useState<EditorId>(() => {138 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
145 const stored = localStorage.getItem(LAST_EDITOR_KEY);139 const stored = localStorage.getItem(LAST_EDITOR_KEY);
146 return EDITORS.some((e) => e.id === stored)140 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
147 ? (stored as EditorId)
148 : EDITORS[0].id;
149 });141 });
150 const [selectedEffort, setSelectedEffort] =142 const [selectedEffort, setSelectedEffort] = useState<string>(DEFAULT_REASONING);
151 useState<string>(DEFAULT_REASONING);
152 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");143 const [envMode, setEnvMode] = useState<"local" | "worktree">("local");
153 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);144 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
154 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>(145 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>([]);
155 [],146 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
156 );
157 const [expandedWorkGroups, setExpandedWorkGroups] = useState<
158 Record<string, boolean>
159 >({});
160 const [nowTick, setNowTick] = useState(() => Date.now());147 const [nowTick, setNowTick] = useState(() => Date.now());
161 const messagesEndRef = useRef<HTMLDivElement>(null);148 const messagesEndRef = useRef<HTMLDivElement>(null);
162 const textareaRef = useRef<HTMLTextAreaElement>(null);149 const textareaRef = useRef<HTMLTextAreaElement>(null);
...@@ -177,11 +164,7 @@ export default function ChatView() {...@@ -177,11 +164,7 @@ export default function ChatView() {
177 [activeThread?.events],164 [activeThread?.events],
178 );165 );
179 const latestTurnWorkEntries = useMemo(166 const latestTurnWorkEntries = useMemo(
180 () =>167 () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId),
181 deriveWorkLogEntries(
182 activeThread?.events ?? [],
183 activeThread?.latestTurnId,
184 ),
185 [activeThread?.events, activeThread?.latestTurnId],168 [activeThread?.events, activeThread?.latestTurnId],
186 );169 );
187 const pendingApprovals = useMemo(170 const pendingApprovals = useMemo(
...@@ -269,22 +252,18 @@ export default function ChatView() {...@@ -269,22 +252,18 @@ export default function ChatView() {
269 } as const);252 } as const);
270 const envLocked = Boolean(253 const envLocked = Boolean(
271 activeThread &&254 activeThread &&
272 (activeThread.messages.length > 0 ||255 (activeThread.messages.length > 0 ||
273 (activeThread.session !== null && activeThread.session.status !== "closed")),256 (activeThread.session !== null && activeThread.session.status !== "closed")),
274 );257 );
275258
276 const handleRuntimeModeChange = async (259 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
277 mode: "approval-required" | "full-access",
278 ) => {
279 if (mode === state.runtimeMode) return;260 if (mode === state.runtimeMode) return;
280 dispatch({ type: "SET_RUNTIME_MODE", mode });261 dispatch({ type: "SET_RUNTIME_MODE", mode });
281 if (!api) return;262 if (!api) return;
282263
283 const sessionIds = state.threads264 const sessionIds = state.threads
284 .map((t) => t.session)265 .map((t) => t.session)
285 .filter(266 .filter((s): s is NonNullable<typeof s> => s !== null && s.status !== "closed")
286 (s): s is NonNullable<typeof s> => s !== null && s.status !== "closed",
287 )
288 .map((s) => s.sessionId);267 .map((s) => s.sessionId);
289268
290 if (sessionIds.length === 0) return;269 if (sessionIds.length === 0) return;
...@@ -292,9 +271,7 @@ export default function ChatView() {...@@ -292,9 +271,7 @@ export default function ChatView() {
292 setIsSwitchingRuntimeMode(true);271 setIsSwitchingRuntimeMode(true);
293 try {272 try {
294 await Promise.all(273 await Promise.all(
295 sessionIds.map((id) =>274 sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)),
296 api.providers.stopSession({ sessionId: id }).catch(() => undefined),
297 ),
298 );275 );
299 } finally {276 } finally {
300 setIsSwitchingRuntimeMode(false);277 setIsSwitchingRuntimeMode(false);
...@@ -316,10 +293,11 @@ export default function ChatView() {...@@ -316,10 +293,11 @@ export default function ChatView() {
316 setExpandedWorkGroups({});293 setExpandedWorkGroups({});
317 }, [activeThread?.id]);294 }, [activeThread?.id]);
318295
296 const activeWorktreePath = activeThread?.worktreePath;
319 useEffect(() => {297 useEffect(() => {
320 if (!activeThread) return;298 if (!activeThread?.id) return;
321 setEnvMode(activeThread.worktreePath ? "worktree" : "local");299 setEnvMode(activeWorktreePath ? "worktree" : "local");
322 }, [activeThread?.id, activeThread?.worktreePath]);300 }, [activeThread?.id, activeWorktreePath]);
323301
324 // Auto-resize textarea302 // Auto-resize textarea
325 useEffect(() => {303 useEffect(() => {
...@@ -360,10 +338,7 @@ export default function ChatView() {...@@ -360,10 +338,7 @@ export default function ChatView() {
360338
361 const handleClickOutside = (event: MouseEvent) => {339 const handleClickOutside = (event: MouseEvent) => {
362 if (!editorMenuRef.current) return;340 if (!editorMenuRef.current) return;
363 if (341 if (event.target instanceof Node && !editorMenuRef.current.contains(event.target)) {
364 event.target instanceof Node &&
365 !editorMenuRef.current.contains(event.target)
366 ) {
367 setIsEditorMenuOpen(false);342 setIsEditorMenuOpen(false);
368 }343 }
369 };344 };
...@@ -398,9 +373,7 @@ export default function ChatView() {...@@ -398,9 +373,7 @@ export default function ChatView() {
398 setIsEditorMenuOpen(false);373 setIsEditorMenuOpen(false);
399 };374 };
400375
401 const ensureSession = async (376 const ensureSession = async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => {
402 cwdOverride?: string,
403 ): Promise<EnsuredSessionInfo | null> => {
404 if (!api || !activeThread || !activeProject) return null;377 if (!api || !activeThread || !activeProject) return null;
405 if (activeThread.session && activeThread.session.status !== "closed") {378 if (activeThread.session && activeThread.session.status !== "closed") {
406 const sessionThreadId = activeThread.session.threadId ?? null;379 const sessionThreadId = activeThread.session.threadId ?? null;
...@@ -527,14 +500,9 @@ export default function ChatView() {...@@ -527,14 +500,9 @@ export default function ChatView() {
527 try {500 try {
528 const shouldBootstrap =501 const shouldBootstrap =
529 previousMessages.length > 0 &&502 previousMessages.length > 0 &&
530 (sessionInfo.continuityState === "new" ||503 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
531 sessionInfo.continuityState === "fallback_new");
532 const input = shouldBootstrap504 const input = shouldBootstrap
533 ? buildBootstrapInput(505 ? buildBootstrapInput(previousMessages, trimmed, PROVIDER_SEND_TURN_MAX_INPUT_CHARS).text
534 previousMessages,
535 trimmed,
536 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
537 ).text
538 : trimmed;506 : trimmed;
539 await api.providers.sendTurn({507 await api.providers.sendTurn({
540 sessionId: sessionInfo.sessionId,508 sessionId: sessionInfo.sessionId,
...@@ -561,10 +529,7 @@ export default function ChatView() {...@@ -561,10 +529,7 @@ export default function ChatView() {
561 });529 });
562 };530 };
563531
564 const onRespondToApproval = async (532 const onRespondToApproval = async (requestId: string, decision: ProviderApprovalDecision) => {
565 requestId: string,
566 decision: ProviderApprovalDecision,
567 ) => {
568 if (!api || !activeThread?.session) return;533 if (!api || !activeThread?.session) return;
569534
570 setRespondingRequestIds((existing) =>535 setRespondingRequestIds((existing) =>
...@@ -580,15 +545,10 @@ export default function ChatView() {...@@ -580,15 +545,10 @@ export default function ChatView() {
580 dispatch({545 dispatch({
581 type: "SET_ERROR",546 type: "SET_ERROR",
582 threadId: activeThread.id,547 threadId: activeThread.id,
583 error:548 error: err instanceof Error ? err.message : "Failed to submit approval decision.",
584 err instanceof Error
585 ? err.message
586 : "Failed to submit approval decision.",
587 });549 });
588 } finally {550 } finally {
589 setRespondingRequestIds((existing) =>551 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
590 existing.filter((id) => id !== requestId),
591 );
592 }552 }
593 };553 };
594554
...@@ -626,11 +586,11 @@ export default function ChatView() {...@@ -626,11 +586,11 @@ export default function ChatView() {
626 return (586 return (
627 <div className="flex flex-1 flex-col bg-background">587 <div className="flex flex-1 flex-col bg-background">
628 {/* Top bar */}588 {/* 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 >
630 <div className="flex items-center gap-3">592 <div className="flex items-center gap-3">
631 <h2 className="text-sm font-medium text-foreground">593 <h2 className="text-sm font-medium text-foreground">{activeThread.title}</h2>
632 {activeThread.title}
633 </h2>
634 {activeProject && (594 {activeProject && (
635 <span className="rounded-full bg-accent px-2 py-0.5 text-[10px] text-muted-foreground/50">595 <span className="rounded-full bg-accent px-2 py-0.5 text-[10px] text-muted-foreground/50">
636 {activeProject.name}596 {activeProject.name}
...@@ -666,9 +626,7 @@ export default function ChatView() {...@@ -666,9 +626,7 @@ export default function ChatView() {
666 {editorLabel(editor)}626 {editorLabel(editor)}
667 {editor.id === lastEditor && (627 {editor.id === lastEditor && (
668 <kbd className="ml-auto text-[9px] text-muted-foreground/40">628 <kbd className="ml-auto text-[9px] text-muted-foreground/40">
669 {navigator.platform.includes("Mac")629 {navigator.platform.includes("Mac") ? "\u2318O" : "Ctrl+O"}
670 ? "\u2318O"
671 : "Ctrl+O"}
672 </kbd>630 </kbd>
673 )}631 )}
674 </button>632 </button>
...@@ -702,9 +660,7 @@ export default function ChatView() {...@@ -702,9 +660,7 @@ export default function ChatView() {
702 {pendingApprovals.length > 0 && (660 {pendingApprovals.length > 0 && (
703 <div className="mx-4 mt-3 space-y-2">661 <div className="mx-4 mt-3 space-y-2">
704 {pendingApprovals.map((approval) => {662 {pendingApprovals.map((approval) => {
705 const isResponding = respondingRequestIds.includes(663 const isResponding = respondingRequestIds.includes(approval.requestId);
706 approval.requestId,
707 );
708 return (664 return (
709 <div665 <div
710 key={approval.requestId}666 key={approval.requestId}
...@@ -728,9 +684,7 @@ export default function ChatView() {...@@ -728,9 +684,7 @@ export default function ChatView() {
728 type="button"684 type="button"
729 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"685 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"
730 disabled={isResponding}686 disabled={isResponding}
731 onClick={() =>687 onClick={() => void onRespondToApproval(approval.requestId, "accept")}
732 void onRespondToApproval(approval.requestId, "accept")
733 }
734 >688 >
735 Approve once689 Approve once
736 </button>690 </button>
...@@ -738,12 +692,7 @@ export default function ChatView() {...@@ -738,12 +692,7 @@ export default function ChatView() {
738 type="button"692 type="button"
739 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"693 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"
740 disabled={isResponding}694 disabled={isResponding}
741 onClick={() =>695 onClick={() => void onRespondToApproval(approval.requestId, "acceptForSession")}
742 void onRespondToApproval(
743 approval.requestId,
744 "acceptForSession",
745 )
746 }
747 >696 >
748 Always allow this session697 Always allow this session
749 </button>698 </button>
...@@ -751,9 +700,7 @@ export default function ChatView() {...@@ -751,9 +700,7 @@ export default function ChatView() {
751 type="button"700 type="button"
752 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"701 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"
753 disabled={isResponding}702 disabled={isResponding}
754 onClick={() =>703 onClick={() => void onRespondToApproval(approval.requestId, "decline")}
755 void onRespondToApproval(approval.requestId, "decline")
756 }
757 >704 >
758 Decline705 Decline
759 </button>706 </button>
...@@ -761,9 +708,7 @@ export default function ChatView() {...@@ -761,9 +708,7 @@ export default function ChatView() {
761 type="button"708 type="button"
762 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"709 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"
763 disabled={isResponding}710 disabled={isResponding}
764 onClick={() =>711 onClick={() => void onRespondToApproval(approval.requestId, "cancel")}
765 void onRespondToApproval(approval.requestId, "cancel")
766 }
767 >712 >
768 Cancel turn713 Cancel turn
769 </button>714 </button>
...@@ -778,15 +723,14 @@ export default function ChatView() {...@@ -778,15 +723,14 @@ export default function ChatView() {
778 <div className="flex-1 overflow-y-auto px-5 py-4">723 <div className="flex-1 overflow-y-auto px-5 py-4">
779 {activeThread.messages.length === 0 && !isWorking ? (724 {activeThread.messages.length === 0 && !isWorking ? (
780 <div className="flex h-full items-center justify-center">725 <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>
782 </div>729 </div>
783 ) : (730 ) : (
784 <div className="mx-auto max-w-3xl space-y-4">731 <div className="mx-auto max-w-3xl space-y-4">
785 {timelineEntries.map((timelineEntry, index) => {732 {timelineEntries.map((timelineEntry, index) => {
786 if (733 if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") {
787 timelineEntry.kind === "work" &&
788 timelineEntries[index - 1]?.kind === "work"
789 ) {
790 return null;734 return null;
791 }735 }
792736
...@@ -807,16 +751,13 @@ export default function ChatView() {...@@ -807,16 +751,13 @@ export default function ChatView() {
807751
808 const groupId = timelineEntry.id;752 const groupId = timelineEntry.id;
809 const isExpanded = expandedWorkGroups[groupId] ?? false;753 const isExpanded = expandedWorkGroups[groupId] ?? false;
810 const hasOverflow =754 const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
811 groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
812 const visibleEntries =755 const visibleEntries =
813 hasOverflow && !isExpanded756 hasOverflow && !isExpanded
814 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)757 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
815 : groupedEntries;758 : groupedEntries;
816 const hiddenCount = groupedEntries.length - visibleEntries.length;759 const hiddenCount = groupedEntries.length - visibleEntries.length;
817 const onlyToolEntries = groupedEntries.every(760 const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool");
818 (entry) => entry.tone === "tool",
819 );
820 const groupLabel = onlyToolEntries761 const groupLabel = onlyToolEntries
821 ? groupedEntries.length === 1762 ? groupedEntries.length === 1
822 ? "Tool call"763 ? "Tool call"
...@@ -902,9 +843,7 @@ export default function ChatView() {...@@ -902,9 +843,7 @@ export default function ChatView() {
902 <div className="my-3 flex items-center gap-3">843 <div className="my-3 flex items-center gap-3">
903 <span className="h-px flex-1 bg-border" />844 <span className="h-px flex-1 bg-border" />
904 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">845 <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 {completionSummary846 {completionSummary ? `Response • ${completionSummary}` : "Response"}
906 ? `Response • ${completionSummary}`
907 : "Response"}
908 </span>847 </span>
909 <span className="h-px flex-1 bg-border" />848 <span className="h-px flex-1 bg-border" />
910 </div>849 </div>
...@@ -913,24 +852,17 @@ export default function ChatView() {...@@ -913,24 +852,17 @@ export default function ChatView() {
913 <ChatMarkdown852 <ChatMarkdown
914 text={853 text={
915 timelineEntry.message.text ||854 timelineEntry.message.text ||
916 (timelineEntry.message.streaming855 (timelineEntry.message.streaming ? "" : "(empty response)")
917 ? ""
918 : "(empty response)")
919 }856 }
920 />857 />
921 <p className="mt-1.5 text-[10px] text-muted-foreground/30">858 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
922 {formatMessageMeta(859 {formatMessageMeta(
923 timelineEntry.message.createdAt,860 timelineEntry.message.createdAt,
924 timelineEntry.message.streaming861 timelineEntry.message.streaming
925 ? formatElapsed(862 ? formatElapsed(timelineEntry.message.createdAt, nowIso)
926 timelineEntry.message.createdAt,
927 nowIso,
928 )
929 : formatElapsed(863 : formatElapsed(
930 timelineEntry.message.createdAt,864 timelineEntry.message.createdAt,
931 assistantCompletionByItemId.get(865 assistantCompletionByItemId.get(timelineEntry.message.id),
932 timelineEntry.message.id,
933 ),
934 ),866 ),
935 )}867 )}
936 </p>868 </p>
...@@ -1086,9 +1018,7 @@ export default function ChatView() {...@@ -1086,9 +1018,7 @@ export default function ChatView() {
1086 disabled={isSwitchingRuntimeMode}1018 disabled={isSwitchingRuntimeMode}
1087 onClick={() =>1019 onClick={() =>
1088 void handleRuntimeModeChange(1020 void handleRuntimeModeChange(
1089 state.runtimeMode === "full-access"1021 state.runtimeMode === "full-access" ? "approval-required" : "full-access",
1090 ? "approval-required"
1091 : "full-access",
1092 )1022 )
1093 }1023 }
1094 title={1024 title={
...@@ -1098,13 +1028,7 @@ export default function ChatView() {...@@ -1098,13 +1028,7 @@ export default function ChatView() {
1098 }1028 }
1099 >1029 >
1100 {state.runtimeMode === "full-access" ? (1030 {state.runtimeMode === "full-access" ? (
1101 <svg1031 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1102 width="14"
1103 height="14"
1104 viewBox="0 0 14 14"
1105 fill="none"
1106 aria-hidden="true"
1107 >
1108 <rect1032 <rect
1109 x="2"1033 x="2"
1110 y="5.5"1034 y="5.5"
...@@ -1122,13 +1046,7 @@ export default function ChatView() {...@@ -1122,13 +1046,7 @@ export default function ChatView() {
1122 />1046 />
1123 </svg>1047 </svg>
1124 ) : (1048 ) : (
1125 <svg1049 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
1126 width="14"
1127 height="14"
1128 viewBox="0 0 14 14"
1129 fill="none"
1130 aria-hidden="true"
1131 >
1132 <rect1050 <rect
1133 x="2"1051 x="2"
1134 y="5.5"1052 y="5.5"
...@@ -1146,11 +1064,7 @@ export default function ChatView() {...@@ -1146,11 +1064,7 @@ export default function ChatView() {
1146 />1064 />
1147 </svg>1065 </svg>
1148 )}1066 )}
1149 <span>1067 <span>{state.runtimeMode === "full-access" ? "Full access" : "Supervised"}</span>
1150 {state.runtimeMode === "full-access"
1151 ? "Full access"
1152 : "Supervised"}
1153 </span>
1154 </button>1068 </button>
1155 </div>1069 </div>
11561070
...@@ -1226,11 +1140,7 @@ export default function ChatView() {...@@ -1226,11 +1140,7 @@ export default function ChatView() {
1226 </form>1140 </form>
1227 </div>1141 </div>
12281142
1229 <BranchToolbar1143 <BranchToolbar envMode={envMode} onEnvModeChange={setEnvMode} envLocked={envLocked} />
1230 envMode={envMode}
1231 onEnvModeChange={setEnvMode}
1232 envLocked={envLocked}
1233 />
1234 </div>1144 </div>
1235 );1145 );
1236}1146}
apps/web/src/components/DiffPanel.tsx+3-1
...@@ -7,7 +7,9 @@ export default function DiffPanel() {...@@ -7,7 +7,9 @@ export default function DiffPanel() {
7 return (7 return (
8 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">8 <aside className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
9 {/* Header */}9 {/* Header */}
10 <div className={`flex items-center justify-between border-b border-border px-4 pb-3 ${isElectron ? "drag-region pt-[28px]" : "pt-3"}`}>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 >
11 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>13 <h3 className="text-xs font-medium text-foreground">Uncommitted changes</h3>
12 <button14 <button
13 type="button"15 type="button"
apps/web/src/historyBootstrap.ts+4-11
...@@ -36,13 +36,8 @@ export function buildBootstrapInput(...@@ -36,13 +36,8 @@ export function buildBootstrapInput(
36 latestPrompt: string,36 latestPrompt: string,
37 maxChars: number,37 maxChars: number,
38): BootstrapInputResult {38): BootstrapInputResult {
39 const budget = Number.isFinite(maxChars)39 const budget = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : 1;
40 ? Math.max(1, Math.floor(maxChars))40 const promptOnly = latestPrompt.length <= budget ? latestPrompt : latestPrompt.slice(0, budget);
41 : 1;
42 const promptOnly =
43 latestPrompt.length <= budget
44 ? latestPrompt
45 : latestPrompt.slice(0, budget);
4641
47 if (previousMessages.length === 0) {42 if (previousMessages.length === 0) {
48 return {43 return {
...@@ -87,8 +82,7 @@ export function buildBootstrapInput(...@@ -87,8 +82,7 @@ export function buildBootstrapInput(
8782
88 let includedChronological = [...includedNewestFirst].reverse();83 let includedChronological = [...includedNewestFirst].reverse();
89 while (true) {84 while (true) {
90 const omittedCount =85 const omittedCount = newestFirstBlocks.length - includedChronological.length;
91 newestFirstBlocks.length - includedChronological.length;
92 const transcriptBody =86 const transcriptBody =
93 omittedCount > 087 omittedCount > 0
94 ? includedChronological.length > 088 ? includedChronological.length > 0
...@@ -101,8 +95,7 @@ export function buildBootstrapInput(...@@ -101,8 +95,7 @@ export function buildBootstrapInput(
101 text: finalized,95 text: finalized,
102 includedCount: includedChronological.length,96 includedCount: includedChronological.length,
103 omittedCount,97 omittedCount,
104 truncated:98 truncated: omittedCount > 0 || latestPrompt.length !== promptOnly.length,
105 omittedCount > 0 || latestPrompt.length !== promptOnly.length,
106 };99 };
107 }100 }
108101
apps/web/src/index.css+11-26
...@@ -88,11 +88,7 @@...@@ -88,11 +88,7 @@
88 --secondary: --alpha(var(--color-black) / 4%);88 --secondary: --alpha(var(--color-black) / 4%);
89 --secondary-foreground: var(--color-neutral-800);89 --secondary-foreground: var(--color-neutral-800);
90 --muted: --alpha(var(--color-black) / 4%);90 --muted: --alpha(var(--color-black) / 4%);
91 --muted-foreground: color-mix(91 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
92 in srgb,
93 var(--color-neutral-500) 90%,
94 var(--color-black)
95 );
96 --accent: --alpha(var(--color-black) / 5%);92 --accent: --alpha(var(--color-black) / 5%);
97 --accent-foreground: var(--color-neutral-800);93 --accent-foreground: var(--color-neutral-800);
98 --destructive: var(--color-red-500);94 --destructive: var(--color-red-500);
...@@ -122,11 +118,7 @@...@@ -122,11 +118,7 @@
122118
123 @variant dark {119 @variant dark {
124 color-scheme: dark;120 color-scheme: dark;
125 --background: color-mix(121 --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white));
126 in srgb,
127 var(--color-neutral-950) 95%,
128 var(--color-white)
129 );
130 --foreground: var(--color-neutral-100);122 --foreground: var(--color-neutral-100);
131 --card: color-mix(in srgb, var(--background) 98%, var(--color-white));123 --card: color-mix(in srgb, var(--background) 98%, var(--color-white));
132 --card-foreground: var(--color-neutral-100);124 --card-foreground: var(--color-neutral-100);
...@@ -137,18 +129,10 @@...@@ -137,18 +129,10 @@
137 --secondary: --alpha(var(--color-white) / 4%);129 --secondary: --alpha(var(--color-white) / 4%);
138 --secondary-foreground: var(--color-neutral-100);130 --secondary-foreground: var(--color-neutral-100);
139 --muted: --alpha(var(--color-white) / 4%);131 --muted: --alpha(var(--color-white) / 4%);
140 --muted-foreground: color-mix(132 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));
141 in srgb,
142 var(--color-neutral-500) 90%,
143 var(--color-white)
144 );
145 --accent: --alpha(var(--color-white) / 4%);133 --accent: --alpha(var(--color-white) / 4%);
146 --accent-foreground: var(--color-neutral-100);134 --accent-foreground: var(--color-neutral-100);
147 --destructive: color-mix(135 --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white));
148 in srgb,
149 var(--color-red-500) 90%,
150 var(--color-white)
151 );
152 --border: --alpha(var(--color-white) / 6%);136 --border: --alpha(var(--color-white) / 6%);
153 --input: --alpha(var(--color-white) / 8%);137 --input: --alpha(var(--color-white) / 8%);
154 --ring: oklch(0.588 0.217 264);138 --ring: oklch(0.588 0.217 264);
...@@ -177,7 +161,12 @@...@@ -177,7 +161,12 @@
177161
178body {162body {
179 font-family:163 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;
181 margin: 0;170 margin: 0;
182 padding: 0;171 padding: 0;
183 min-height: 100vh;172 min-height: 100vh;
...@@ -287,11 +276,7 @@ label:has(> select#reasoning-effort) select {...@@ -287,11 +276,7 @@ label:has(> select#reasoning-effort) select {
287.chat-markdown a {276.chat-markdown a {
288 color: var(--info-foreground);277 color: var(--info-foreground);
289 text-decoration: underline;278 text-decoration: underline;
290 text-decoration-color: color-mix(279 text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent);
291 in srgb,
292 var(--info-foreground) 40%,
293 transparent
294 );
295}280}
296281
297.chat-markdown a:hover {282.chat-markdown a:hover {
apps/web/src/persistenceSchema.ts+3-12
...@@ -1,12 +1,7 @@...@@ -1,12 +1,7 @@
1import { z } from "zod";1import { z } from "zod";
22
3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";3import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
4import {4import { DEFAULT_RUNTIME_MODE, type Project, type RuntimeMode, type Thread } from "./types";
5 DEFAULT_RUNTIME_MODE,
6 type Project,
7 type RuntimeMode,
8 type Thread,
9} from "./types";
105
11const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";6const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex";
127
...@@ -150,13 +145,9 @@ export function hydratePersistedState(...@@ -150,13 +145,9 @@ export function hydratePersistedState(
150 return {145 return {
151 projects,146 projects,
152 threads,147 threads,
153 activeThreadId: hasActiveThread148 activeThreadId: hasActiveThread ? parsedState.data.activeThreadId : (threads[0]?.id ?? null),
154 ? parsedState.data.activeThreadId
155 : (threads[0]?.id ?? null),
156 runtimeMode:149 runtimeMode:
157 "runtimeMode" in parsedState.data150 "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE,
158 ? parsedState.data.runtimeMode
159 : DEFAULT_RUNTIME_MODE,
160 };151 };
161}152}
162153
apps/web/src/session-logic.ts+5-20
...@@ -109,11 +109,7 @@ function normalizeItemType(raw: string | undefined): string {...@@ -109,11 +109,7 @@ function normalizeItemType(raw: string | undefined): string {
109}109}
110110
111function shouldDropItemType(type: string): boolean {111function shouldDropItemType(type: string): boolean {
112 if (112 if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) {
113 type.includes("preamble") ||
114 type.includes("reasoning") ||
115 type.includes("thought")
116 ) {
117 return true;113 return true;
118 }114 }
119115
...@@ -121,11 +117,7 @@ function shouldDropItemType(type: string): boolean {...@@ -121,11 +117,7 @@ function shouldDropItemType(type: string): boolean {
121}117}
122118
123function shouldShowItemLifecycle(type: string): boolean {119function shouldShowItemLifecycle(type: string): boolean {
124 return (120 return type.includes("tool") || type.includes("command") || type.includes("file change");
125 type.includes("tool") ||
126 type.includes("command") ||
127 type.includes("file change")
128 );
129}121}
130122
131function shouldDropMethod(method: string): boolean {123function shouldDropMethod(method: string): boolean {
...@@ -182,9 +174,7 @@ function extractDetail(...@@ -182,9 +174,7 @@ function extractDetail(
182 return undefined;174 return undefined;
183}175}
184176
185function lifecycleCandidateFromItemEvent(177function lifecycleCandidateFromItemEvent(event: ProviderEvent): ItemLifecycleCandidate | null {
186 event: ProviderEvent,
187): ItemLifecycleCandidate | null {
188 const payload = asObject(event.payload);178 const payload = asObject(event.payload);
189 const item = asObject(payload?.item);179 const item = asObject(payload?.item);
190 const normalizedType = normalizeItemType(asString(item?.type));180 const normalizedType = normalizeItemType(asString(item?.type));
...@@ -360,10 +350,7 @@ export function deriveWorkLogEntries(...@@ -360,10 +350,7 @@ export function deriveWorkLogEntries(
360 ) {350 ) {
361 continue;351 continue;
362 }352 }
363 if (353 if (lifecycleCandidate.label === "Tool call" && !lifecycleCandidate.detail) {
364 lifecycleCandidate.label === "Tool call" &&
365 !lifecycleCandidate.detail
366 ) {
367 continue;354 continue;
368 }355 }
369356
...@@ -371,9 +358,7 @@ export function deriveWorkLogEntries(...@@ -371,9 +358,7 @@ export function deriveWorkLogEntries(
371 id: lifecycleCandidate.id,358 id: lifecycleCandidate.id,
372 createdAt: lifecycleCandidate.createdAt,359 createdAt: lifecycleCandidate.createdAt,
373 label: lifecycleCandidate.label,360 label: lifecycleCandidate.label,
374 ...(lifecycleCandidate.detail361 ...(lifecycleCandidate.detail ? { detail: lifecycleCandidate.detail } : {}),
375 ? { detail: lifecycleCandidate.detail }
376 : {}),
377 tone: lifecycleCandidate.tone,362 tone: lifecycleCandidate.tone,
378 });363 });
379 continue;364 continue;
apps/web/src/store.ts+1-3
...@@ -395,9 +395,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -395,9 +395,7 @@ export function reducer(state: AppState, action: Action): AppState {
395 case "DELETE_THREAD": {395 case "DELETE_THREAD": {
396 const threads = state.threads.filter((t) => t.id !== action.threadId);396 const threads = state.threads.filter((t) => t.id !== action.threadId);
397 const activeThreadId =397 const activeThreadId =
398 state.activeThreadId === action.threadId398 state.activeThreadId === action.threadId ? (threads[0]?.id ?? null) : state.activeThreadId;
399 ? (threads[0]?.id ?? null)
400 : state.activeThreadId;
401 return { ...state, threads, activeThreadId };399 return { ...state, threads, activeThreadId };
402 }400 }
403401
apps/web/src/wsNativeApi.ts+2-4
...@@ -98,10 +98,8 @@ export function createWsNativeApi(): NativeApi {...@@ -98,10 +98,8 @@ export function createWsNativeApi(): NativeApi {
98 },98 },
99 git: {99 git: {
100 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),100 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
101 createWorktree: (input) =>101 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
102 transport.request(WS_METHODS.gitCreateWorktree, input),102 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
103 removeWorktree: (input) =>
104 transport.request(WS_METHODS.gitRemoveWorktree, input),
105 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),103 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
106 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),104 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
107 init: (input) => transport.request(WS_METHODS.gitInit, input),105 init: (input) => transport.request(WS_METHODS.gitInit, input),
apps/web/turbo.jsonc+3-3
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3 "extends": ["//"],3 "extends": ["//"],
4 "tasks": {4 "tasks": {
5 "dev": {5 "dev": {
6 "persistent": true6 "persistent": true,
7 }7 },
8 }8 },
9}9}
apps/web/vite.config.ts+1-3
...@@ -13,9 +13,7 @@ export default defineConfig({...@@ -13,9 +13,7 @@ export default defineConfig({
13 ],13 ],
14 define: {14 define: {
15 // In dev mode, tell the web app where the WebSocket server lives15 // In dev mode, tell the web app where the WebSocket server lives
16 "import.meta.env.VITE_WS_URL": JSON.stringify(16 "import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
17 process.env.VITE_WS_URL ?? "",
18 ),
19 },17 },
20 server: {18 server: {
21 port: Number(process.env.PORT ?? 5173),19 port: Number(process.env.PORT ?? 5173),
bun.lock+5
...@@ -51,6 +51,7 @@...@@ -51,6 +51,7 @@
51 "version": "0.0.0",51 "version": "0.0.0",
52 "dependencies": {52 "dependencies": {
53 "@t3tools/contracts": "workspace:*",53 "@t3tools/contracts": "workspace:*",
54 "@tanstack/react-query": "^5.90.0",
54 "highlight.js": "^11.11.1",55 "highlight.js": "^11.11.1",
55 "lucide-react": "^0.563.0",56 "lucide-react": "^0.563.0",
56 "react": "^19.0.0",57 "react": "^19.0.0",
...@@ -364,6 +365,10 @@...@@ -364,6 +365,10 @@
364365
365 "@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=="],366 "@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
367 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],372 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
368373
369 "@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=="],374 "@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({...@@ -51,13 +51,9 @@ export const gitWorktreeSchema = z.object({
5151
52export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;52export type GitListBranchesInput = z.infer<typeof gitListBranchesInputSchema>;
53export type GitBranch = z.infer<typeof gitBranchSchema>;53export type GitBranch = z.infer<typeof gitBranchSchema>;
54export type GitCreateWorktreeInput = z.infer<54export type GitCreateWorktreeInput = z.infer<typeof gitCreateWorktreeInputSchema>;
55 typeof gitCreateWorktreeInputSchema
56>;
57export type GitWorktree = z.infer<typeof gitWorktreeSchema>;55export type GitWorktree = z.infer<typeof gitWorktreeSchema>;
58export type GitRemoveWorktreeInput = z.infer<56export type GitRemoveWorktreeInput = z.infer<typeof gitRemoveWorktreeInputSchema>;
59 typeof gitRemoveWorktreeInputSchema
60>;
61export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>;57export type GitCreateBranchInput = z.infer<typeof gitCreateBranchInputSchema>;
62export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>;58export type GitCheckoutInput = z.infer<typeof gitCheckoutInputSchema>;
63export type GitInitInput = z.infer<typeof gitInitInputSchema>;59export type GitInitInput = z.infer<typeof gitInitInputSchema>;
packages/contracts/src/provider.ts+6-21
...@@ -83,12 +83,7 @@ export const providerRespondToRequestInputSchema = z.object({...@@ -83,12 +83,7 @@ export const providerRespondToRequestInputSchema = z.object({
83 decision: providerApprovalDecisionSchema,83 decision: providerApprovalDecisionSchema,
84});84});
8585
86export const providerEventKindSchema = z.enum([86export const providerEventKindSchema = z.enum(["session", "notification", "request", "error"]);
87 "session",
88 "notification",
89 "request",
90 "error",
91]);
9287
93export const providerEventSchema = z.object({88export const providerEventSchema = z.object({
94 id: z.string().min(1),89 id: z.string().min(1),
...@@ -111,24 +106,14 @@ export type ProviderKind = z.infer<typeof providerKindSchema>;...@@ -111,24 +106,14 @@ export type ProviderKind = z.infer<typeof providerKindSchema>;
111export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>;106export type ProviderApprovalPolicy = z.infer<typeof providerApprovalPolicySchema>;
112export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;107export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>;
113export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>;108export type ProviderRequestKind = z.infer<typeof providerRequestKindSchema>;
114export type ProviderApprovalDecision = z.infer<109export type ProviderApprovalDecision = z.infer<typeof providerApprovalDecisionSchema>;
115 typeof providerApprovalDecisionSchema
116>;
117export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;110export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>;
118export type ProviderSession = z.infer<typeof providerSessionSchema>;111export type ProviderSession = z.infer<typeof providerSessionSchema>;
119export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;112export type ProviderSessionStartInput = z.input<typeof providerSessionStartInputSchema>;
120export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;113export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>;
121export type ProviderTurnStartResult = z.infer<114export type ProviderTurnStartResult = z.infer<typeof providerTurnStartResultSchema>;
122 typeof providerTurnStartResultSchema115export type ProviderInterruptTurnInput = z.input<typeof providerInterruptTurnInputSchema>;
123>;116export type ProviderStopSessionInput = z.input<typeof providerStopSessionInputSchema>;
124export type ProviderInterruptTurnInput = z.input<117export type ProviderRespondToRequestInput = z.input<typeof providerRespondToRequestInputSchema>;
125 typeof providerInterruptTurnInputSchema
126>;
127export type ProviderStopSessionInput = z.input<
128 typeof providerStopSessionInputSchema
129>;
130export type ProviderRespondToRequestInput = z.input<
131 typeof providerRespondToRequestInputSchema
132>;
133export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;118export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
134export type ProviderEvent = z.infer<typeof providerEventSchema>;119export type ProviderEvent = z.infer<typeof providerEventSchema>;