| author | |
| committer | |
| log | 0837c8b639f3fb54c155d824c308c6b03038fa9c |
| tree | e5e43d3852090e20f9f6946ccf0e62a1efc4722d |
| parent | 5a90b6be9c902df934bada91b543865c197cfb2d |
| signature | Signed by PGP key B5690EEEBB952194 |
* 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 creation16 files changed, 544 insertions(+), 86 deletions(-)
.agents/skills/btca-cli/SKILL.md deleted-20| ... | ... | @@ -1,20 +0,0 @@ |
| 1 | --- | |
| 2 | name: btca-cli | |
| 3 | description: 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 | ||
| 6 | btca 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 | ||
| 8 | For example, assuming you have a resource named `codex`, you can ask questions like: | |
| 9 | ||
| 10 | ```bash | |
| 11 | btca ask -r codex -q "What is the return format for Codex App Server responses? Are there any edge cases worth knowing?" | |
| 12 | ``` | |
| 13 | ||
| 14 | Your 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 | ||
| 16 | New resources can be created if the ones you need do not yet exist. | |
| 17 | ||
| 18 | ```bash | |
| 19 | btca add -n svelte-dev https://github.com/sveltejs/svelte.dev | |
| 20 | ``` |
.agents/skills/btca-cli/agents/openai.yaml deleted-3| ... | ... | @@ -1,3 +0,0 @@ |
| 1 | interface: | |
| 2 | display_name: "BTCA CLI" | |
| 3 | short_description: "Help with BTCA CLI setup and usage workflows" |
TODO.md-12| ... | ... | @@ -2,24 +2,12 @@ |
| 2 | 2 | |
| 3 | 3 | ## Small things |
| 4 | 4 | |
| 5 | - [ ] Plan mode | |
| 6 | 5 | - [ ] Submitting new messages should scroll to bottom |
| 7 | - [ ] 1 click copy in codeblocks | |
| 8 | - [x] Pasting/adding images to submit with prompts | |
| 9 | 6 | - [ ] Only show last 10 threads for a given project |
| 10 | 7 | - [ ] Thread archiving |
| 11 | - [x] Selecting folder should instantly create project | |
| 12 | 8 | - [ ] New projects should go on top |
| 13 | 9 | - [ ] 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) | |
| 21 | 10 | |
| 22 | 11 | ## Bigger things |
| 23 | 12 | |
| 24 | 13 | - [ ] 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 @@ |
| 1 | import { existsSync } from "node:fs"; | |
| 2 | import { join } from "node:path"; | |
| 3 | ||
| 4 | export 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 | 20 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 21 | 21 | import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 22 | 22 | import { OrchestrationDispatchError } from "../Errors.ts"; |
| 23 | import { isGitRepository } from "../../git/isRepo.ts"; | |
| 23 | 24 | |
| 24 | 25 | type ReactorInput = |
| 25 | 26 | | { |
| ... | ... | @@ -144,6 +145,8 @@ const make = Effect.gen(function* () { |
| 144 | 145 | return Option.none(); |
| 145 | 146 | }); |
| 146 | 147 | |
| 148 | const isGitWorkspace = (cwd: string) => isGitRepository(cwd); | |
| 149 | ||
| 147 | 150 | const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( |
| 148 | 151 | event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>, |
| 149 | 152 | ) { |
| ... | ... | @@ -184,6 +187,14 @@ const make = Effect.gen(function* () { |
| 184 | 187 | }); |
| 185 | 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 | } | |
| 187 | 198 | |
| 188 | 199 | const currentTurnCount = thread.checkpoints.reduce( |
| 189 | 200 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), |
| ... | ... | @@ -319,6 +330,9 @@ const make = Effect.gen(function* () { |
| 319 | 330 | }); |
| 320 | 331 | return; |
| 321 | 332 | } |
| 333 | if (!isGitWorkspace(checkpointCwd)) { | |
| 334 | return; | |
| 335 | } | |
| 322 | 336 | |
| 323 | 337 | const currentTurnCount = thread.checkpoints.reduce( |
| 324 | 338 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), |
| ... | ... | @@ -378,6 +392,9 @@ const make = Effect.gen(function* () { |
| 378 | 392 | }); |
| 379 | 393 | return; |
| 380 | 394 | } |
| 395 | if (!isGitWorkspace(checkpointCwd)) { | |
| 396 | return; | |
| 397 | } | |
| 381 | 398 | |
| 382 | 399 | const currentTurnCount = thread.checkpoints.reduce( |
| 383 | 400 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), |
| ... | ... | @@ -425,6 +442,15 @@ const make = Effect.gen(function* () { |
| 425 | 442 | }).pipe(Effect.catch(() => Effect.void)); |
| 426 | 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 | } | |
| 428 | 454 | |
| 429 | 455 | const currentTurnCount = thread.checkpoints.reduce( |
| 430 | 456 | (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+17-1| ... | ... | @@ -1,3 +1,7 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 1 | 5 | import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; |
| 2 | 6 | import { |
| 3 | 7 | ApprovalRequestId, |
| ... | ... | @@ -112,6 +116,13 @@ describe("ProviderRuntimeIngestion", () => { |
| 112 | 116 | unknown |
| 113 | 117 | > | null = null; |
| 114 | 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 | } | |
| 115 | 126 | |
| 116 | 127 | afterEach(async () => { |
| 117 | 128 | if (scope) { |
| ... | ... | @@ -122,9 +133,14 @@ describe("ProviderRuntimeIngestion", () => { |
| 122 | 133 | await runtime.dispose(); |
| 123 | 134 | } |
| 124 | 135 | runtime = null; |
| 136 | for (const dir of tempDirs.splice(0)) { | |
| 137 | fs.rmSync(dir, { recursive: true, force: true }); | |
| 138 | } | |
| 125 | 139 | }); |
| 126 | 140 | |
| 127 | 141 | async function createHarness() { |
| 142 | const workspaceRoot = makeTempDir("t3-provider-project-"); | |
| 143 | fs.mkdirSync(path.join(workspaceRoot, ".git")); | |
| 128 | 144 | const provider = createProviderServiceHarness(); |
| 129 | 145 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 130 | 146 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | ... | @@ -152,7 +168,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 152 | 168 | commandId: CommandId.makeUnsafe("cmd-provider-project-create"), |
| 153 | 169 | projectId: asProjectId("project-1"), |
| 154 | 170 | title: "Provider Project", |
| 155 | workspaceRoot: "/tmp/provider-project", | |
| 171 | workspaceRoot, | |
| 156 | 172 | defaultModel: "gpt-5-codex", |
| 157 | 173 | createdAt, |
| 158 | 174 | }), |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+19-1| ... | ... | @@ -13,6 +13,8 @@ import { |
| 13 | 13 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect"; |
| 14 | 14 | |
| 15 | 15 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 16 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | |
| 17 | import { isGitRepository } from "../../git/isRepo.ts"; | |
| 16 | 18 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 17 | 19 | import { |
| 18 | 20 | ProviderRuntimeIngestionService, |
| ... | ... | @@ -507,6 +509,22 @@ const make = Effect.gen(function* () { |
| 507 | 509 | lookup: () => Effect.succeed({ text: "", createdAt: "" }), |
| 508 | 510 | }); |
| 509 | 511 | |
| 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 | 528 | const rememberAssistantMessageId = ( |
| 511 | 529 | threadId: ThreadId, |
| 512 | 530 | turnId: TurnId, |
| ... | ... | @@ -1041,7 +1059,7 @@ const make = Effect.gen(function* () { |
| 1041 | 1059 | |
| 1042 | 1060 | if (event.type === "turn.diff.updated") { |
| 1043 | 1061 | const turnId = toTurnId(event.turnId); |
| 1044 | if (turnId) { | |
| 1062 | if (turnId && (yield* isGitRepoForThread(thread.id))) { | |
| 1045 | 1063 | const assistantMessageId = MessageId.makeUnsafe( |
| 1046 | 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 | 20 | } |
| 21 | 21 | |
| 22 | 22 | export const expandHomePath = Effect.fn(function* (input: string) { |
| 23 | const { join, sep } = yield* Path.Path; | |
| 23 | const { join } = yield* Path.Path; | |
| 24 | 24 | if (input === "~") { |
| 25 | 25 | return OS.homedir(); |
| 26 | 26 | } |
| 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)); | |
| 29 | 29 | } |
| 30 | 30 | return input; |
| 31 | 31 | }); |
apps/server/src/wsServer.test.ts+4-2| ... | ... | @@ -1108,13 +1108,14 @@ describe("WebSocket Server", () => { |
| 1108 | 1108 | connections.push(ws); |
| 1109 | 1109 | await waitForMessage(ws); |
| 1110 | 1110 | |
| 1111 | const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); | |
| 1111 | 1112 | const createdAt = new Date().toISOString(); |
| 1112 | 1113 | const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { |
| 1113 | 1114 | type: "project.create", |
| 1114 | 1115 | commandId: "cmd-diff-project-create", |
| 1115 | 1116 | projectId: "project-diff", |
| 1116 | 1117 | title: "Diff Project", |
| 1117 | workspaceRoot: "/tmp/ws-diff-project", | |
| 1118 | workspaceRoot, | |
| 1118 | 1119 | defaultModel: "gpt-5-codex", |
| 1119 | 1120 | createdAt, |
| 1120 | 1121 | }); |
| ... | ... | @@ -1186,13 +1187,14 @@ describe("WebSocket Server", () => { |
| 1186 | 1187 | connections.push(ws); |
| 1187 | 1188 | await waitForMessage(ws); |
| 1188 | 1189 | |
| 1190 | const workspaceRoot = makeTempDir("t3code-ws-project-"); | |
| 1189 | 1191 | const createdAt = new Date().toISOString(); |
| 1190 | 1192 | const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { |
| 1191 | 1193 | type: "project.create", |
| 1192 | 1194 | commandId: "cmd-ws-project-create", |
| 1193 | 1195 | projectId: "project-1", |
| 1194 | 1196 | title: "WS Project", |
| 1195 | workspaceRoot: "/tmp/ws-project", | |
| 1197 | workspaceRoot, | |
| 1196 | 1198 | defaultModel: "gpt-5-codex", |
| 1197 | 1199 | createdAt, |
| 1198 | 1200 | }); |
apps/server/src/wsServer.ts+49-3| ... | ... | @@ -72,6 +72,7 @@ import { |
| 72 | 72 | } from "./attachmentStore.ts"; |
| 73 | 73 | import { parseBase64DataUrl } from "./imageMime.ts"; |
| 74 | 74 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; |
| 75 | import { expandHomePath } from "./os-jank.ts"; | |
| 75 | 76 | |
| 76 | 77 | /** |
| 77 | 78 | * ServerShape - Service API for server lifecycle control. |
| ... | ... | @@ -194,6 +195,13 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { |
| 194 | 195 | return Struct.omit(body, ["_tag"]); |
| 195 | 196 | } |
| 196 | 197 | |
| 198 | function 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 | ||
| 197 | 205 | export type ServerCoreRuntimeServices = |
| 198 | 206 | | OrchestrationEngineService |
| 199 | 207 | | ProjectionSnapshotQuery |
| ... | ... | @@ -298,6 +306,41 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 298 | 306 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { |
| 299 | 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 | 344 | if (input.command.type !== "thread.turn.start") { |
| 302 | 345 | return input.command as OrchestrationCommand; |
| 303 | 346 | } |
| ... | ... | @@ -658,7 +701,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 658 | 701 | ); |
| 659 | 702 | } |
| 660 | 703 | |
| 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); | |
| 662 | 708 | |
| 663 | 709 | const unsubscribeTerminalEvents = yield* terminalManager.subscribe( |
| 664 | 710 | (event) => void Effect.runPromise(onTerminalEvent(event)), |
| ... | ... | @@ -873,7 +919,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 873 | 919 | if (request._tag === "Failure") { |
| 874 | 920 | const errorResponse = yield* encodeResponse({ |
| 875 | 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 | 924 | ws.send(errorResponse); |
| 879 | 925 | return; |
| ... | ... | @@ -883,7 +929,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 883 | 929 | if (result._tag === "Failure") { |
| 884 | 930 | const errorResponse = yield* encodeResponse({ |
| 885 | 931 | id: request.value.id, |
| 886 | error: { message: Cause.pretty(result.cause) }, | |
| 932 | error: { message: messageFromCause(result.cause) }, | |
| 887 | 933 | }); |
| 888 | 934 | ws.send(errorResponse); |
| 889 | 935 | return; |
apps/web/src/components/ChatView.tsx+132-29| ... | ... | @@ -67,6 +67,7 @@ import { |
| 67 | 67 | derivePendingUserInputs, |
| 68 | 68 | derivePhase, |
| 69 | 69 | deriveTimelineEntries, |
| 70 | deriveActiveWorkStartedAt, | |
| 70 | 71 | deriveActivePlanState, |
| 71 | 72 | findLatestProposedPlan, |
| 72 | 73 | type PendingApproval, |
| ... | ... | @@ -223,6 +224,29 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 223 | 224 | return `${formatTimestamp(createdAt)} • ${duration}`; |
| 224 | 225 | } |
| 225 | 226 | |
| 227 | function 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 | ||
| 226 | 250 | const LAST_EDITOR_KEY = "t3code:last-editor"; |
| 227 | 251 | const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 228 | 252 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; |
| ... | ... | @@ -619,6 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 619 | 643 | Record<ThreadId, string | null> |
| 620 | 644 | >({}); |
| 621 | 645 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 646 | const [sendStartedAt, setSendStartedAt] = useState<string | null>(null); | |
| 622 | 647 | const [isConnecting, _setIsConnecting] = useState(false); |
| 623 | 648 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| 624 | 649 | const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]); |
| ... | ... | @@ -838,6 +863,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 838 | 863 | const isPreparingWorktree = sendPhase === "preparing-worktree"; |
| 839 | 864 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; |
| 840 | 865 | const nowIso = new Date(nowTick).toISOString(); |
| 866 | const activeWorkStartedAt = deriveActiveWorkStartedAt( | |
| 867 | activeLatestTurn, | |
| 868 | activeThread?.session ?? null, | |
| 869 | sendStartedAt, | |
| 870 | ); | |
| 841 | 871 | const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; |
| 842 | 872 | const workLogEntries = useMemo( |
| 843 | 873 | () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| ... | ... | @@ -1943,6 +1973,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1943 | 1973 | return []; |
| 1944 | 1974 | }); |
| 1945 | 1975 | setSendPhase("idle"); |
| 1976 | setSendStartedAt(null); | |
| 1946 | 1977 | setComposerHighlightedItemId(null); |
| 1947 | 1978 | setComposerCursor(promptRef.current.length); |
| 1948 | 1979 | setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); |
| ... | ... | @@ -2082,6 +2113,37 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2082 | 2113 | }; |
| 2083 | 2114 | }, [phase]); |
| 2084 | 2115 | |
| 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 | 2147 | useEffect(() => { |
| 2086 | 2148 | if (!activeThreadId) return; |
| 2087 | 2149 | const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; |
| ... | ... | @@ -2391,7 +2453,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2391 | 2453 | } |
| 2392 | 2454 | |
| 2393 | 2455 | sendInFlightRef.current = true; |
| 2394 | setSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); | |
| 2456 | beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); | |
| 2395 | 2457 | |
| 2396 | 2458 | const composerImagesSnapshot = [...composerImages]; |
| 2397 | 2459 | const messageIdForSend = newMessageId(); |
| ... | ... | @@ -2442,7 +2504,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2442 | 2504 | await (async () => { |
| 2443 | 2505 | // On first message: lock in branch + create worktree if needed. |
| 2444 | 2506 | if (baseBranchForWorktree) { |
| 2445 | setSendPhase("preparing-worktree"); | |
| 2507 | beginSendPhase("preparing-worktree"); | |
| 2446 | 2508 | const newBranch = buildTemporaryWorktreeBranchName(); |
| 2447 | 2509 | const result = await createWorktreeMutation.mutateAsync({ |
| 2448 | 2510 | cwd: activeProject.cwd, |
| ... | ... | @@ -2547,7 +2609,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2547 | 2609 | }); |
| 2548 | 2610 | } |
| 2549 | 2611 | |
| 2550 | setSendPhase("sending-turn"); | |
| 2612 | beginSendPhase("sending-turn"); | |
| 2551 | 2613 | const turnAttachments = await turnAttachmentsPromise; |
| 2552 | 2614 | await api.orchestration.dispatchCommand({ |
| 2553 | 2615 | type: "thread.turn.start", |
| ... | ... | @@ -2609,7 +2671,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2609 | 2671 | ); |
| 2610 | 2672 | }); |
| 2611 | 2673 | sendInFlightRef.current = false; |
| 2612 | setSendPhase("idle"); | |
| 2674 | if (!turnStartSucceeded) { | |
| 2675 | resetSendPhase(); | |
| 2676 | } | |
| 2613 | 2677 | }; |
| 2614 | 2678 | |
| 2615 | 2679 | const onInterrupt = async () => { |
| ... | ... | @@ -2796,7 +2860,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2796 | 2860 | const messageCreatedAt = new Date().toISOString(); |
| 2797 | 2861 | |
| 2798 | 2862 | sendInFlightRef.current = true; |
| 2799 | setSendPhase("sending-turn"); | |
| 2863 | beginSendPhase("sending-turn"); | |
| 2800 | 2864 | setThreadError(threadIdForSend, null); |
| 2801 | 2865 | setOptimisticUserMessages((existing) => [ |
| 2802 | 2866 | ...existing, |
| ... | ... | @@ -2845,7 +2909,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2845 | 2909 | createdAt: messageCreatedAt, |
| 2846 | 2910 | }); |
| 2847 | 2911 | sendInFlightRef.current = false; |
| 2848 | setSendPhase("idle"); | |
| 2849 | 2912 | } catch (err) { |
| 2850 | 2913 | setOptimisticUserMessages((existing) => |
| 2851 | 2914 | existing.filter((message) => message.id !== messageIdForSend), |
| ... | ... | @@ -2855,16 +2918,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2855 | 2918 | err instanceof Error ? err.message : "Failed to send plan follow-up.", |
| 2856 | 2919 | ); |
| 2857 | 2920 | sendInFlightRef.current = false; |
| 2858 | setSendPhase("idle"); | |
| 2921 | resetSendPhase(); | |
| 2859 | 2922 | } |
| 2860 | 2923 | }, |
| 2861 | 2924 | [ |
| 2862 | 2925 | activeThread, |
| 2926 | beginSendPhase, | |
| 2863 | 2927 | forceStickToBottom, |
| 2864 | 2928 | isConnecting, |
| 2865 | 2929 | isSendBusy, |
| 2866 | 2930 | isServerThread, |
| 2867 | 2931 | persistThreadSettingsForNextTurn, |
| 2932 | resetSendPhase, | |
| 2868 | 2933 | runtimeMode, |
| 2869 | 2934 | selectedModel, |
| 2870 | 2935 | selectedModelOptionsForDispatch, |
| ... | ... | @@ -2902,10 +2967,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2902 | 2967 | DEFAULT_MODEL_BY_PROVIDER.codex; |
| 2903 | 2968 | |
| 2904 | 2969 | sendInFlightRef.current = true; |
| 2905 | setSendPhase("sending-turn"); | |
| 2970 | beginSendPhase("sending-turn"); | |
| 2906 | 2971 | const finish = () => { |
| 2907 | 2972 | sendInFlightRef.current = false; |
| 2908 | setSendPhase("idle"); | |
| 2973 | resetSendPhase(); | |
| 2909 | 2974 | }; |
| 2910 | 2975 | |
| 2911 | 2976 | await api.orchestration |
| ... | ... | @@ -2978,10 +3043,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2978 | 3043 | activeProject, |
| 2979 | 3044 | activeProposedPlan, |
| 2980 | 3045 | activeThread, |
| 3046 | beginSendPhase, | |
| 2981 | 3047 | isConnecting, |
| 2982 | 3048 | isSendBusy, |
| 2983 | 3049 | isServerThread, |
| 2984 | 3050 | navigate, |
| 3051 | resetSendPhase, | |
| 2985 | 3052 | runtimeMode, |
| 2986 | 3053 | selectedModel, |
| 2987 | 3054 | selectedModelOptionsForDispatch, |
| ... | ... | @@ -3323,6 +3390,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3323 | 3390 | activeThreadId={activeThread.id} |
| 3324 | 3391 | activeThreadTitle={activeThread.title} |
| 3325 | 3392 | activeProjectName={activeProject?.name} |
| 3393 | isGitRepo={isGitRepo} | |
| 3326 | 3394 | openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} |
| 3327 | 3395 | activeProjectScripts={activeProject?.scripts} |
| 3328 | 3396 | preferredScriptId={ |
| ... | ... | @@ -3366,8 +3434,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3366 | 3434 | key={activeThread.id} |
| 3367 | 3435 | hasMessages={timelineEntries.length > 0} |
| 3368 | 3436 | isWorking={isWorking} |
| 3369 | activeTurnInProgress={!latestTurnSettled} | |
| 3370 | activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} | |
| 3437 | activeTurnInProgress={isWorking || !latestTurnSettled} | |
| 3438 | activeTurnStartedAt={activeWorkStartedAt} | |
| 3371 | 3439 | scrollContainer={messagesScrollElement} |
| 3372 | 3440 | timelineEntries={timelineEntries} |
| 3373 | 3441 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| ... | ... | @@ -3903,6 +3971,7 @@ interface ChatHeaderProps { |
| 3903 | 3971 | activeThreadId: ThreadId; |
| 3904 | 3972 | activeThreadTitle: string; |
| 3905 | 3973 | activeProjectName: string | undefined; |
| 3974 | isGitRepo: boolean; | |
| 3906 | 3975 | openInCwd: string | null; |
| 3907 | 3976 | activeProjectScripts: ProjectScript[] | undefined; |
| 3908 | 3977 | preferredScriptId: string | null; |
| ... | ... | @@ -3921,6 +3990,7 @@ const ChatHeader = memo(function ChatHeader({ |
| 3921 | 3990 | activeThreadId, |
| 3922 | 3991 | activeThreadTitle, |
| 3923 | 3992 | activeProjectName, |
| 3993 | isGitRepo, | |
| 3924 | 3994 | openInCwd, |
| 3925 | 3995 | activeProjectScripts, |
| 3926 | 3996 | preferredScriptId, |
| ... | ... | @@ -3949,6 +4019,11 @@ const ChatHeader = memo(function ChatHeader({ |
| 3949 | 4019 | {activeProjectName} |
| 3950 | 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 | 4027 | </div> |
| 3953 | 4028 | <div className="@container/header-actions flex min-w-0 flex-1 items-center justify-end gap-2 @sm/header-actions:gap-3"> |
| 3954 | 4029 | {activeProjectScripts && ( |
| ... | ... | @@ -3979,15 +4054,18 @@ const ChatHeader = memo(function ChatHeader({ |
| 3979 | 4054 | aria-label="Toggle diff panel" |
| 3980 | 4055 | variant="outline" |
| 3981 | 4056 | size="xs" |
| 4057 | disabled={!isGitRepo} | |
| 3982 | 4058 | > |
| 3983 | 4059 | <DiffIcon className="size-3" /> |
| 3984 | 4060 | </Toggle> |
| 3985 | 4061 | } |
| 3986 | 4062 | /> |
| 3987 | 4063 | <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"} | |
| 3991 | 4069 | </TooltipPopup> |
| 3992 | 4070 | </Tooltip> |
| 3993 | 4071 | </div> |
| ... | ... | @@ -4919,23 +4997,43 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 4919 | 4997 | {visibleEntries.map((workEntry) => ( |
| 4920 | 4998 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> |
| 4921 | 4999 | <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" | |
| 4930 | 5031 | title={workEntry.detail} |
| 4931 | 5032 | > |
| 4932 | 5033 | {workEntry.detail} |
| 4933 | </span> | |
| 4934 | </> | |
| 4935 | ) : ( | |
| 4936 | workEntry.label | |
| 4937 | )} | |
| 4938 | </p> | |
| 5034 | </p> | |
| 5035 | )} | |
| 5036 | </div> | |
| 4939 | 5037 | </div> |
| 4940 | 5038 | ))} |
| 4941 | 5039 | </div> |
| ... | ... | @@ -5121,12 +5219,17 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 5121 | 5219 | {row.kind === "working" && ( |
| 5122 | 5220 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> |
| 5123 | 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 | 5223 | <span className="inline-flex items-center gap-[3px]"> |
| 5126 | 5224 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> |
| 5127 | 5225 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> |
| 5128 | 5226 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> |
| 5129 | 5227 | </span> |
| 5228 | <span> | |
| 5229 | {row.createdAt | |
| 5230 | ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` | |
| 5231 | : "Working..."} | |
| 5232 | </span> | |
| 5130 | 5233 | </div> |
| 5131 | 5234 | </div> |
| 5132 | 5235 | )} |
apps/web/src/components/DiffPanel.tsx+8| ... | ... | @@ -5,6 +5,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; |
| 6 | 6 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 7 | 7 | import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 8 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | |
| 8 | 9 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 9 | 10 | import { cn } from "~/lib/utils"; |
| 10 | 11 | import { readNativeApi } from "../nativeApi"; |
| ... | ... | @@ -175,6 +176,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 175 | 176 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 176 | 177 | ); |
| 177 | 178 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 179 | const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); | |
| 180 | const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; | |
| 178 | 181 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 179 | 182 | useTurnDiffSummaries(activeThread); |
| 180 | 183 | const orderedTurnDiffSummaries = useMemo( |
| ... | ... | @@ -250,6 +253,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 250 | 253 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 251 | 254 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| 252 | 255 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, |
| 256 | enabled: isGitRepo, | |
| 253 | 257 | }), |
| 254 | 258 | ); |
| 255 | 259 | const selectedTurnCheckpointDiff = selectedTurn |
| ... | ... | @@ -530,6 +534,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 530 | 534 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 531 | 535 | Select a thread to inspect turn diffs. |
| 532 | 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 | 541 | ) : orderedTurnDiffSummaries.length === 0 ? ( |
| 534 | 542 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 535 | 543 | No completed turns yet. |
apps/web/src/components/Sidebar.tsx+12-6| ... | ... | @@ -499,8 +499,8 @@ export default function Sidebar() { |
| 499 | 499 | const projectId = newProjectId(); |
| 500 | 500 | const createdAt = new Date().toISOString(); |
| 501 | 501 | const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; |
| 502 | const projectCreated = await api.orchestration | |
| 503 | .dispatchCommand({ | |
| 502 | try { | |
| 503 | await api.orchestration.dispatchCommand({ | |
| 504 | 504 | type: "project.create", |
| 505 | 505 | commandId: newCommandId(), |
| 506 | 506 | projectId, |
| ... | ... | @@ -508,11 +508,17 @@ export default function Sidebar() { |
| 508 | 508 | workspaceRoot: cwd, |
| 509 | 509 | defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, |
| 510 | 510 | createdAt, |
| 511 | }) | |
| 512 | .then(() => true) | |
| 513 | .catch(() => false); | |
| 514 | if (projectCreated) { | |
| 511 | }); | |
| 515 | 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 | 523 | finishAddingProject(); |
| 518 | 524 | }, |
apps/web/src/lib/providerReactQuery.ts+36-4| ... | ... | @@ -12,6 +12,7 @@ interface CheckpointDiffQueryInput { |
| 12 | 12 | fromTurnCount: number | null; |
| 13 | 13 | toTurnCount: number | null; |
| 14 | 14 | cacheScope?: string | null; |
| 15 | enabled?: boolean; | |
| 15 | 16 | } |
| 16 | 17 | |
| 17 | 18 | export const providerQueryKeys = { |
| ... | ... | @@ -52,6 +53,33 @@ function asCheckpointErrorMessage(error: unknown): string { |
| 52 | 53 | return ""; |
| 53 | 54 | } |
| 54 | 55 | |
| 56 | function 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 | ||
| 55 | 83 | function isCheckpointTemporarilyUnavailable(error: unknown): boolean { |
| 56 | 84 | const message = asCheckpointErrorMessage(error).toLowerCase(); |
| 57 | 85 | return ( |
| ... | ... | @@ -71,12 +99,16 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { |
| 71 | 99 | if (!input.threadId || decodedRequest._tag === "None") { |
| 72 | 100 | throw new Error("Checkpoint diff is unavailable."); |
| 73 | 101 | } |
| 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 }); | |
| 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 | 112 | staleTime: Infinity, |
| 81 | 113 | retry: (failureCount, error) => { |
| 82 | 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 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | deriveActiveWorkStartedAt, | |
| 5 | 6 | deriveActivePlanState, |
| 6 | 7 | PROVIDER_OPTIONS, |
| 7 | 8 | derivePendingApprovals, |
| ... | ... | @@ -430,6 +431,54 @@ describe("deriveWorkLogEntries", () => { |
| 430 | 431 | const entries = deriveWorkLogEntries(activities, undefined); |
| 431 | 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 | }); |
| 434 | 483 | |
| 435 | 484 | describe("deriveTimelineEntries", () => { |
| ... | ... | @@ -542,6 +591,54 @@ describe("isLatestTurnSettled", () => { |
| 542 | 591 | }); |
| 543 | 592 | }); |
| 544 | 593 | |
| 594 | describe("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 | ||
| 545 | 642 | describe("PROVIDER_OPTIONS", () => { |
| 546 | 643 | it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { |
| 547 | 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 | 27 | createdAt: string; |
| 28 | 28 | label: string; |
| 29 | 29 | detail?: string; |
| 30 | command?: string; | |
| 31 | changedFiles?: ReadonlyArray<string>; | |
| 30 | 32 | tone: "thinking" | "tool" | "info" | "error"; |
| 31 | 33 | } |
| 32 | 34 | |
| ... | ... | @@ -111,9 +113,12 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str |
| 111 | 113 | return formatDuration(endedAt - startedAt); |
| 112 | 114 | } |
| 113 | 115 | |
| 116 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; | |
| 117 | type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">; | |
| 118 | ||
| 114 | 119 | export 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, | |
| 117 | 122 | ): boolean { |
| 118 | 123 | if (!latestTurn?.startedAt) return false; |
| 119 | 124 | if (!latestTurn.completedAt) return false; |
| ... | ... | @@ -122,6 +127,17 @@ export function isLatestTurnSettled( |
| 122 | 127 | return true; |
| 123 | 128 | } |
| 124 | 129 | |
| 130 | export 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 | ||
| 125 | 141 | function requestKindFromRequestType( |
| 126 | 142 | requestType: unknown, |
| 127 | 143 | ): PendingApproval["requestKind"] | null { |
| ... | ... | @@ -401,6 +417,8 @@ export function deriveWorkLogEntries( |
| 401 | 417 | activity.payload && typeof activity.payload === "object" |
| 402 | 418 | ? (activity.payload as Record<string, unknown>) |
| 403 | 419 | : null; |
| 420 | const command = extractToolCommand(payload); | |
| 421 | const changedFiles = extractChangedFiles(payload); | |
| 404 | 422 | const entry: WorkLogEntry = { |
| 405 | 423 | id: activity.id, |
| 406 | 424 | createdAt: activity.createdAt, |
| ... | ... | @@ -410,10 +428,125 @@ export function deriveWorkLogEntries( |
| 410 | 428 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { |
| 411 | 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 | 437 | return entry; |
| 414 | 438 | }); |
| 415 | 439 | } |
| 416 | 440 | |
| 441 | function asRecord(value: unknown): Record<string, unknown> | null { | |
| 442 | return value && typeof value === "object" ? (value as Record<string, unknown>) : null; | |
| 443 | } | |
| 444 | ||
| 445 | function 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 | ||
| 453 | function 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 | ||
| 467 | function 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 | ||
| 481 | function 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 | ||
| 490 | function 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 | ||
| 543 | function 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 | ||
| 417 | 550 | function compareActivitiesByOrder( |
| 418 | 551 | left: OrchestrationThreadActivity, |
| 419 | 552 | right: OrchestrationThreadActivity, |