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 @@
22
33## Small things
44
5- [ ] Plan mode
65- [ ] Submitting new messages should scroll to bottom
7- [ ] 1 click copy in codeblocks
8- [x] Pasting/adding images to submit with prompts
96- [ ] Only show last 10 threads for a given project
107- [ ] Thread archiving
11- [x] Selecting folder should instantly create project
128- [ ] New projects should go on top
139- [ ] 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
2211## Bigger things
2312
2413- [ ] 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
2020import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2121import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2222import { OrchestrationDispatchError } from "../Errors.ts";
23import { isGitRepository } from "../../git/isRepo.ts";
2324
2425type ReactorInput =
2526 | {
......@@ -144,6 +145,8 @@ const make = Effect.gen(function* () {
144145 return Option.none();
145146 });
146147
148 const isGitWorkspace = (cwd: string) => isGitRepository(cwd);
149
147150 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
148151 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
149152 ) {
......@@ -184,6 +187,14 @@ const make = Effect.gen(function* () {
184187 });
185188 return;
186189 }
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
188199 const currentTurnCount = thread.checkpoints.reduce(
189200 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
......@@ -319,6 +330,9 @@ const make = Effect.gen(function* () {
319330 });
320331 return;
321332 }
333 if (!isGitWorkspace(checkpointCwd)) {
334 return;
335 }
322336
323337 const currentTurnCount = thread.checkpoints.reduce(
324338 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
......@@ -378,6 +392,9 @@ const make = Effect.gen(function* () {
378392 });
379393 return;
380394 }
395 if (!isGitWorkspace(checkpointCwd)) {
396 return;
397 }
381398
382399 const currentTurnCount = thread.checkpoints.reduce(
383400 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
......@@ -425,6 +442,15 @@ const make = Effect.gen(function* () {
425442 }).pipe(Effect.catch(() => Effect.void));
426443 return;
427444 }
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
429455 const currentTurnCount = thread.checkpoints.reduce(
430456 (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount),
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+17-1
......@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
15import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts";
26import {
37 ApprovalRequestId,
......@@ -112,6 +116,13 @@ describe("ProviderRuntimeIngestion", () => {
112116 unknown
113117 > | null = null;
114118 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
116127 afterEach(async () => {
117128 if (scope) {
......@@ -122,9 +133,14 @@ describe("ProviderRuntimeIngestion", () => {
122133 await runtime.dispose();
123134 }
124135 runtime = null;
136 for (const dir of tempDirs.splice(0)) {
137 fs.rmSync(dir, { recursive: true, force: true });
138 }
125139 });
126140
127141 async function createHarness() {
142 const workspaceRoot = makeTempDir("t3-provider-project-");
143 fs.mkdirSync(path.join(workspaceRoot, ".git"));
128144 const provider = createProviderServiceHarness();
129145 const orchestrationLayer = OrchestrationEngineLive.pipe(
130146 Layer.provide(OrchestrationProjectionPipelineLive),
......@@ -152,7 +168,7 @@ describe("ProviderRuntimeIngestion", () => {
152168 commandId: CommandId.makeUnsafe("cmd-provider-project-create"),
153169 projectId: asProjectId("project-1"),
154170 title: "Provider Project",
155 workspaceRoot: "/tmp/provider-project",
171 workspaceRoot,
156172 defaultModel: "gpt-5-codex",
157173 createdAt,
158174 }),
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+19-1
......@@ -13,6 +13,8 @@ import {
1313import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect";
1414
1515import { ProviderService } from "../../provider/Services/ProviderService.ts";
16import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
17import { isGitRepository } from "../../git/isRepo.ts";
1618import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
1719import {
1820 ProviderRuntimeIngestionService,
......@@ -507,6 +509,22 @@ const make = Effect.gen(function* () {
507509 lookup: () => Effect.succeed({ text: "", createdAt: "" }),
508510 });
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
510528 const rememberAssistantMessageId = (
511529 threadId: ThreadId,
512530 turnId: TurnId,
......@@ -1041,7 +1059,7 @@ const make = Effect.gen(function* () {
10411059
10421060 if (event.type === "turn.diff.updated") {
10431061 const turnId = toTurnId(event.turnId);
1044 if (turnId) {
1062 if (turnId && (yield* isGitRepoForThread(thread.id))) {
10451063 const assistantMessageId = MessageId.makeUnsafe(
10461064 `assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
10471065 );
apps/server/src/os-jank.ts+3-3
......@@ -20,12 +20,12 @@ export function fixPath(): void {
2020}
2121
2222export const expandHomePath = Effect.fn(function* (input: string) {
23 const { join, sep } = yield* Path.Path;
23 const { join } = yield* Path.Path;
2424 if (input === "~") {
2525 return OS.homedir();
2626 }
27 if (input.startsWith(`~${sep}`)) {
28 return join(OS.homedir(), input.slice(sep.length));
27 if (input.startsWith("~/") || input.startsWith("~\\")) {
28 return join(OS.homedir(), input.slice(2));
2929 }
3030 return input;
3131});
apps/server/src/wsServer.test.ts+4-2
......@@ -1108,13 +1108,14 @@ describe("WebSocket Server", () => {
11081108 connections.push(ws);
11091109 await waitForMessage(ws);
11101110
1111 const workspaceRoot = makeTempDir("t3code-ws-diff-project-");
11111112 const createdAt = new Date().toISOString();
11121113 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
11131114 type: "project.create",
11141115 commandId: "cmd-diff-project-create",
11151116 projectId: "project-diff",
11161117 title: "Diff Project",
1117 workspaceRoot: "/tmp/ws-diff-project",
1118 workspaceRoot,
11181119 defaultModel: "gpt-5-codex",
11191120 createdAt,
11201121 });
......@@ -1186,13 +1187,14 @@ describe("WebSocket Server", () => {
11861187 connections.push(ws);
11871188 await waitForMessage(ws);
11881189
1190 const workspaceRoot = makeTempDir("t3code-ws-project-");
11891191 const createdAt = new Date().toISOString();
11901192 const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
11911193 type: "project.create",
11921194 commandId: "cmd-ws-project-create",
11931195 projectId: "project-1",
11941196 title: "WS Project",
1195 workspaceRoot: "/tmp/ws-project",
1197 workspaceRoot,
11961198 defaultModel: "gpt-5-codex",
11971199 createdAt,
11981200 });
apps/server/src/wsServer.ts+49-3
......@@ -72,6 +72,7 @@ import {
7272} from "./attachmentStore.ts";
7373import { parseBase64DataUrl } from "./imageMime.ts";
7474import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts";
75import { expandHomePath } from "./os-jank.ts";
7576
7677/**
7778 * ServerShape - Service API for server lifecycle control.
......@@ -194,6 +195,13 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {
194195 return Struct.omit(body, ["_tag"]);
195196}
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
197205export type ServerCoreRuntimeServices =
198206 | OrchestrationEngineService
199207 | ProjectionSnapshotQuery
......@@ -298,6 +306,41 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
298306 const normalizeDispatchCommand = Effect.fnUntraced(function* (input: {
299307 readonly command: ClientOrchestrationCommand;
300308 }) {
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
301344 if (input.command.type !== "thread.turn.start") {
302345 return input.command as OrchestrationCommand;
303346 }
......@@ -658,7 +701,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
658701 );
659702 }
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
663709 const unsubscribeTerminalEvents = yield* terminalManager.subscribe(
664710 (event) => void Effect.runPromise(onTerminalEvent(event)),
......@@ -873,7 +919,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
873919 if (request._tag === "Failure") {
874920 const errorResponse = yield* encodeResponse({
875921 id: "unknown",
876 error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` },
922 error: { message: `Invalid request format: ${messageFromCause(request.cause)}` },
877923 });
878924 ws.send(errorResponse);
879925 return;
......@@ -883,7 +929,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
883929 if (result._tag === "Failure") {
884930 const errorResponse = yield* encodeResponse({
885931 id: request.value.id,
886 error: { message: Cause.pretty(result.cause) },
932 error: { message: messageFromCause(result.cause) },
887933 });
888934 ws.send(errorResponse);
889935 return;
apps/web/src/components/ChatView.tsx+132-29
......@@ -67,6 +67,7 @@ import {
6767 derivePendingUserInputs,
6868 derivePhase,
6969 deriveTimelineEntries,
70 deriveActiveWorkStartedAt,
7071 deriveActivePlanState,
7172 findLatestProposedPlan,
7273 type PendingApproval,
......@@ -223,6 +224,29 @@ function formatMessageMeta(createdAt: string, duration: string | null): string {
223224 return `${formatTimestamp(createdAt)} • ${duration}`;
224225}
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
226250const LAST_EDITOR_KEY = "t3code:last-editor";
227251const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
228252const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
......@@ -619,6 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
619643 Record<ThreadId, string | null>
620644 >({});
621645 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
646 const [sendStartedAt, setSendStartedAt] = useState<string | null>(null);
622647 const [isConnecting, _setIsConnecting] = useState(false);
623648 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
624649 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
......@@ -838,6 +863,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
838863 const isPreparingWorktree = sendPhase === "preparing-worktree";
839864 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
840865 const nowIso = new Date(nowTick).toISOString();
866 const activeWorkStartedAt = deriveActiveWorkStartedAt(
867 activeLatestTurn,
868 activeThread?.session ?? null,
869 sendStartedAt,
870 );
841871 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
842872 const workLogEntries = useMemo(
843873 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
......@@ -1943,6 +1973,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
19431973 return [];
19441974 });
19451975 setSendPhase("idle");
1976 setSendStartedAt(null);
19461977 setComposerHighlightedItemId(null);
19471978 setComposerCursor(promptRef.current.length);
19481979 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
......@@ -2082,6 +2113,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
20822113 };
20832114 }, [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
20852147 useEffect(() => {
20862148 if (!activeThreadId) return;
20872149 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
......@@ -2391,7 +2453,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
23912453 }
23922454
23932455 sendInFlightRef.current = true;
2394 setSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");
2456 beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn");
23952457
23962458 const composerImagesSnapshot = [...composerImages];
23972459 const messageIdForSend = newMessageId();
......@@ -2442,7 +2504,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24422504 await (async () => {
24432505 // On first message: lock in branch + create worktree if needed.
24442506 if (baseBranchForWorktree) {
2445 setSendPhase("preparing-worktree");
2507 beginSendPhase("preparing-worktree");
24462508 const newBranch = buildTemporaryWorktreeBranchName();
24472509 const result = await createWorktreeMutation.mutateAsync({
24482510 cwd: activeProject.cwd,
......@@ -2547,7 +2609,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25472609 });
25482610 }
25492611
2550 setSendPhase("sending-turn");
2612 beginSendPhase("sending-turn");
25512613 const turnAttachments = await turnAttachmentsPromise;
25522614 await api.orchestration.dispatchCommand({
25532615 type: "thread.turn.start",
......@@ -2609,7 +2671,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
26092671 );
26102672 });
26112673 sendInFlightRef.current = false;
2612 setSendPhase("idle");
2674 if (!turnStartSucceeded) {
2675 resetSendPhase();
2676 }
26132677 };
26142678
26152679 const onInterrupt = async () => {
......@@ -2796,7 +2860,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27962860 const messageCreatedAt = new Date().toISOString();
27972861
27982862 sendInFlightRef.current = true;
2799 setSendPhase("sending-turn");
2863 beginSendPhase("sending-turn");
28002864 setThreadError(threadIdForSend, null);
28012865 setOptimisticUserMessages((existing) => [
28022866 ...existing,
......@@ -2845,7 +2909,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
28452909 createdAt: messageCreatedAt,
28462910 });
28472911 sendInFlightRef.current = false;
2848 setSendPhase("idle");
28492912 } catch (err) {
28502913 setOptimisticUserMessages((existing) =>
28512914 existing.filter((message) => message.id !== messageIdForSend),
......@@ -2855,16 +2918,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
28552918 err instanceof Error ? err.message : "Failed to send plan follow-up.",
28562919 );
28572920 sendInFlightRef.current = false;
2858 setSendPhase("idle");
2921 resetSendPhase();
28592922 }
28602923 },
28612924 [
28622925 activeThread,
2926 beginSendPhase,
28632927 forceStickToBottom,
28642928 isConnecting,
28652929 isSendBusy,
28662930 isServerThread,
28672931 persistThreadSettingsForNextTurn,
2932 resetSendPhase,
28682933 runtimeMode,
28692934 selectedModel,
28702935 selectedModelOptionsForDispatch,
......@@ -2902,10 +2967,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
29022967 DEFAULT_MODEL_BY_PROVIDER.codex;
29032968
29042969 sendInFlightRef.current = true;
2905 setSendPhase("sending-turn");
2970 beginSendPhase("sending-turn");
29062971 const finish = () => {
29072972 sendInFlightRef.current = false;
2908 setSendPhase("idle");
2973 resetSendPhase();
29092974 };
29102975
29112976 await api.orchestration
......@@ -2978,10 +3043,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
29783043 activeProject,
29793044 activeProposedPlan,
29803045 activeThread,
3046 beginSendPhase,
29813047 isConnecting,
29823048 isSendBusy,
29833049 isServerThread,
29843050 navigate,
3051 resetSendPhase,
29853052 runtimeMode,
29863053 selectedModel,
29873054 selectedModelOptionsForDispatch,
......@@ -3323,6 +3390,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
33233390 activeThreadId={activeThread.id}
33243391 activeThreadTitle={activeThread.title}
33253392 activeProjectName={activeProject?.name}
3393 isGitRepo={isGitRepo}
33263394 openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null}
33273395 activeProjectScripts={activeProject?.scripts}
33283396 preferredScriptId={
......@@ -3366,8 +3434,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
33663434 key={activeThread.id}
33673435 hasMessages={timelineEntries.length > 0}
33683436 isWorking={isWorking}
3369 activeTurnInProgress={!latestTurnSettled}
3370 activeTurnStartedAt={activeLatestTurn?.startedAt ?? null}
3437 activeTurnInProgress={isWorking || !latestTurnSettled}
3438 activeTurnStartedAt={activeWorkStartedAt}
33713439 scrollContainer={messagesScrollElement}
33723440 timelineEntries={timelineEntries}
33733441 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
......@@ -3903,6 +3971,7 @@ interface ChatHeaderProps {
39033971 activeThreadId: ThreadId;
39043972 activeThreadTitle: string;
39053973 activeProjectName: string | undefined;
3974 isGitRepo: boolean;
39063975 openInCwd: string | null;
39073976 activeProjectScripts: ProjectScript[] | undefined;
39083977 preferredScriptId: string | null;
......@@ -3921,6 +3990,7 @@ const ChatHeader = memo(function ChatHeader({
39213990 activeThreadId,
39223991 activeThreadTitle,
39233992 activeProjectName,
3993 isGitRepo,
39243994 openInCwd,
39253995 activeProjectScripts,
39263996 preferredScriptId,
......@@ -3949,6 +4019,11 @@ const ChatHeader = memo(function ChatHeader({
39494019 {activeProjectName}
39504020 </Badge>
39514021 )}
4022 {activeProjectName && !isGitRepo && (
4023 <Badge variant="outline" className="shrink-0 text-[10px] text-amber-700">
4024 No Git
4025 </Badge>
4026 )}
39524027 </div>
39534028 <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3">
39544029 {activeProjectScripts && (
......@@ -3979,15 +4054,18 @@ const ChatHeader = memo(function ChatHeader({
39794054 aria-label="Toggle diff panel"
39804055 variant="outline"
39814056 size="xs"
4057 disabled={!isGitRepo}
39824058 >
39834059 <DiffIcon className="size-3" />
39844060 </Toggle>
39854061 }
39864062 />
39874063 <TooltipPopup side="bottom">
3988 {diffToggleShortcutLabel
3989 ? `Toggle diff panel (${diffToggleShortcutLabel})`
3990 : "Toggle diff panel"}
4064 {!isGitRepo
4065 ? "Diff panel is unavailable because this project is not a git repository."
4066 : diffToggleShortcutLabel
4067 ? `Toggle diff panel (${diffToggleShortcutLabel})`
4068 : "Toggle diff panel"}
39914069 </TooltipPopup>
39924070 </Tooltip>
39934071 </div>
......@@ -4919,23 +4997,43 @@ const MessagesTimeline = memo(function MessagesTimeline({
49194997 {visibleEntries.map((workEntry) => (
49204998 <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5">
49214999 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
4922 <p
4923 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
4924 >
4925 {workEntry.detail ? (
4926 <>
4927 {workEntry.label}
4928 <span
4929 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
5000 <div className="min-w-0 flex-1 py-[2px]">
5001 <p className={`text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}>
5002 {workEntry.label}
5003 </p>
5004 {workEntry.command && (
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">
5006 {workEntry.command}
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"
49305031 title={workEntry.detail}
49315032 >
49325033 {workEntry.detail}
4933 </span>
4934 </>
4935 ) : (
4936 workEntry.label
4937 )}
4938 </p>
5034 </p>
5035 )}
5036 </div>
49395037 </div>
49405038 ))}
49415039 </div>
......@@ -5121,12 +5219,17 @@ const MessagesTimeline = memo(function MessagesTimeline({
51215219 {row.kind === "working" && (
51225220 <div className="flex items-center gap-2 py-0.5 pl-1.5">
51235221 <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">
51255223 <span className="inline-flex items-center gap-[3px]">
51265224 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" />
51275225 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" />
51285226 <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" />
51295227 </span>
5228 <span>
5229 {row.createdAt
5230 ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}`
5231 : "Working..."}
5232 </span>
51305233 </div>
51315234 </div>
51325235 )}
apps/web/src/components/DiffPanel.tsx+8
......@@ -5,6 +5,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
55import { ThreadId, type TurnId } from "@t3tools/contracts";
66import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
77import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
89import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
910import { cn } from "~/lib/utils";
1011import { readNativeApi } from "../nativeApi";
......@@ -175,6 +176,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
175176 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
176177 );
177178 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
179 const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null));
180 const isGitRepo = gitBranchesQuery.data?.isRepo ?? true;
178181 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
179182 useTurnDiffSummaries(activeThread);
180183 const orderedTurnDiffSummaries = useMemo(
......@@ -250,6 +253,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
250253 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
251254 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
252255 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
256 enabled: isGitRepo,
253257 }),
254258 );
255259 const selectedTurnCheckpointDiff = selectedTurn
......@@ -530,6 +534,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
530534 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
531535 Select a thread to inspect turn diffs.
532536 </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>
533541 ) : orderedTurnDiffSummaries.length === 0 ? (
534542 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
535543 No completed turns yet.
apps/web/src/components/Sidebar.tsx+12-6
......@@ -499,8 +499,8 @@ export default function Sidebar() {
499499 const projectId = newProjectId();
500500 const createdAt = new Date().toISOString();
501501 const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd;
502 const projectCreated = await api.orchestration
503 .dispatchCommand({
502 try {
503 await api.orchestration.dispatchCommand({
504504 type: "project.create",
505505 commandId: newCommandId(),
506506 projectId,
......@@ -508,11 +508,17 @@ export default function Sidebar() {
508508 workspaceRoot: cwd,
509509 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
510510 createdAt,
511 })
512 .then(() => true)
513 .catch(() => false);
514 if (projectCreated) {
511 });
515512 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;
516522 }
517523 finishAddingProject();
518524 },
apps/web/src/lib/providerReactQuery.ts+36-4
......@@ -12,6 +12,7 @@ interface CheckpointDiffQueryInput {
1212 fromTurnCount: number | null;
1313 toTurnCount: number | null;
1414 cacheScope?: string | null;
15 enabled?: boolean;
1516}
1617
1718export const providerQueryKeys = {
......@@ -52,6 +53,33 @@ function asCheckpointErrorMessage(error: unknown): string {
5253 return "";
5354}
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
5583function isCheckpointTemporarilyUnavailable(error: unknown): boolean {
5684 const message = asCheckpointErrorMessage(error).toLowerCase();
5785 return (
......@@ -71,12 +99,16 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) {
7199 if (!input.threadId || decodedRequest._tag === "None") {
72100 throw new Error("Checkpoint diff is unavailable.");
73101 }
74 if (decodedRequest.value.kind === "fullThreadDiff") {
75 return api.orchestration.getFullThreadDiff(decodedRequest.value.input);
102 try {
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 });
76109 }
77 return api.orchestration.getTurnDiff(decodedRequest.value.input);
78110 },
79 enabled: !!input.threadId && decodedRequest._tag === "Some",
111 enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some",
80112 staleTime: Infinity,
81113 retry: (failureCount, error) => {
82114 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/session-logic.test.ts+97
......@@ -2,6 +2,7 @@ import { EventId, MessageId, TurnId, type OrchestrationThreadActivity } from "@t
22import { describe, expect, it } from "vitest";
33
44import {
5 deriveActiveWorkStartedAt,
56 deriveActivePlanState,
67 PROVIDER_OPTIONS,
78 derivePendingApprovals,
......@@ -430,6 +431,54 @@ describe("deriveWorkLogEntries", () => {
430431 const entries = deriveWorkLogEntries(activities, undefined);
431432 expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]);
432433 });
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 });
433482});
434483
435484describe("deriveTimelineEntries", () => {
......@@ -542,6 +591,54 @@ describe("isLatestTurnSettled", () => {
542591 });
543592});
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
545642describe("PROVIDER_OPTIONS", () => {
546643 it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => {
547644 const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode");
apps/web/src/session-logic.ts+135-2
......@@ -27,6 +27,8 @@ export interface WorkLogEntry {
2727 createdAt: string;
2828 label: string;
2929 detail?: string;
30 command?: string;
31 changedFiles?: ReadonlyArray<string>;
3032 tone: "thinking" | "tool" | "info" | "error";
3133}
3234
......@@ -111,9 +113,12 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
111113 return formatDuration(endedAt - startedAt);
112114}
113115
116type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
117type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">;
118
114119export function isLatestTurnSettled(
115 latestTurn: Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt"> | null,
116 session: Pick<ThreadSession, "orchestrationStatus" | "activeTurnId"> | null,
120 latestTurn: LatestTurnTiming | null,
121 session: SessionActivityState | null,
117122): boolean {
118123 if (!latestTurn?.startedAt) return false;
119124 if (!latestTurn.completedAt) return false;
......@@ -122,6 +127,17 @@ export function isLatestTurnSettled(
122127 return true;
123128}
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
125141function requestKindFromRequestType(
126142 requestType: unknown,
127143): PendingApproval["requestKind"] | null {
......@@ -401,6 +417,8 @@ export function deriveWorkLogEntries(
401417 activity.payload && typeof activity.payload === "object"
402418 ? (activity.payload as Record<string, unknown>)
403419 : null;
420 const command = extractToolCommand(payload);
421 const changedFiles = extractChangedFiles(payload);
404422 const entry: WorkLogEntry = {
405423 id: activity.id,
406424 createdAt: activity.createdAt,
......@@ -410,10 +428,125 @@ export function deriveWorkLogEntries(
410428 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
411429 entry.detail = payload.detail;
412430 }
431 if (command) {
432 entry.command = command;
433 }
434 if (changedFiles.length > 0) {
435 entry.changedFiles = changedFiles;
436 }
413437 return entry;
414438 });
415439}
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
417550function compareActivitiesByOrder(
418551 left: OrchestrationThreadActivity,
419552 right: OrchestrationThreadActivity,