authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-03-07 02:06:55-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-07 02:06:55-08:00
log0837c8b639f3fb54c155d824c308c6b03038fa9c
treee5e43d3852090e20f9f6946ccf0e62a1efc4722d
parent5a90b6be9c902df934bada91b543865c197cfb2d
signature Signed by PGP key B5690EEEBB952194

fix: everything AICodeKing reported (#320)

* Guard non-git projects and improve send and diff UX - Skip checkpoint capture/revert and turn diff ingestion for non-git workspaces - Normalize project workspace roots and return clearer request/error messages - Show "No Git" state in chat/diff UI and enrich work log with commands/files - Track send start time so working status shows elapsed duration consistently * Format active working timer in concise h/m/s units - add `formatWorkingTimer` for human-friendly durations in ChatView - show active "Working for" time as `0s`, `1m 5s`, or `2h 3m` instead of ms * more cleanup * Fix active work timing and use temp git workspaces in tests - derive active work start from unsettled turn state instead of always latest turn - update chat view to use shared deriveActiveWorkStartedAt helper - make server/ws tests create temporary workspace roots with .git for project creation

16 files changed, 544 insertions(+), 86 deletions(-)

.agents/skills/btca-cli/SKILL.md deleted-20
...@@ -1,20 +0,0 @@
1---
2name: btca-cli
3description: btca is a cli for getting information from other git repositories by querying them with natural language. Spawns an external subagent to explore external codebases (i.e. a github repo) and answer questions about them. Great when integrating complex libraries, frameworks, or tools.
4---
5
6btca is a cli for asking questions about various folders, primarily git repositories. It's built to give you the exact information you need without bloating your context window. btca uses agents under the hood to answer your question, so prompt it the way you would prompt a tool similar to yourself.
7
8For example, assuming you have a resource named `codex`, you can ask questions like:
9
10```bash
11btca ask -r codex -q "What is the return format for Codex App Server responses? Are there any edge cases worth knowing?"
12```
13
14Your prompt may include specific names for resources to use, if they do then you should try those first. If you need to check available resources, you can use the `btca resources` command.
15
16New resources can be created if the ones you need do not yet exist.
17
18```bash
19btca add -n svelte-dev https://github.com/sveltejs/svelte.dev
20```
.agents/skills/btca-cli/agents/openai.yaml deleted-3
...@@ -1,3 +0,0 @@
1interface:
2 display_name: "BTCA CLI"
3 short_description: "Help with BTCA CLI setup and usage workflows"
TODO.md-12
...@@ -2,24 +2,12 @@...@@ -2,24 +2,12 @@
22
3## Small things3## Small things
44
5- [ ] Plan mode
6- [ ] Submitting new messages should scroll to bottom5- [ ] Submitting new messages should scroll to bottom
7- [ ] 1 click copy in codeblocks
8- [x] Pasting/adding images to submit with prompts
9- [ ] Only show last 10 threads for a given project6- [ ] Only show last 10 threads for a given project
10- [ ] Thread archiving7- [ ] Thread archiving
11- [x] Selecting folder should instantly create project
12- [ ] New projects should go on top8- [ ] New projects should go on top
13- [ ] Projects should be sorted by latest thread update9- [ ] Projects should be sorted by latest thread update
14- [x] "Response" bar should ONLY appear on work completed
15- [x] "Response" bar should ONLY appear above the FINAL message in a series
16- [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc)
17- [x] Insta focus input on new thread creation
18- [x] Disable "streaming" (wait until whole paragraph/step finishes before showing it
19- [x] Making new thread should auto-focus input box
20- [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank)
2110
22## Bigger things11## Bigger things
2312
24- [ ] Queueing messages13- [ ] Queueing messages
25- [x] External runners (i.e. running the cli on a mac mini)
apps/server/src/git/isRepo.ts created+6
...@@ -0,0 +1,6 @@
1import { existsSync } from "node:fs";
2import { join } from "node:path";
3
4export function isGitRepository(cwd: string): boolean {
5 return existsSync(join(cwd, ".git"));
6}
apps/server/src/orchestration/Layers/CheckpointReactor.ts+26
...@@ -20,6 +20,7 @@ import { CheckpointReactor, type CheckpointReactorShape } from "../Services/Chec...@@ -20,6 +20,7 @@ import { CheckpointReactor, type CheckpointReactorShape } from "../Services/Chec
20import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";20import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
21import { CheckpointStoreError } from "../../checkpointing/Errors.ts";21import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
22import { OrchestrationDispatchError } from "../Errors.ts";22import { OrchestrationDispatchError } from "../Errors.ts";
23import { isGitRepository } from "../../git/isRepo.ts";
2324
24type ReactorInput =25type ReactorInput =
25 | {26 | {
...@@ -144,6 +145,8 @@ const make = Effect.gen(function* () {...@@ -144,6 +145,8 @@ const make = Effect.gen(function* () {
144 return Option.none();145 return Option.none();
145 });146 });
146147
148 const isGitWorkspace = (cwd: string) => isGitRepository(cwd);
149
147 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (150 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
148 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,151 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
149 ) {152 ) {
...@@ -184,6 +187,14 @@ const make = Effect.gen(function* () {...@@ -184,6 +187,14 @@ const make = Effect.gen(function* () {
184 });187 });
185 return;188 return;
186 }189 }
190 if (!isGitWorkspace(checkpointCwd)) {
191 yield* Effect.logDebug("checkpoint capture skipped for non-git workspace", {
192 threadId: thread.id,
193 turnId,
194 cwd: checkpointCwd,
195 });
196 return;
197 }
187198
188 const currentTurnCount = thread.checkpoints.reduce(199 const currentTurnCount = thread.checkpoints.reduce(
189 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),200 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
...@@ -319,6 +330,9 @@ const make = Effect.gen(function* () {...@@ -319,6 +330,9 @@ const make = Effect.gen(function* () {
319 });330 });
320 return;331 return;
321 }332 }
333 if (!isGitWorkspace(checkpointCwd)) {
334 return;
335 }
322336
323 const currentTurnCount = thread.checkpoints.reduce(337 const currentTurnCount = thread.checkpoints.reduce(
324 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),338 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
...@@ -378,6 +392,9 @@ const make = Effect.gen(function* () {...@@ -378,6 +392,9 @@ const make = Effect.gen(function* () {
378 });392 });
379 return;393 return;
380 }394 }
395 if (!isGitWorkspace(checkpointCwd)) {
396 return;
397 }
381398
382 const currentTurnCount = thread.checkpoints.reduce(399 const currentTurnCount = thread.checkpoints.reduce(
383 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),400 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
...@@ -425,6 +442,15 @@ const make = Effect.gen(function* () {...@@ -425,6 +442,15 @@ const make = Effect.gen(function* () {
425 }).pipe(Effect.catch(() => Effect.void));442 }).pipe(Effect.catch(() => Effect.void));
426 return;443 return;
427 }444 }
445 if (!isGitWorkspace(sessionRuntime.value.cwd)) {
446 yield* appendRevertFailureActivity({
447 threadId: event.payload.threadId,
448 turnCount: event.payload.turnCount,
449 detail: "Checkpoints are unavailable because this project is not a git repository.",
450 createdAt: now,
451 }).pipe(Effect.catch(() => Effect.void));
452 return;
453 }
428454
429 const currentTurnCount = thread.checkpoints.reduce(455 const currentTurnCount = thread.checkpoints.reduce(
430 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),456 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+17-1
...@@ -1,3 +1,7 @@...@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
1import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts";5import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts";
2import {6import {
3 ApprovalRequestId,7 ApprovalRequestId,
...@@ -112,6 +116,13 @@ describe("ProviderRuntimeIngestion", () => {...@@ -112,6 +116,13 @@ describe("ProviderRuntimeIngestion", () => {
112 unknown116 unknown
113 > | null = null;117 > | null = null;
114 let scope: Scope.Closeable | null = null;118 let scope: Scope.Closeable | null = null;
119 const tempDirs: string[] = [];
120
121 function makeTempDir(prefix: string): string {
122 const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
123 tempDirs.push(dir);
124 return dir;
125 }
115126
116 afterEach(async () => {127 afterEach(async () => {
117 if (scope) {128 if (scope) {
...@@ -122,9 +133,14 @@ describe("ProviderRuntimeIngestion", () => {...@@ -122,9 +133,14 @@ describe("ProviderRuntimeIngestion", () => {
122 await runtime.dispose();133 await runtime.dispose();
123 }134 }
124 runtime = null;135 runtime = null;
136 for (const dir of tempDirs.splice(0)) {
137 fs.rmSync(dir, { recursive: true, force: true });
138 }
125 });139 });
126140
127 async function createHarness() {141 async function createHarness() {
142 const workspaceRoot = makeTempDir("t3-provider-project-");
143 fs.mkdirSync(path.join(workspaceRoot, ".git"));
128 const provider = createProviderServiceHarness();144 const provider = createProviderServiceHarness();
129 const orchestrationLayer = OrchestrationEngineLive.pipe(145 const orchestrationLayer = OrchestrationEngineLive.pipe(
130 Layer.provide(OrchestrationProjectionPipelineLive),146 Layer.provide(OrchestrationProjectionPipelineLive),
...@@ -152,7 +168,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -152,7 +168,7 @@ describe("ProviderRuntimeIngestion", () => {
152 commandId: CommandId.makeUnsafe("cmd-provider-project-create"),168 commandId: CommandId.makeUnsafe("cmd-provider-project-create"),
153 projectId: asProjectId("project-1"),169 projectId: asProjectId("project-1"),
154 title: "Provider Project",170 title: "Provider Project",
155 workspaceRoot: "/tmp/provider-project",171 workspaceRoot,
156 defaultModel: "gpt-5-codex",172 defaultModel: "gpt-5-codex",
157 createdAt,173 createdAt,
158 }),174 }),
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+19-1
...@@ -13,6 +13,8 @@ import {...@@ -13,6 +13,8 @@ import {
13import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";13import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";
1414
15import { ProviderService } from "../../provider/Services/ProviderService.ts";15import { ProviderService } from "../../provider/Services/ProviderService.ts";
16import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
17import { isGitRepository } from "../../git/isRepo.ts";
16import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";18import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
17import {19import {
18 ProviderRuntimeIngestionService,20 ProviderRuntimeIngestionService,
...@@ -507,6 +509,22 @@ const make = Effect.gen(function* () {...@@ -507,6 +509,22 @@ const make = Effect.gen(function* () {
507 lookup: () => Effect.succeed({ text: "", createdAt: "" }),509 lookup: () => Effect.succeed({ text: "", createdAt: "" }),
508 });510 });
509511
512 const isGitRepoForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
513 const readModel = yield* orchestrationEngine.getReadModel();
514 const thread = readModel.threads.find((entry) => entry.id === threadId);
515 if (!thread) {
516 return false;
517 }
518 const workspaceCwd = resolveThreadWorkspaceCwd({
519 thread,
520 projects: readModel.projects,
521 });
522 if (!workspaceCwd) {
523 return false;
524 }
525 return isGitRepository(workspaceCwd);
526 });
527
510 const rememberAssistantMessageId = (528 const rememberAssistantMessageId = (
511 threadId: ThreadId,529 threadId: ThreadId,
512 turnId: TurnId,530 turnId: TurnId,
...@@ -1041,7 +1059,7 @@ const make = Effect.gen(function* () {...@@ -1041,7 +1059,7 @@ const make = Effect.gen(function* () {
10411059
1042 if (event.type === "turn.diff.updated") {1060 if (event.type === "turn.diff.updated") {
1043 const turnId = toTurnId(event.turnId);1061 const turnId = toTurnId(event.turnId);
1044 if (turnId) {1062 if (turnId && (yield* isGitRepoForThread(thread.id))) {
1045 const assistantMessageId = MessageId.makeUnsafe(1063 const assistantMessageId = MessageId.makeUnsafe(
1046 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,1064 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
1047 );1065 );
apps/server/src/os-jank.ts+3-3
...@@ -20,12 +20,12 @@ export function fixPath(): void {...@@ -20,12 +20,12 @@ export function fixPath(): void {
20}20}
2121
22export const expandHomePath = Effect.fn(function* (input: string) {22export const expandHomePath = Effect.fn(function* (input: string) {
23 const { join, sep } = yield* Path.Path;23 const { join } = yield* Path.Path;
24 if (input === "~") {24 if (input === "~") {
25 return OS.homedir();25 return OS.homedir();
26 }26 }
27 if (input.startsWith(`~${sep}`)) {27 if (input.startsWith("~/") || input.startsWith("~\\")) {
28 return join(OS.homedir(), input.slice(sep.length));28 return join(OS.homedir(), input.slice(2));
29 }29 }
30 return input;30 return input;
31});31});
apps/server/src/wsServer.test.ts+4-2
...@@ -1108,13 +1108,14 @@ describe("WebSocket Server", () => {...@@ -1108,13 +1108,14 @@ describe("WebSocket Server", () => {
1108 connections.push(ws);1108 connections.push(ws);
1109 await waitForMessage(ws);1109 await waitForMessage(ws);
11101110
1111 const workspaceRoot = makeTempDir("t3code-ws-diff-project-");
1111 const createdAt = new Date().toISOString();1112 const createdAt = new Date().toISOString();
1112 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {1113 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1113 type: "project.create",1114 type: "project.create",
1114 commandId: "cmd-diff-project-create",1115 commandId: "cmd-diff-project-create",
1115 projectId: "project-diff",1116 projectId: "project-diff",
1116 title: "Diff Project",1117 title: "Diff Project",
1117 workspaceRoot: "/tmp/ws-diff-project",1118 workspaceRoot,
1118 defaultModel: "gpt-5-codex",1119 defaultModel: "gpt-5-codex",
1119 createdAt,1120 createdAt,
1120 });1121 });
...@@ -1186,13 +1187,14 @@ describe("WebSocket Server", () => {...@@ -1186,13 +1187,14 @@ describe("WebSocket Server", () => {
1186 connections.push(ws);1187 connections.push(ws);
1187 await waitForMessage(ws);1188 await waitForMessage(ws);
11881189
1190 const workspaceRoot = makeTempDir("t3code-ws-project-");
1189 const createdAt = new Date().toISOString();1191 const createdAt = new Date().toISOString();
1190 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {1192 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
1191 type: "project.create",1193 type: "project.create",
1192 commandId: "cmd-ws-project-create",1194 commandId: "cmd-ws-project-create",
1193 projectId: "project-1",1195 projectId: "project-1",
1194 title: "WS Project",1196 title: "WS Project",
1195 workspaceRoot: "/tmp/ws-project",1197 workspaceRoot,
1196 defaultModel: "gpt-5-codex",1198 defaultModel: "gpt-5-codex",
1197 createdAt,1199 createdAt,
1198 });1200 });
apps/server/src/wsServer.ts+49-3
...@@ -72,6 +72,7 @@ import {...@@ -72,6 +72,7 @@ import {
72} from "./attachmentStore.ts";72} from "./attachmentStore.ts";
73import { parseBase64DataUrl } from "./imageMime.ts";73import { parseBase64DataUrl } from "./imageMime.ts";
74import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";74import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
75import { expandHomePath } from "./os-jank.ts";
7576
76/**77/**
77 * ServerShape - Service API for server lifecycle control.78 * ServerShape - Service API for server lifecycle control.
...@@ -194,6 +195,13 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {...@@ -194,6 +195,13 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {
194 return Struct.omit(body, ["_tag"]);195 return Struct.omit(body, ["_tag"]);
195}196}
196197
198function messageFromCause(cause: Cause.Cause<unknown>): string {
199 const squashed = Cause.squash(cause);
200 const message =
201 squashed instanceof Error ? squashed.message.trim() : String(squashed).trim();
202 return message.length > 0 ? message : Cause.pretty(cause);
203}
204
197export type ServerCoreRuntimeServices =205export type ServerCoreRuntimeServices =
198 | OrchestrationEngineService206 | OrchestrationEngineService
199 | ProjectionSnapshotQuery207 | ProjectionSnapshotQuery
...@@ -298,6 +306,41 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -298,6 +306,41 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
298 const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {306 const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
299 readonly command: ClientOrchestrationCommand;307 readonly command: ClientOrchestrationCommand;
300 }) {308 }) {
309 const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) {
310 const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim()));
311 const workspaceStat = yield* fileSystem
312 .stat(normalizedWorkspaceRoot)
313 .pipe(Effect.catch(() => Effect.succeed(null)));
314 if (!workspaceStat) {
315 return yield* new RouteRequestError({
316 message: `Project directory does not exist: ${normalizedWorkspaceRoot}`,
317 });
318 }
319 if (workspaceStat.type !== "Directory") {
320 return yield* new RouteRequestError({
321 message: `Project path is not a directory: ${normalizedWorkspaceRoot}`,
322 });
323 }
324 return normalizedWorkspaceRoot;
325 });
326
327 if (input.command.type === "project.create") {
328 return {
329 ...input.command,
330 workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),
331 } satisfies OrchestrationCommand;
332 }
333
334 if (
335 input.command.type === "project.meta.update" &&
336 input.command.workspaceRoot !== undefined
337 ) {
338 return {
339 ...input.command,
340 workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot),
341 } satisfies OrchestrationCommand;
342 }
343
301 if (input.command.type !== "thread.turn.start") {344 if (input.command.type !== "thread.turn.start") {
302 return input.command as OrchestrationCommand;345 return input.command as OrchestrationCommand;
303 }346 }
...@@ -658,7 +701,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -658,7 +701,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
658 );701 );
659 }702 }
660703
661 const runPromise = yield* Effect.map(Effect.services<never>(), Effect.runPromiseWith);704 const runtimeServices = yield* Effect.services<
705 ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path
706 >();
707 const runPromise = Effect.runPromiseWith(runtimeServices);
662708
663 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(709 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
664 (event) => void Effect.runPromise(onTerminalEvent(event)),710 (event) => void Effect.runPromise(onTerminalEvent(event)),
...@@ -873,7 +919,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -873,7 +919,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
873 if (request._tag === "Failure") {919 if (request._tag === "Failure") {
874 const errorResponse = yield* encodeResponse({920 const errorResponse = yield* encodeResponse({
875 id: "unknown",921 id: "unknown",
876 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },922 error: { message: `Invalid request format: ${messageFromCause(request.cause)}` },
877 });923 });
878 ws.send(errorResponse);924 ws.send(errorResponse);
879 return;925 return;
...@@ -883,7 +929,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -883,7 +929,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
883 if (result._tag === "Failure") {929 if (result._tag === "Failure") {
884 const errorResponse = yield* encodeResponse({930 const errorResponse = yield* encodeResponse({
885 id: request.value.id,931 id: request.value.id,
886 error: { message: Cause.pretty(result.cause) },932 error: { message: messageFromCause(result.cause) },
887 });933 });
888 ws.send(errorResponse);934 ws.send(errorResponse);
889 return;935 return;
apps/web/src/components/ChatView.tsx+132-29
...@@ -67,6 +67,7 @@ import {...@@ -67,6 +67,7 @@ import {
67 derivePendingUserInputs,67 derivePendingUserInputs,
68 derivePhase,68 derivePhase,
69 deriveTimelineEntries,69 deriveTimelineEntries,
70 deriveActiveWorkStartedAt,
70 deriveActivePlanState,71 deriveActivePlanState,
71 findLatestProposedPlan,72 findLatestProposedPlan,
72 type PendingApproval,73 type PendingApproval,
...@@ -223,6 +224,29 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {...@@ -223,6 +224,29 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
223 return `${formatTimestamp(createdAt)} • ${duration}`;224 return `${formatTimestamp(createdAt)} • ${duration}`;
224}225}
225226
227function formatWorkingTimer(startIso: string, endIso: string): string | null {
228 const startedAtMs = Date.parse(startIso);
229 const endedAtMs = Date.parse(endIso);
230 if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
231 return null;
232 }
233
234 const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
235 if (elapsedSeconds < 60) {
236 return `${elapsedSeconds}s`;
237 }
238
239 const hours = Math.floor(elapsedSeconds / 3600);
240 const minutes = Math.floor((elapsedSeconds % 3600) / 60);
241 const seconds = elapsedSeconds % 60;
242
243 if (hours > 0) {
244 return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
245 }
246
247 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
248}
249
226const LAST_EDITOR_KEY = "t3code:last-editor";250const LAST_EDITOR_KEY = "t3code:last-editor";
227const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";251const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
228const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;252const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
...@@ -619,6 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -619,6 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
619 Record<ThreadId, string | null>643 Record<ThreadId, string | null>
620 >({});644 >({});
621 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");645 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
646 const [sendStartedAt, setSendStartedAt] = useState<string | null>(null);
622 const [isConnecting, _setIsConnecting] = useState(false);647 const [isConnecting, _setIsConnecting] = useState(false);
623 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);648 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
624 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);649 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
...@@ -838,6 +863,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -838,6 +863,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
838 const isPreparingWorktree = sendPhase === "preparing-worktree";863 const isPreparingWorktree = sendPhase === "preparing-worktree";
839 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;864 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
840 const nowIso = new Date(nowTick).toISOString();865 const nowIso = new Date(nowTick).toISOString();
866 const activeWorkStartedAt = deriveActiveWorkStartedAt(
867 activeLatestTurn,
868 activeThread?.session ?? null,
869 sendStartedAt,
870 );
841 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;871 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
842 const workLogEntries = useMemo(872 const workLogEntries = useMemo(
843 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),873 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
...@@ -1943,6 +1973,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1943,6 +1973,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1943 return [];1973 return [];
1944 });1974 });
1945 setSendPhase("idle");1975 setSendPhase("idle");
1976 setSendStartedAt(null);
1946 setComposerHighlightedItemId(null);1977 setComposerHighlightedItemId(null);
1947 setComposerCursor(promptRef.current.length);1978 setComposerCursor(promptRef.current.length);
1948 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));1979 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
...@@ -2082,6 +2113,37 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2082,6 +2113,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
2082 };2113 };
2083 }, [phase]);2114 }, [phase]);
20842115
2116 const beginSendPhase = useCallback((nextPhase: Exclude<SendPhase, "idle">) => {
2117 setSendStartedAt((current) => current ?? new Date().toISOString());
2118 setSendPhase(nextPhase);
2119 }, []);
2120
2121 const resetSendPhase = useCallback(() => {
2122 setSendPhase("idle");
2123 setSendStartedAt(null);
2124 }, []);
2125
2126 useEffect(() => {
2127 if (sendPhase === "idle") {
2128 return;
2129 }
2130 if (
2131 phase === "running" ||
2132 activePendingApproval !== null ||
2133 activePendingUserInput !== null ||
2134 activeThread?.error
2135 ) {
2136 resetSendPhase();
2137 }
2138 }, [
2139 activePendingApproval,
2140 activePendingUserInput,
2141 activeThread?.error,
2142 phase,
2143 resetSendPhase,
2144 sendPhase,
2145 ]);
2146
2085 useEffect(() => {2147 useEffect(() => {
2086 if (!activeThreadId) return;2148 if (!activeThreadId) return;
2087 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;2149 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
...@@ -2391,7 +2453,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2391,7 +2453,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2391 }2453 }
23922454
2393 sendInFlightRef.current = true;2455 sendInFlightRef.current = true;
2394 setSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");2456 beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");
23952457
2396 const composerImagesSnapshot = [...composerImages];2458 const composerImagesSnapshot = [...composerImages];
2397 const messageIdForSend = newMessageId();2459 const messageIdForSend = newMessageId();
...@@ -2442,7 +2504,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2442,7 +2504,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2442 await (async () => {2504 await (async () => {
2443 // On first message: lock in branch + create worktree if needed.2505 // On first message: lock in branch + create worktree if needed.
2444 if (baseBranchForWorktree) {2506 if (baseBranchForWorktree) {
2445 setSendPhase("preparing-worktree");2507 beginSendPhase("preparing-worktree");
2446 const newBranch = buildTemporaryWorktreeBranchName();2508 const newBranch = buildTemporaryWorktreeBranchName();
2447 const result = await createWorktreeMutation.mutateAsync({2509 const result = await createWorktreeMutation.mutateAsync({
2448 cwd: activeProject.cwd,2510 cwd: activeProject.cwd,
...@@ -2547,7 +2609,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2547,7 +2609,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2547 });2609 });
2548 }2610 }
25492611
2550 setSendPhase("sending-turn");2612 beginSendPhase("sending-turn");
2551 const turnAttachments = await turnAttachmentsPromise;2613 const turnAttachments = await turnAttachmentsPromise;
2552 await api.orchestration.dispatchCommand({2614 await api.orchestration.dispatchCommand({
2553 type: "thread.turn.start",2615 type: "thread.turn.start",
...@@ -2609,7 +2671,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2609,7 +2671,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2609 );2671 );
2610 });2672 });
2611 sendInFlightRef.current = false;2673 sendInFlightRef.current = false;
2612 setSendPhase("idle");2674 if (!turnStartSucceeded) {
2675 resetSendPhase();
2676 }
2613 };2677 };
26142678
2615 const onInterrupt = async () => {2679 const onInterrupt = async () => {
...@@ -2796,7 +2860,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2796,7 +2860,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2796 const messageCreatedAt = new Date().toISOString();2860 const messageCreatedAt = new Date().toISOString();
27972861
2798 sendInFlightRef.current = true;2862 sendInFlightRef.current = true;
2799 setSendPhase("sending-turn");2863 beginSendPhase("sending-turn");
2800 setThreadError(threadIdForSend, null);2864 setThreadError(threadIdForSend, null);
2801 setOptimisticUserMessages((existing) => [2865 setOptimisticUserMessages((existing) => [
2802 ...existing,2866 ...existing,
...@@ -2845,7 +2909,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2845,7 +2909,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
2845 createdAt: messageCreatedAt,2909 createdAt: messageCreatedAt,
2846 });2910 });
2847 sendInFlightRef.current = false;2911 sendInFlightRef.current = false;
2848 setSendPhase("idle");
2849 } catch (err) {2912 } catch (err) {
2850 setOptimisticUserMessages((existing) =>2913 setOptimisticUserMessages((existing) =>
2851 existing.filter((message) => message.id !== messageIdForSend),2914 existing.filter((message) => message.id !== messageIdForSend),
...@@ -2855,16 +2918,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2855,16 +2918,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
2855 err instanceof Error ? err.message : "Failed to send plan follow-up.",2918 err instanceof Error ? err.message : "Failed to send plan follow-up.",
2856 );2919 );
2857 sendInFlightRef.current = false;2920 sendInFlightRef.current = false;
2858 setSendPhase("idle");2921 resetSendPhase();
2859 }2922 }
2860 },2923 },
2861 [2924 [
2862 activeThread,2925 activeThread,
2926 beginSendPhase,
2863 forceStickToBottom,2927 forceStickToBottom,
2864 isConnecting,2928 isConnecting,
2865 isSendBusy,2929 isSendBusy,
2866 isServerThread,2930 isServerThread,
2867 persistThreadSettingsForNextTurn,2931 persistThreadSettingsForNextTurn,
2932 resetSendPhase,
2868 runtimeMode,2933 runtimeMode,
2869 selectedModel,2934 selectedModel,
2870 selectedModelOptionsForDispatch,2935 selectedModelOptionsForDispatch,
...@@ -2902,10 +2967,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2902,10 +2967,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
2902 DEFAULT_MODEL_BY_PROVIDER.codex;2967 DEFAULT_MODEL_BY_PROVIDER.codex;
29032968
2904 sendInFlightRef.current = true;2969 sendInFlightRef.current = true;
2905 setSendPhase("sending-turn");2970 beginSendPhase("sending-turn");
2906 const finish = () => {2971 const finish = () => {
2907 sendInFlightRef.current = false;2972 sendInFlightRef.current = false;
2908 setSendPhase("idle");2973 resetSendPhase();
2909 };2974 };
29102975
2911 await api.orchestration2976 await api.orchestration
...@@ -2978,10 +3043,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2978,10 +3043,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2978 activeProject,3043 activeProject,
2979 activeProposedPlan,3044 activeProposedPlan,
2980 activeThread,3045 activeThread,
3046 beginSendPhase,
2981 isConnecting,3047 isConnecting,
2982 isSendBusy,3048 isSendBusy,
2983 isServerThread,3049 isServerThread,
2984 navigate,3050 navigate,
3051 resetSendPhase,
2985 runtimeMode,3052 runtimeMode,
2986 selectedModel,3053 selectedModel,
2987 selectedModelOptionsForDispatch,3054 selectedModelOptionsForDispatch,
...@@ -3323,6 +3390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3323,6 +3390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3323 activeThreadId={activeThread.id}3390 activeThreadId={activeThread.id}
3324 activeThreadTitle={activeThread.title}3391 activeThreadTitle={activeThread.title}
3325 activeProjectName={activeProject?.name}3392 activeProjectName={activeProject?.name}
3393 isGitRepo={isGitRepo}
3326 openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null}3394 openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null}
3327 activeProjectScripts={activeProject?.scripts}3395 activeProjectScripts={activeProject?.scripts}
3328 preferredScriptId={3396 preferredScriptId={
...@@ -3366,8 +3434,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3366,8 +3434,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
3366 key={activeThread.id}3434 key={activeThread.id}
3367 hasMessages={timelineEntries.length > 0}3435 hasMessages={timelineEntries.length > 0}
3368 isWorking={isWorking}3436 isWorking={isWorking}
3369 activeTurnInProgress={!latestTurnSettled}3437 activeTurnInProgress={isWorking || !latestTurnSettled}
3370 activeTurnStartedAt={activeLatestTurn?.startedAt ?? null}3438 activeTurnStartedAt={activeWorkStartedAt}
3371 scrollContainer={messagesScrollElement}3439 scrollContainer={messagesScrollElement}
3372 timelineEntries={timelineEntries}3440 timelineEntries={timelineEntries}
3373 completionDividerBeforeEntryId={completionDividerBeforeEntryId}3441 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
...@@ -3903,6 +3971,7 @@ interface ChatHeaderProps {...@@ -3903,6 +3971,7 @@ interface ChatHeaderProps {
3903 activeThreadId: ThreadId;3971 activeThreadId: ThreadId;
3904 activeThreadTitle: string;3972 activeThreadTitle: string;
3905 activeProjectName: string | undefined;3973 activeProjectName: string | undefined;
3974 isGitRepo: boolean;
3906 openInCwd: string | null;3975 openInCwd: string | null;
3907 activeProjectScripts: ProjectScript[] | undefined;3976 activeProjectScripts: ProjectScript[] | undefined;
3908 preferredScriptId: string | null;3977 preferredScriptId: string | null;
...@@ -3921,6 +3990,7 @@ const ChatHeader = memo(function ChatHeader({...@@ -3921,6 +3990,7 @@ const ChatHeader = memo(function ChatHeader({
3921 activeThreadId,3990 activeThreadId,
3922 activeThreadTitle,3991 activeThreadTitle,
3923 activeProjectName,3992 activeProjectName,
3993 isGitRepo,
3924 openInCwd,3994 openInCwd,
3925 activeProjectScripts,3995 activeProjectScripts,
3926 preferredScriptId,3996 preferredScriptId,
...@@ -3949,6 +4019,11 @@ const ChatHeader = memo(function ChatHeader({...@@ -3949,6 +4019,11 @@ const ChatHeader = memo(function ChatHeader({
3949 {activeProjectName}4019 {activeProjectName}
3950 </Badge>4020 </Badge>
3951 )}4021 )}
4022 {activeProjectName && !isGitRepo && (
4023 <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700">
4024 No Git
4025 </Badge>
4026 )}
3952 </div>4027 </div>
3953 <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3">4028 <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3">
3954 {activeProjectScripts && (4029 {activeProjectScripts && (
...@@ -3979,15 +4054,18 @@ const ChatHeader = memo(function ChatHeader({...@@ -3979,15 +4054,18 @@ const ChatHeader = memo(function ChatHeader({
3979 aria-label="Toggle diff panel"4054 aria-label="Toggle diff panel"
3980 variant="outline"4055 variant="outline"
3981 size="xs"4056 size="xs"
4057 disabled={!isGitRepo}
3982 >4058 >
3983 <DiffIcon className="size-3" />4059 <DiffIcon className="size-3" />
3984 </Toggle>4060 </Toggle>
3985 }4061 }
3986 />4062 />
3987 <TooltipPopup side="bottom">4063 <TooltipPopup side="bottom">
3988 {diffToggleShortcutLabel4064 {!isGitRepo
3989 ? `Toggle diff panel (${diffToggleShortcutLabel})`4065 ? "Diff panel is unavailable because this project is not a git repository."
3990 : "Toggle diff panel"}4066 : diffToggleShortcutLabel
4067 ? `Toggle diff panel (${diffToggleShortcutLabel})`
4068 : "Toggle diff panel"}
3991 </TooltipPopup>4069 </TooltipPopup>
3992 </Tooltip>4070 </Tooltip>
3993 </div>4071 </div>
...@@ -4919,23 +4997,43 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -4919,23 +4997,43 @@ const MessagesTimeline = memo(function MessagesTimeline({
4919 {visibleEntries.map((workEntry) => (4997 {visibleEntries.map((workEntry) => (
4920 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">4998 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
4921 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />4999 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
4922 <p5000 <div className="min-w-0 flex-1 py-[2px]">
4923 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}5001 <p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}>
4924 >5002 {workEntry.label}
4925 {workEntry.detail ? (5003 </p>
4926 <>5004 {workEntry.command && (
4927 {workEntry.label}5005 <pre className="mt-1 overflow-x-auto rounded-md border border-border/70 bg-background/80 px-2 py-1 font-mono text-[11px] leading-relaxed text-foreground/80">
4928 <span5006 {workEntry.command}
4929 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"5007 </pre>
5008 )}
5009 {workEntry.changedFiles && workEntry.changedFiles.length > 0 && (
5010 <div className="mt-1 flex flex-wrap gap-1">
5011 {workEntry.changedFiles.slice(0, 6).map((filePath) => (
5012 <span
5013 key={`${workEntry.id}:${filePath}`}
5014 className="rounded-md border border-border/70 bg-background/65 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground/85"
5015 title={filePath}
5016 >
5017 {filePath}
5018 </span>
5019 ))}
5020 {workEntry.changedFiles.length > 6 && (
5021 <span className="px-1 text-[10px] text-muted-foreground/65">
5022 +{workEntry.changedFiles.length - 6} more
5023 </span>
5024 )}
5025 </div>
5026 )}
5027 {workEntry.detail &&
5028 (!workEntry.command || workEntry.detail !== workEntry.command) && (
5029 <p
5030 className="mt-1 text-[11px] leading-relaxed text-muted-foreground/75"
4930 title={workEntry.detail}5031 title={workEntry.detail}
4931 >5032 >
4932 {workEntry.detail}5033 {workEntry.detail}
4933 </span>5034 </p>
4934 </>5035 )}
4935 ) : (5036 </div>
4936 workEntry.label
4937 )}
4938 </p>
4939 </div>5037 </div>
4940 ))}5038 ))}
4941 </div>5039 </div>
...@@ -5121,12 +5219,17 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -5121,12 +5219,17 @@ const MessagesTimeline = memo(function MessagesTimeline({
5121 {row.kind === "working" && (5219 {row.kind === "working" && (
5122 <div className="flex items-center gap-2 py-0.5 pl-1.5">5220 <div className="flex items-center gap-2 py-0.5 pl-1.5">
5123 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />5221 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
5124 <div className="flex items-center pt-1">5222 <div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/70">
5125 <span className="inline-flex items-center gap-[3px]">5223 <span className="inline-flex items-center gap-[3px]">
5126 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />5224 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
5127 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />5225 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
5128 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />5226 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
5129 </span>5227 </span>
5228 <span>
5229 {row.createdAt
5230 ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}`
5231 : "Working..."}
5232 </span>
5130 </div>5233 </div>
5131 </div>5234 </div>
5132 )}5235 )}
apps/web/src/components/DiffPanel.tsx+8
...@@ -5,6 +5,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router";...@@ -5,6 +5,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { ThreadId, type TurnId } from "@t3tools/contracts";5import { ThreadId, type TurnId } from "@t3tools/contracts";
6import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";6import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
7import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";7import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
8import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";9import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
9import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
10import { readNativeApi } from "../nativeApi";11import { readNativeApi } from "../nativeApi";
...@@ -175,6 +176,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -175,6 +176,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
175 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,176 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
176 );177 );
177 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;178 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
179 const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));
180 const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;
178 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =181 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
179 useTurnDiffSummaries(activeThread);182 useTurnDiffSummaries(activeThread);
180 const orderedTurnDiffSummaries = useMemo(183 const orderedTurnDiffSummaries = useMemo(
...@@ -250,6 +253,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -250,6 +253,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
250 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,253 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
251 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,254 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
252 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,255 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
256 enabled: isGitRepo,
253 }),257 }),
254 );258 );
255 const selectedTurnCheckpointDiff = selectedTurn259 const selectedTurnCheckpointDiff = selectedTurn
...@@ -530,6 +534,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -530,6 +534,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
530 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">534 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
531 Select a thread to inspect turn diffs.535 Select a thread to inspect turn diffs.
532 </div>536 </div>
537 ) : !isGitRepo ? (
538 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
539 Turn diffs are unavailable because this project is not a git repository.
540 </div>
533 ) : orderedTurnDiffSummaries.length === 0 ? (541 ) : orderedTurnDiffSummaries.length === 0 ? (
534 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">542 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
535 No completed turns yet.543 No completed turns yet.
apps/web/src/components/Sidebar.tsx+12-6
...@@ -499,8 +499,8 @@ export default function Sidebar() {...@@ -499,8 +499,8 @@ export default function Sidebar() {
499 const projectId = newProjectId();499 const projectId = newProjectId();
500 const createdAt = new Date().toISOString();500 const createdAt = new Date().toISOString();
501 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;501 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
502 const projectCreated = await api.orchestration502 try {
503 .dispatchCommand({503 await api.orchestration.dispatchCommand({
504 type: "project.create",504 type: "project.create",
505 commandId: newCommandId(),505 commandId: newCommandId(),
506 projectId,506 projectId,
...@@ -508,11 +508,17 @@ export default function Sidebar() {...@@ -508,11 +508,17 @@ export default function Sidebar() {
508 workspaceRoot: cwd,508 workspaceRoot: cwd,
509 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,509 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
510 createdAt,510 createdAt,
511 })511 });
512 .then(() => true)
513 .catch(() => false);
514 if (projectCreated) {
515 await handleNewThread(projectId).catch(() => undefined);512 await handleNewThread(projectId).catch(() => undefined);
513 } catch (error) {
514 setIsAddingProject(false);
515 toastManager.add({
516 type: "error",
517 title: "Unable to add project",
518 description:
519 error instanceof Error ? error.message : "An error occurred while adding the project.",
520 });
521 return;
516 }522 }
517 finishAddingProject();523 finishAddingProject();
518 },524 },
apps/web/src/lib/providerReactQuery.ts+36-4
...@@ -12,6 +12,7 @@ interface CheckpointDiffQueryInput {...@@ -12,6 +12,7 @@ interface CheckpointDiffQueryInput {
12 fromTurnCount: number | null;12 fromTurnCount: number | null;
13 toTurnCount: number | null;13 toTurnCount: number | null;
14 cacheScope?: string | null;14 cacheScope?: string | null;
15 enabled?: boolean;
15}16}
1617
17export const providerQueryKeys = {18export const providerQueryKeys = {
...@@ -52,6 +53,33 @@ function asCheckpointErrorMessage(error: unknown): string {...@@ -52,6 +53,33 @@ function asCheckpointErrorMessage(error: unknown): string {
52 return "";53 return "";
53}54}
5455
56function normalizeCheckpointErrorMessage(error: unknown): string {
57 const message = asCheckpointErrorMessage(error).trim();
58 if (message.length === 0) {
59 return "Failed to load checkpoint diff.";
60 }
61
62 const lower = message.toLowerCase();
63 if (lower.includes("not a git repository")) {
64 return "Turn diffs are unavailable because this project is not a git repository.";
65 }
66
67 if (
68 lower.includes("checkpoint unavailable for thread") ||
69 lower.includes("checkpoint invariant violation")
70 ) {
71 const separatorIndex = message.indexOf(":");
72 if (separatorIndex >= 0) {
73 const detail = message.slice(separatorIndex + 1).trim();
74 if (detail.length > 0) {
75 return detail;
76 }
77 }
78 }
79
80 return message;
81}
82
55function isCheckpointTemporarilyUnavailable(error: unknown): boolean {83function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
56 const message = asCheckpointErrorMessage(error).toLowerCase();84 const message = asCheckpointErrorMessage(error).toLowerCase();
57 return (85 return (
...@@ -71,12 +99,16 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {...@@ -71,12 +99,16 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
71 if (!input.threadId || decodedRequest._tag === "None") {99 if (!input.threadId || decodedRequest._tag === "None") {
72 throw new Error("Checkpoint diff is unavailable.");100 throw new Error("Checkpoint diff is unavailable.");
73 }101 }
74 if (decodedRequest.value.kind === "fullThreadDiff") {102 try {
75 return api.orchestration.getFullThreadDiff(decodedRequest.value.input);103 if (decodedRequest.value.kind === "fullThreadDiff") {
104 return await api.orchestration.getFullThreadDiff(decodedRequest.value.input);
105 }
106 return await api.orchestration.getTurnDiff(decodedRequest.value.input);
107 } catch (error) {
108 throw new Error(normalizeCheckpointErrorMessage(error), { cause: error });
76 }109 }
77 return api.orchestration.getTurnDiff(decodedRequest.value.input);
78 },110 },
79 enabled: !!input.threadId && decodedRequest._tag === "Some",111 enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some",
80 staleTime: Infinity,112 staleTime: Infinity,
81 retry: (failureCount, error) => {113 retry: (failureCount, error) => {
82 if (isCheckpointTemporarilyUnavailable(error)) {114 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/session-logic.test.ts+97
...@@ -2,6 +2,7 @@ import { EventId, MessageId, TurnId, type OrchestrationThreadActivity } from "@t...@@ -2,6 +2,7 @@ import { EventId, MessageId, TurnId, type OrchestrationThreadActivity } from "@t
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import {4import {
5 deriveActiveWorkStartedAt,
5 deriveActivePlanState,6 deriveActivePlanState,
6 PROVIDER_OPTIONS,7 PROVIDER_OPTIONS,
7 derivePendingApprovals,8 derivePendingApprovals,
...@@ -430,6 +431,54 @@ describe("deriveWorkLogEntries", () => {...@@ -430,6 +431,54 @@ describe("deriveWorkLogEntries", () => {
430 const entries = deriveWorkLogEntries(activities, undefined);431 const entries = deriveWorkLogEntries(activities, undefined);
431 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);432 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);
432 });433 });
434
435 it("extracts command text for command tool activities", () => {
436 const activities: OrchestrationThreadActivity[] = [
437 makeActivity({
438 id: "command-tool",
439 kind: "tool.completed",
440 summary: "Command run complete",
441 payload: {
442 itemType: "command_execution",
443 data: {
444 item: {
445 command: ["bun", "run", "lint"],
446 },
447 },
448 },
449 }),
450 ];
451
452 const [entry] = deriveWorkLogEntries(activities, undefined);
453 expect(entry?.command).toBe("bun run lint");
454 });
455
456 it("extracts changed file paths for file-change tool activities", () => {
457 const activities: OrchestrationThreadActivity[] = [
458 makeActivity({
459 id: "file-tool",
460 kind: "tool.completed",
461 summary: "File change complete",
462 payload: {
463 itemType: "file_change",
464 data: {
465 item: {
466 changes: [
467 { path: "apps/web/src/components/ChatView.tsx" },
468 { filename: "apps/web/src/session-logic.ts" },
469 ],
470 },
471 },
472 },
473 }),
474 ];
475
476 const [entry] = deriveWorkLogEntries(activities, undefined);
477 expect(entry?.changedFiles).toEqual([
478 "apps/web/src/components/ChatView.tsx",
479 "apps/web/src/session-logic.ts",
480 ]);
481 });
433});482});
434483
435describe("deriveTimelineEntries", () => {484describe("deriveTimelineEntries", () => {
...@@ -542,6 +591,54 @@ describe("isLatestTurnSettled", () => {...@@ -542,6 +591,54 @@ describe("isLatestTurnSettled", () => {
542 });591 });
543});592});
544593
594describe("deriveActiveWorkStartedAt", () => {
595 const latestTurn = {
596 turnId: TurnId.makeUnsafe("turn-1"),
597 startedAt: "2026-02-27T21:10:00.000Z",
598 completedAt: "2026-02-27T21:10:06.000Z",
599 } as const;
600
601 it("prefers the in-flight turn start when the latest turn is not settled", () => {
602 expect(
603 deriveActiveWorkStartedAt(
604 latestTurn,
605 {
606 orchestrationStatus: "running",
607 activeTurnId: TurnId.makeUnsafe("turn-1"),
608 },
609 "2026-02-27T21:11:00.000Z",
610 ),
611 ).toBe("2026-02-27T21:10:00.000Z");
612 });
613
614 it("falls back to sendStartedAt once the latest turn is settled", () => {
615 expect(
616 deriveActiveWorkStartedAt(
617 latestTurn,
618 {
619 orchestrationStatus: "ready",
620 activeTurnId: undefined,
621 },
622 "2026-02-27T21:11:00.000Z",
623 ),
624 ).toBe("2026-02-27T21:11:00.000Z");
625 });
626
627 it("uses sendStartedAt for a fresh send after the prior turn completed", () => {
628 expect(
629 deriveActiveWorkStartedAt(
630 {
631 turnId: TurnId.makeUnsafe("turn-1"),
632 startedAt: "2026-02-27T21:10:00.000Z",
633 completedAt: "2026-02-27T21:10:06.000Z",
634 },
635 null,
636 "2026-02-27T21:11:00.000Z",
637 ),
638 ).toBe("2026-02-27T21:11:00.000Z");
639 });
640});
641
545describe("PROVIDER_OPTIONS", () => {642describe("PROVIDER_OPTIONS", () => {
546 it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => {643 it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => {
547 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode");644 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode");
apps/web/src/session-logic.ts+135-2
...@@ -27,6 +27,8 @@ export interface WorkLogEntry {...@@ -27,6 +27,8 @@ export interface WorkLogEntry {
27 createdAt: string;27 createdAt: string;
28 label: string;28 label: string;
29 detail?: string;29 detail?: string;
30 command?: string;
31 changedFiles?: ReadonlyArray<string>;
30 tone: "thinking" | "tool" | "info" | "error";32 tone: "thinking" | "tool" | "info" | "error";
31}33}
3234
...@@ -111,9 +113,12 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str...@@ -111,9 +113,12 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
111 return formatDuration(endedAt - startedAt);113 return formatDuration(endedAt - startedAt);
112}114}
113115
116type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
117type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">;
118
114export function isLatestTurnSettled(119export function isLatestTurnSettled(
115 latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt"> | null,120 latestTurn: LatestTurnTiming | null,
116 session: Pick<ThreadSession, "orchestrationStatus" | "activeTurnId"> | null,121 session: SessionActivityState | null,
117): boolean {122): boolean {
118 if (!latestTurn?.startedAt) return false;123 if (!latestTurn?.startedAt) return false;
119 if (!latestTurn.completedAt) return false;124 if (!latestTurn.completedAt) return false;
...@@ -122,6 +127,17 @@ export function isLatestTurnSettled(...@@ -122,6 +127,17 @@ export function isLatestTurnSettled(
122 return true;127 return true;
123}128}
124129
130export function deriveActiveWorkStartedAt(
131 latestTurn: LatestTurnTiming | null,
132 session: SessionActivityState | null,
133 sendStartedAt: string | null,
134): string | null {
135 if (!isLatestTurnSettled(latestTurn, session)) {
136 return latestTurn?.startedAt ?? sendStartedAt;
137 }
138 return sendStartedAt;
139}
140
125function requestKindFromRequestType(141function requestKindFromRequestType(
126 requestType: unknown,142 requestType: unknown,
127): PendingApproval["requestKind"] | null {143): PendingApproval["requestKind"] | null {
...@@ -401,6 +417,8 @@ export function deriveWorkLogEntries(...@@ -401,6 +417,8 @@ export function deriveWorkLogEntries(
401 activity.payload && typeof activity.payload === "object"417 activity.payload && typeof activity.payload === "object"
402 ? (activity.payload as Record<string, unknown>)418 ? (activity.payload as Record<string, unknown>)
403 : null;419 : null;
420 const command = extractToolCommand(payload);
421 const changedFiles = extractChangedFiles(payload);
404 const entry: WorkLogEntry = {422 const entry: WorkLogEntry = {
405 id: activity.id,423 id: activity.id,
406 createdAt: activity.createdAt,424 createdAt: activity.createdAt,
...@@ -410,10 +428,125 @@ export function deriveWorkLogEntries(...@@ -410,10 +428,125 @@ export function deriveWorkLogEntries(
410 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {428 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
411 entry.detail = payload.detail;429 entry.detail = payload.detail;
412 }430 }
431 if (command) {
432 entry.command = command;
433 }
434 if (changedFiles.length > 0) {
435 entry.changedFiles = changedFiles;
436 }
413 return entry;437 return entry;
414 });438 });
415}439}
416440
441function asRecord(value: unknown): Record<string, unknown> | null {
442 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
443}
444
445function asTrimmedString(value: unknown): string | null {
446 if (typeof value !== "string") {
447 return null;
448 }
449 const trimmed = value.trim();
450 return trimmed.length > 0 ? trimmed : null;
451}
452
453function normalizeCommandValue(value: unknown): string | null {
454 const direct = asTrimmedString(value);
455 if (direct) {
456 return direct;
457 }
458 if (!Array.isArray(value)) {
459 return null;
460 }
461 const parts = value
462 .map((entry) => asTrimmedString(entry))
463 .filter((entry): entry is string => entry !== null);
464 return parts.length > 0 ? parts.join(" ") : null;
465}
466
467function extractToolCommand(payload: Record<string, unknown> | null): string | null {
468 const data = asRecord(payload?.data);
469 const item = asRecord(data?.item);
470 const itemResult = asRecord(item?.result);
471 const itemInput = asRecord(item?.input);
472 const candidates = [
473 normalizeCommandValue(item?.command),
474 normalizeCommandValue(itemInput?.command),
475 normalizeCommandValue(itemResult?.command),
476 normalizeCommandValue(data?.command),
477 ];
478 return candidates.find((candidate) => candidate !== null) ?? null;
479}
480
481function pushChangedFile(target: string[], seen: Set<string>, value: unknown) {
482 const normalized = asTrimmedString(value);
483 if (!normalized || seen.has(normalized)) {
484 return;
485 }
486 seen.add(normalized);
487 target.push(normalized);
488}
489
490function collectChangedFiles(
491 value: unknown,
492 target: string[],
493 seen: Set<string>,
494 depth: number,
495) {
496 if (depth > 4 || target.length >= 12) {
497 return;
498 }
499 if (Array.isArray(value)) {
500 for (const entry of value) {
501 collectChangedFiles(entry, target, seen, depth + 1);
502 if (target.length >= 12) {
503 return;
504 }
505 }
506 return;
507 }
508
509 const record = asRecord(value);
510 if (!record) {
511 return;
512 }
513
514 pushChangedFile(target, seen, record.path);
515 pushChangedFile(target, seen, record.filePath);
516 pushChangedFile(target, seen, record.relativePath);
517 pushChangedFile(target, seen, record.filename);
518 pushChangedFile(target, seen, record.newPath);
519 pushChangedFile(target, seen, record.oldPath);
520
521 for (const nestedKey of [
522 "item",
523 "result",
524 "input",
525 "data",
526 "changes",
527 "files",
528 "edits",
529 "patch",
530 "patches",
531 "operations",
532 ]) {
533 if (!(nestedKey in record)) {
534 continue;
535 }
536 collectChangedFiles(record[nestedKey], target, seen, depth + 1);
537 if (target.length >= 12) {
538 return;
539 }
540 }
541}
542
543function extractChangedFiles(payload: Record<string, unknown> | null): string[] {
544 const data = asRecord(payload?.data);
545 const changedFiles: string[] = [];
546 collectChangedFiles(data, changedFiles, new Set<string>(), 0);
547 return changedFiles;
548}
549
417function compareActivitiesByOrder(550function compareActivitiesByOrder(
418 left: OrchestrationThreadActivity,551 left: OrchestrationThreadActivity,
419 right: OrchestrationThreadActivity,552 right: OrchestrationThreadActivity,