diff --git a/.agents/skills/btca-cli/SKILL.md b/.agents/skills/btca-cli/SKILL.md deleted file mode 100644 index e6b71ee4e8c172516f17140d6f6482d87091f7b6..0000000000000000000000000000000000000000 --- a/.agents/skills/btca-cli/SKILL.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: btca-cli -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. ---- - -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. - -For example, assuming you have a resource named `codex`, you can ask questions like: - -```bash -btca ask -r codex -q "What is the return format for Codex App Server responses? Are there any edge cases worth knowing?" -``` - -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. - -New resources can be created if the ones you need do not yet exist. - -```bash -btca add -n svelte-dev https://github.com/sveltejs/svelte.dev -``` diff --git a/.agents/skills/btca-cli/agents/openai.yaml b/.agents/skills/btca-cli/agents/openai.yaml deleted file mode 100644 index 37e40099138ee0e4b826f2363ef130528edb0f4d..0000000000000000000000000000000000000000 --- a/.agents/skills/btca-cli/agents/openai.yaml +++ /dev/null @@ -1,3 +0,0 @@ -interface: - display_name: "BTCA CLI" - short_description: "Help with BTCA CLI setup and usage workflows" diff --git a/TODO.md b/TODO.md index 4e31b7437005233620c940af2b7bf6e9adb97d99..3d856996d8dab98b4d370622ef0d4c27ceff6d1a 100644 --- a/TODO.md +++ b/TODO.md @@ -2,24 +2,12 @@ ## Small things -- [ ] Plan mode - [ ] Submitting new messages should scroll to bottom -- [ ] 1 click copy in codeblocks -- [x] Pasting/adding images to submit with prompts - [ ] Only show last 10 threads for a given project - [ ] Thread archiving -- [x] Selecting folder should instantly create project - [ ] New projects should go on top - [ ] Projects should be sorted by latest thread update -- [x] "Response" bar should ONLY appear on work completed -- [x] "Response" bar should ONLY appear above the FINAL message in a series -- [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) -- [x] Insta focus input on new thread creation -- [x] Disable "streaming" (wait until whole paragraph/step finishes before showing it -- [x] Making new thread should auto-focus input box -- [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank) ## Bigger things - [ ] Queueing messages -- [x] External runners (i.e. running the cli on a mac mini) diff --git a/apps/server/src/git/isRepo.ts b/apps/server/src/git/isRepo.ts new file mode 100644 index 0000000000000000000000000000000000000000..6faf3e99c77b34cdd3a2dc8cebc03a687f74dde6 --- /dev/null +++ b/apps/server/src/git/isRepo.ts @@ -0,0 +1,6 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export function isGitRepository(cwd: string): boolean { + return existsSync(join(cwd, ".git")); +} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 07ffac32531319c29a13cfed7048f932fb859a48..52243248fb0398bbc372292ca3304caed4263bc3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -20,6 +20,7 @@ import { CheckpointReactor, type CheckpointReactorShape } from "../Services/Chec import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import { OrchestrationDispatchError } from "../Errors.ts"; +import { isGitRepository } from "../../git/isRepo.ts"; type ReactorInput = | { @@ -144,6 +145,8 @@ const make = Effect.gen(function* () { return Option.none(); }); + const isGitWorkspace = (cwd: string) => isGitRepository(cwd); + const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( event: Extract, ) { @@ -184,6 +187,14 @@ const make = Effect.gen(function* () { }); return; } + if (!isGitWorkspace(checkpointCwd)) { + yield* Effect.logDebug("checkpoint capture skipped for non-git workspace", { + threadId: thread.id, + turnId, + cwd: checkpointCwd, + }); + return; + } const currentTurnCount = thread.checkpoints.reduce( (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), @@ -319,6 +330,9 @@ const make = Effect.gen(function* () { }); return; } + if (!isGitWorkspace(checkpointCwd)) { + return; + } const currentTurnCount = thread.checkpoints.reduce( (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), @@ -378,6 +392,9 @@ const make = Effect.gen(function* () { }); return; } + if (!isGitWorkspace(checkpointCwd)) { + return; + } const currentTurnCount = thread.checkpoints.reduce( (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), @@ -425,6 +442,15 @@ const make = Effect.gen(function* () { }).pipe(Effect.catch(() => Effect.void)); return; } + if (!isGitWorkspace(sessionRuntime.value.cwd)) { + yield* appendRevertFailureActivity({ + threadId: event.payload.threadId, + turnCount: event.payload.turnCount, + detail: "Checkpoints are unavailable because this project is not a git repository.", + createdAt: now, + }).pipe(Effect.catch(() => Effect.void)); + return; + } const currentTurnCount = thread.checkpoints.reduce( (maxTurnCount, checkpoint) => Math.max(maxTurnCount, checkpoint.checkpointTurnCount), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index eb230e5202dec6d1f95227298246f342fcb5195e..96242b846cbe1a38dfdfff393d7259935965b681 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -112,6 +116,13 @@ describe("ProviderRuntimeIngestion", () => { unknown > | null = null; let scope: Scope.Closeable | null = null; + const tempDirs: string[] = []; + + function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } afterEach(async () => { if (scope) { @@ -122,9 +133,14 @@ describe("ProviderRuntimeIngestion", () => { await runtime.dispose(); } runtime = null; + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); async function createHarness() { + const workspaceRoot = makeTempDir("t3-provider-project-"); + fs.mkdirSync(path.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -152,7 +168,7 @@ describe("ProviderRuntimeIngestion", () => { commandId: CommandId.makeUnsafe("cmd-provider-project-create"), projectId: asProjectId("project-1"), title: "Provider Project", - workspaceRoot: "/tmp/provider-project", + workspaceRoot, defaultModel: "gpt-5-codex", createdAt, }), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 04f6d8523437310d547f4f7629c1a82e96da6386..022a196674804820b5e312b0a1ec3eecd7cf080f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -13,6 +13,8 @@ import { import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Ref, Stream } from "effect"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { isGitRepository } from "../../git/isRepo.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService, @@ -507,6 +509,22 @@ const make = Effect.gen(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); + const isGitRepoForThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); + if (!thread) { + return false; + } + const workspaceCwd = resolveThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); + if (!workspaceCwd) { + return false; + } + return isGitRepository(workspaceCwd); + }); + const rememberAssistantMessageId = ( threadId: ThreadId, turnId: TurnId, @@ -1041,7 +1059,7 @@ const make = Effect.gen(function* () { if (event.type === "turn.diff.updated") { const turnId = toTurnId(event.turnId); - if (turnId) { + if (turnId && (yield* isGitRepoForThread(thread.id))) { const assistantMessageId = MessageId.makeUnsafe( `assistant:${event.itemId ?? event.turnId ?? event.eventId}`, ); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 3f5e2129da06fae068f70415a18331c03dbc8312..938b4280c33ce9bf66bb320b0380eb82bdf78f4b 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -20,12 +20,12 @@ export function fixPath(): void { } export const expandHomePath = Effect.fn(function* (input: string) { - const { join, sep } = yield* Path.Path; + const { join } = yield* Path.Path; if (input === "~") { return OS.homedir(); } - if (input.startsWith(`~${sep}`)) { - return join(OS.homedir(), input.slice(sep.length)); + if (input.startsWith("~/") || input.startsWith("~\\")) { + return join(OS.homedir(), input.slice(2)); } return input; }); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 2c30b7cc544dfadac8c78efd5b6669a972ae0a27..285028cca6e85e3613cafdd10a6e2986e31a2073 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1108,13 +1108,14 @@ describe("WebSocket Server", () => { connections.push(ws); await waitForMessage(ws); + const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); const createdAt = new Date().toISOString(); const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { type: "project.create", commandId: "cmd-diff-project-create", projectId: "project-diff", title: "Diff Project", - workspaceRoot: "/tmp/ws-diff-project", + workspaceRoot, defaultModel: "gpt-5-codex", createdAt, }); @@ -1186,13 +1187,14 @@ describe("WebSocket Server", () => { connections.push(ws); await waitForMessage(ws); + const workspaceRoot = makeTempDir("t3code-ws-project-"); const createdAt = new Date().toISOString(); const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { type: "project.create", commandId: "cmd-ws-project-create", projectId: "project-1", title: "WS Project", - workspaceRoot: "/tmp/ws-project", + workspaceRoot, defaultModel: "gpt-5-codex", createdAt, }); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 934ff92a522649e4ae49a665c491ab8c2c0f0f22..d8859c2fa5b71a0f7c0e50b5070b19a3ee68f636 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -72,6 +72,7 @@ import { } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { expandHomePath } from "./os-jank.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -194,6 +195,13 @@ function stripRequestTag(body: T) { return Struct.omit(body, ["_tag"]); } +function messageFromCause(cause: Cause.Cause): string { + const squashed = Cause.squash(cause); + const message = + squashed instanceof Error ? squashed.message.trim() : String(squashed).trim(); + return message.length > 0 ? message : Cause.pretty(cause); +} + export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery @@ -298,6 +306,41 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { readonly command: ClientOrchestrationCommand; }) { + const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { + const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); + const workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat) { + return yield* new RouteRequestError({ + message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, + }); + } + if (workspaceStat.type !== "Directory") { + return yield* new RouteRequestError({ + message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, + }); + } + return normalizedWorkspaceRoot; + }); + + if (input.command.type === "project.create") { + return { + ...input.command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + } satisfies OrchestrationCommand; + } + + if ( + input.command.type === "project.meta.update" && + input.command.workspaceRoot !== undefined + ) { + return { + ...input.command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + } satisfies OrchestrationCommand; + } + if (input.command.type !== "thread.turn.start") { return input.command as OrchestrationCommand; } @@ -658,7 +701,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); } - const runPromise = yield* Effect.map(Effect.services(), Effect.runPromiseWith); + const runtimeServices = yield* Effect.services< + ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + >(); + const runPromise = Effect.runPromiseWith(runtimeServices); const unsubscribeTerminalEvents = yield* terminalManager.subscribe( (event) => void Effect.runPromise(onTerminalEvent(event)), @@ -873,7 +919,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (request._tag === "Failure") { const errorResponse = yield* encodeResponse({ id: "unknown", - error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, + error: { message: `Invalid request format: ${messageFromCause(request.cause)}` }, }); ws.send(errorResponse); return; @@ -883,7 +929,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (result._tag === "Failure") { const errorResponse = yield* encodeResponse({ id: request.value.id, - error: { message: Cause.pretty(result.cause) }, + error: { message: messageFromCause(result.cause) }, }); ws.send(errorResponse); return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46231694d2dcca200cf0eaab8b2bd6779fb7a0f9..c1693f419979a9eb1c6b58df7472dc59cd7e2d29 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -67,6 +67,7 @@ import { derivePendingUserInputs, derivePhase, deriveTimelineEntries, + deriveActiveWorkStartedAt, deriveActivePlanState, findLatestProposedPlan, type PendingApproval, @@ -223,6 +224,29 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { return `${formatTimestamp(createdAt)} • ${duration}`; } +function formatWorkingTimer(startIso: string, endIso: string): string | null { + const startedAtMs = Date.parse(startIso); + const endedAtMs = Date.parse(endIso); + if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) { + return null; + } + + const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000)); + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s`; + } + + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + const seconds = elapsedSeconds % 60; + + if (hours > 0) { + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} + const LAST_EDITOR_KEY = "t3code:last-editor"; const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; @@ -619,6 +643,7 @@ export default function ChatView({ threadId }: ChatViewProps) { Record >({}); const [sendPhase, setSendPhase] = useState("idle"); + const [sendStartedAt, setSendStartedAt] = useState(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); @@ -838,6 +863,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); + const activeWorkStartedAt = deriveActiveWorkStartedAt( + activeLatestTurn, + activeThread?.session ?? null, + sendStartedAt, + ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), @@ -1943,6 +1973,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return []; }); setSendPhase("idle"); + setSendStartedAt(null); setComposerHighlightedItemId(null); setComposerCursor(promptRef.current.length); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); @@ -2082,6 +2113,37 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [phase]); + const beginSendPhase = useCallback((nextPhase: Exclude) => { + setSendStartedAt((current) => current ?? new Date().toISOString()); + setSendPhase(nextPhase); + }, []); + + const resetSendPhase = useCallback(() => { + setSendPhase("idle"); + setSendStartedAt(null); + }, []); + + useEffect(() => { + if (sendPhase === "idle") { + return; + } + if ( + phase === "running" || + activePendingApproval !== null || + activePendingUserInput !== null || + activeThread?.error + ) { + resetSendPhase(); + } + }, [ + activePendingApproval, + activePendingUserInput, + activeThread?.error, + phase, + resetSendPhase, + sendPhase, + ]); + useEffect(() => { if (!activeThreadId) return; const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; @@ -2391,7 +2453,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } sendInFlightRef.current = true; - setSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); + beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); @@ -2442,7 +2504,7 @@ export default function ChatView({ threadId }: ChatViewProps) { await (async () => { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { - setSendPhase("preparing-worktree"); + beginSendPhase("preparing-worktree"); const newBranch = buildTemporaryWorktreeBranchName(); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, @@ -2547,7 +2609,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } - setSendPhase("sending-turn"); + beginSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; await api.orchestration.dispatchCommand({ type: "thread.turn.start", @@ -2609,7 +2671,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ); }); sendInFlightRef.current = false; - setSendPhase("idle"); + if (!turnStartSucceeded) { + resetSendPhase(); + } }; const onInterrupt = async () => { @@ -2796,7 +2860,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const messageCreatedAt = new Date().toISOString(); sendInFlightRef.current = true; - setSendPhase("sending-turn"); + beginSendPhase("sending-turn"); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, @@ -2845,7 +2909,6 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: messageCreatedAt, }); sendInFlightRef.current = false; - setSendPhase("idle"); } catch (err) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), @@ -2855,16 +2918,18 @@ export default function ChatView({ threadId }: ChatViewProps) { err instanceof Error ? err.message : "Failed to send plan follow-up.", ); sendInFlightRef.current = false; - setSendPhase("idle"); + resetSendPhase(); } }, [ activeThread, + beginSendPhase, forceStickToBottom, isConnecting, isSendBusy, isServerThread, persistThreadSettingsForNextTurn, + resetSendPhase, runtimeMode, selectedModel, selectedModelOptionsForDispatch, @@ -2902,10 +2967,10 @@ export default function ChatView({ threadId }: ChatViewProps) { DEFAULT_MODEL_BY_PROVIDER.codex; sendInFlightRef.current = true; - setSendPhase("sending-turn"); + beginSendPhase("sending-turn"); const finish = () => { sendInFlightRef.current = false; - setSendPhase("idle"); + resetSendPhase(); }; await api.orchestration @@ -2978,10 +3043,12 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeProposedPlan, activeThread, + beginSendPhase, isConnecting, isSendBusy, isServerThread, navigate, + resetSendPhase, runtimeMode, selectedModel, selectedModelOptionsForDispatch, @@ -3323,6 +3390,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId={activeThread.id} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} + isGitRepo={isGitRepo} openInCwd={activeThread.worktreePath ?? activeProject?.cwd ?? null} activeProjectScripts={activeProject?.scripts} preferredScriptId={ @@ -3366,8 +3434,8 @@ export default function ChatView({ threadId }: ChatViewProps) { key={activeThread.id} hasMessages={timelineEntries.length > 0} isWorking={isWorking} - activeTurnInProgress={!latestTurnSettled} - activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} + activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnStartedAt={activeWorkStartedAt} scrollContainer={messagesScrollElement} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} @@ -3903,6 +3971,7 @@ interface ChatHeaderProps { activeThreadId: ThreadId; activeThreadTitle: string; activeProjectName: string | undefined; + isGitRepo: boolean; openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; @@ -3921,6 +3990,7 @@ const ChatHeader = memo(function ChatHeader({ activeThreadId, activeThreadTitle, activeProjectName, + isGitRepo, openInCwd, activeProjectScripts, preferredScriptId, @@ -3949,6 +4019,11 @@ const ChatHeader = memo(function ChatHeader({ {activeProjectName} )} + {activeProjectName && !isGitRepo && ( + + No Git + + )}
{activeProjectScripts && ( @@ -3979,15 +4054,18 @@ const ChatHeader = memo(function ChatHeader({ aria-label="Toggle diff panel" variant="outline" size="xs" + disabled={!isGitRepo} > } /> - {diffToggleShortcutLabel - ? `Toggle diff panel (${diffToggleShortcutLabel})` - : "Toggle diff panel"} + {!isGitRepo + ? "Diff panel is unavailable because this project is not a git repository." + : diffToggleShortcutLabel + ? `Toggle diff panel (${diffToggleShortcutLabel})` + : "Toggle diff panel"}
@@ -4919,23 +4997,43 @@ const MessagesTimeline = memo(function MessagesTimeline({ {visibleEntries.map((workEntry) => (
-

- {workEntry.detail ? ( - <> - {workEntry.label} - +

+ {workEntry.label} +

+ {workEntry.command && ( +
+                          {workEntry.command}
+                        
+ )} + {workEntry.changedFiles && workEntry.changedFiles.length > 0 && ( +
+ {workEntry.changedFiles.slice(0, 6).map((filePath) => ( + + {filePath} + + ))} + {workEntry.changedFiles.length > 6 && ( + + +{workEntry.changedFiles.length - 6} more + + )} +
+ )} + {workEntry.detail && + (!workEntry.command || workEntry.detail !== workEntry.command) && ( +

{workEntry.detail} - - - ) : ( - workEntry.label - )} -

+

+ )} +
))} @@ -5121,12 +5219,17 @@ const MessagesTimeline = memo(function MessagesTimeline({ {row.kind === "working" && (
-
+
+ + {row.createdAt + ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` + : "Working..."} +
)} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 058fa8492d8f60f6dc898cfafcc4eee37f469766..27248ce634a365ae7a09f4d53ae87a133fd95f60 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -5,6 +5,7 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ThreadId, type TurnId } from "@t3tools/contracts"; import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -175,6 +176,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; + const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); + const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( @@ -250,6 +253,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, + enabled: isGitRepo, }), ); const selectedTurnCheckpointDiff = selectedTurn @@ -530,6 +534,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
Select a thread to inspect turn diffs.
+ ) : !isGitRepo ? ( +
+ Turn diffs are unavailable because this project is not a git repository. +
) : orderedTurnDiffSummaries.length === 0 ? (
No completed turns yet. diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2b8b02ee59b7b35685e6991bff0ac9471e2588a8..e3a3ce02efb49a965e0436203f8d253f8aee4f64 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -499,8 +499,8 @@ export default function Sidebar() { const projectId = newProjectId(); const createdAt = new Date().toISOString(); const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; - const projectCreated = await api.orchestration - .dispatchCommand({ + try { + await api.orchestration.dispatchCommand({ type: "project.create", commandId: newCommandId(), projectId, @@ -508,11 +508,17 @@ export default function Sidebar() { workspaceRoot: cwd, defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, createdAt, - }) - .then(() => true) - .catch(() => false); - if (projectCreated) { + }); await handleNewThread(projectId).catch(() => undefined); + } catch (error) { + setIsAddingProject(false); + toastManager.add({ + type: "error", + title: "Unable to add project", + description: + error instanceof Error ? error.message : "An error occurred while adding the project.", + }); + return; } finishAddingProject(); }, diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index db327255eb8c187ae597339a4ecf110e6a485d36..0547aa9e5f8bac16802436bdce1a710f0c523f69 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -12,6 +12,7 @@ interface CheckpointDiffQueryInput { fromTurnCount: number | null; toTurnCount: number | null; cacheScope?: string | null; + enabled?: boolean; } export const providerQueryKeys = { @@ -52,6 +53,33 @@ function asCheckpointErrorMessage(error: unknown): string { return ""; } +function normalizeCheckpointErrorMessage(error: unknown): string { + const message = asCheckpointErrorMessage(error).trim(); + if (message.length === 0) { + return "Failed to load checkpoint diff."; + } + + const lower = message.toLowerCase(); + if (lower.includes("not a git repository")) { + return "Turn diffs are unavailable because this project is not a git repository."; + } + + if ( + lower.includes("checkpoint unavailable for thread") || + lower.includes("checkpoint invariant violation") + ) { + const separatorIndex = message.indexOf(":"); + if (separatorIndex >= 0) { + const detail = message.slice(separatorIndex + 1).trim(); + if (detail.length > 0) { + return detail; + } + } + } + + return message; +} + function isCheckpointTemporarilyUnavailable(error: unknown): boolean { const message = asCheckpointErrorMessage(error).toLowerCase(); return ( @@ -71,12 +99,16 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { if (!input.threadId || decodedRequest._tag === "None") { throw new Error("Checkpoint diff is unavailable."); } - if (decodedRequest.value.kind === "fullThreadDiff") { - return api.orchestration.getFullThreadDiff(decodedRequest.value.input); + try { + if (decodedRequest.value.kind === "fullThreadDiff") { + return await api.orchestration.getFullThreadDiff(decodedRequest.value.input); + } + return await api.orchestration.getTurnDiff(decodedRequest.value.input); + } catch (error) { + throw new Error(normalizeCheckpointErrorMessage(error), { cause: error }); } - return api.orchestration.getTurnDiff(decodedRequest.value.input); }, - enabled: !!input.threadId && decodedRequest._tag === "Some", + enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some", staleTime: Infinity, retry: (failureCount, error) => { if (isCheckpointTemporarilyUnavailable(error)) { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 289d2c0cb2c16210b15603f87a608117fed015b0..3d1d269d48bb6e59c8076db90eb88fe07ec728a8 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2,6 +2,7 @@ import { EventId, MessageId, TurnId, type OrchestrationThreadActivity } from "@t import { describe, expect, it } from "vitest"; import { + deriveActiveWorkStartedAt, deriveActivePlanState, PROVIDER_OPTIONS, derivePendingApprovals, @@ -430,6 +431,54 @@ describe("deriveWorkLogEntries", () => { const entries = deriveWorkLogEntries(activities, undefined); expect(entries.map((entry) => entry.id)).toEqual(["first", "second"]); }); + + it("extracts command text for command tool activities", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool", + kind: "tool.completed", + summary: "Command run complete", + payload: { + itemType: "command_execution", + data: { + item: { + command: ["bun", "run", "lint"], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.command).toBe("bun run lint"); + }); + + it("extracts changed file paths for file-change tool activities", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "file-tool", + kind: "tool.completed", + summary: "File change complete", + payload: { + itemType: "file_change", + data: { + item: { + changes: [ + { path: "apps/web/src/components/ChatView.tsx" }, + { filename: "apps/web/src/session-logic.ts" }, + ], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.changedFiles).toEqual([ + "apps/web/src/components/ChatView.tsx", + "apps/web/src/session-logic.ts", + ]); + }); }); describe("deriveTimelineEntries", () => { @@ -542,6 +591,54 @@ describe("isLatestTurnSettled", () => { }); }); +describe("deriveActiveWorkStartedAt", () => { + const latestTurn = { + turnId: TurnId.makeUnsafe("turn-1"), + startedAt: "2026-02-27T21:10:00.000Z", + completedAt: "2026-02-27T21:10:06.000Z", + } as const; + + it("prefers the in-flight turn start when the latest turn is not settled", () => { + expect( + deriveActiveWorkStartedAt( + latestTurn, + { + orchestrationStatus: "running", + activeTurnId: TurnId.makeUnsafe("turn-1"), + }, + "2026-02-27T21:11:00.000Z", + ), + ).toBe("2026-02-27T21:10:00.000Z"); + }); + + it("falls back to sendStartedAt once the latest turn is settled", () => { + expect( + deriveActiveWorkStartedAt( + latestTurn, + { + orchestrationStatus: "ready", + activeTurnId: undefined, + }, + "2026-02-27T21:11:00.000Z", + ), + ).toBe("2026-02-27T21:11:00.000Z"); + }); + + it("uses sendStartedAt for a fresh send after the prior turn completed", () => { + expect( + deriveActiveWorkStartedAt( + { + turnId: TurnId.makeUnsafe("turn-1"), + startedAt: "2026-02-27T21:10:00.000Z", + completedAt: "2026-02-27T21:10:06.000Z", + }, + null, + "2026-02-27T21:11:00.000Z", + ), + ).toBe("2026-02-27T21:11:00.000Z"); + }); +}); + describe("PROVIDER_OPTIONS", () => { it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9519b7a46df6a138877fda12b1ebbdd6f49fd61a..e9351ca2b206eca3d01795199161f10b4045e566 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -27,6 +27,8 @@ export interface WorkLogEntry { createdAt: string; label: string; detail?: string; + command?: string; + changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; } @@ -111,9 +113,12 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str return formatDuration(endedAt - startedAt); } +type LatestTurnTiming = Pick; +type SessionActivityState = Pick; + export function isLatestTurnSettled( - latestTurn: Pick | null, - session: Pick | null, + latestTurn: LatestTurnTiming | null, + session: SessionActivityState | null, ): boolean { if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; @@ -122,6 +127,17 @@ export function isLatestTurnSettled( return true; } +export function deriveActiveWorkStartedAt( + latestTurn: LatestTurnTiming | null, + session: SessionActivityState | null, + sendStartedAt: string | null, +): string | null { + if (!isLatestTurnSettled(latestTurn, session)) { + return latestTurn?.startedAt ?? sendStartedAt; + } + return sendStartedAt; +} + function requestKindFromRequestType( requestType: unknown, ): PendingApproval["requestKind"] | null { @@ -401,6 +417,8 @@ export function deriveWorkLogEntries( activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) : null; + const command = extractToolCommand(payload); + const changedFiles = extractChangedFiles(payload); const entry: WorkLogEntry = { id: activity.id, createdAt: activity.createdAt, @@ -410,10 +428,125 @@ export function deriveWorkLogEntries( if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { entry.detail = payload.detail; } + if (command) { + entry.command = command; + } + if (changedFiles.length > 0) { + entry.changedFiles = changedFiles; + } return entry; }); } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function asTrimmedString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeCommandValue(value: unknown): string | null { + const direct = asTrimmedString(value); + if (direct) { + return direct; + } + if (!Array.isArray(value)) { + return null; + } + const parts = value + .map((entry) => asTrimmedString(entry)) + .filter((entry): entry is string => entry !== null); + return parts.length > 0 ? parts.join(" ") : null; +} + +function extractToolCommand(payload: Record | null): string | null { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const itemInput = asRecord(item?.input); + const candidates = [ + normalizeCommandValue(item?.command), + normalizeCommandValue(itemInput?.command), + normalizeCommandValue(itemResult?.command), + normalizeCommandValue(data?.command), + ]; + return candidates.find((candidate) => candidate !== null) ?? null; +} + +function pushChangedFile(target: string[], seen: Set, value: unknown) { + const normalized = asTrimmedString(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + target.push(normalized); +} + +function collectChangedFiles( + value: unknown, + target: string[], + seen: Set, + depth: number, +) { + if (depth > 4 || target.length >= 12) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectChangedFiles(entry, target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } + return; + } + + const record = asRecord(value); + if (!record) { + return; + } + + pushChangedFile(target, seen, record.path); + pushChangedFile(target, seen, record.filePath); + pushChangedFile(target, seen, record.relativePath); + pushChangedFile(target, seen, record.filename); + pushChangedFile(target, seen, record.newPath); + pushChangedFile(target, seen, record.oldPath); + + for (const nestedKey of [ + "item", + "result", + "input", + "data", + "changes", + "files", + "edits", + "patch", + "patches", + "operations", + ]) { + if (!(nestedKey in record)) { + continue; + } + collectChangedFiles(record[nestedKey], target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } +} + +function extractChangedFiles(payload: Record | null): string[] { + const data = asRecord(payload?.data); + const changedFiles: string[] = []; + collectChangedFiles(data, changedFiles, new Set(), 0); + return changedFiles; +} + function compareActivitiesByOrder( left: OrchestrationThreadActivity, right: OrchestrationThreadActivity,