diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5757d26ab..5acb8868c 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -25,7 +25,7 @@ import { navigateToWorkspace } from '@/hooks/use-workspace-navigation' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' import * as Clipboard from 'expo-clipboard' -import { Archive, ChevronDown, ChevronRight, Copy, MoreVertical, Plus } from 'lucide-react-native' +import { Archive, Check, ChevronDown, ChevronRight, CircleHelp, Copy, FolderGit2, Monitor, MoreVertical, Plus } from 'lucide-react-native' import { NestableScrollContainer } from 'react-native-draggable-flatlist' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import type { DraggableListDragHandleProps } from './draggable-list.types' @@ -160,18 +160,26 @@ function resolveStatusDotColor(input: { function WorkspaceStatusIndicator({ bucket, + workspaceKind, loading = false, }: { bucket: SidebarWorkspaceEntry['statusBucket'] + workspaceKind: SidebarWorkspaceEntry['workspaceKind'] loading?: boolean }) { const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }) - const shouldRenderIdlePlaceholder = !loading && !shouldShowSyncedLoader && bucket === 'done' + const isIdle = !loading && !shouldShowSyncedLoader && bucket === 'done' - if (shouldRenderIdlePlaceholder) { - return null + if (isIdle) { + const KindIcon = workspaceKind === 'local_checkout' ? Monitor : workspaceKind === 'worktree' ? FolderGit2 : null + if (!KindIcon) return null + return ( + + + + ) } return ( @@ -180,6 +188,10 @@ function WorkspaceStatusIndicator({ ) : shouldShowSyncedLoader ? ( + ) : bucket === 'needs_input' ? ( + + ) : bucket === 'attention' ? ( + ) : ( )} @@ -213,7 +225,7 @@ function ProjectLeadingVisual({ {showChevron && chevron !== null ? ( ) : shouldShowWorkspaceStatus && activeWorkspace ? ( - + ) : iconDataUri ? ( ) : ( @@ -686,6 +698,7 @@ function WorkspaceRowInner({ > ({ workspaceRowLeft: { flexDirection: 'row', alignItems: 'center', - gap: theme.spacing[1], + gap: theme.spacing[2], flex: 1, minWidth: 0, }, @@ -1938,7 +1951,7 @@ const styles = StyleSheet.create((theme) => ({ position: 'relative', }, workspaceStatusDot: { - width: 11, + width: 14, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index d35e0d80a..7be627ea5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -147,6 +147,45 @@ describe("convertClaudeHistoryEntry", () => { ]); }); + test("maps queue-operation task notifications to synthetic tool calls", () => { + const entry = { + type: "queue-operation", + operation: "enqueue", + uuid: "task-note-queue-1", + content: [ + "", + "bg-queue-1", + "completed", + "Background task completed", + "/tmp/bg-queue-1.txt", + "", + ].join("\n"), + }; + + expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([ + { + type: "tool_call", + callId: "task_notification_task-note-queue-1", + name: "task_notification", + status: "completed", + error: null, + detail: { + type: "plain_text", + label: "Background task completed", + icon: "wrench", + text: entry.content, + }, + metadata: { + synthetic: true, + source: "claude_task_notification", + taskId: "bg-queue-1", + status: "completed", + outputFile: "/tmp/bg-queue-1.txt", + }, + }, + ]); + }); + test("passes assistant content blocks through to the mapper", () => { const entry = { type: "assistant", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 4fd2d06d8..fcd948ee0 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -29,6 +29,7 @@ import { mapClaudeRunningToolCall, } from "./claude/tool-call-mapper.js"; import { + coerceTaskNotificationHistoryRecordToSystemMessage, isTaskNotificationUserContent, mapTaskNotificationSystemRecordToToolCall, mapTaskNotificationUserContentToToolCall, @@ -1495,6 +1496,9 @@ class ClaudeAgentSession implements AgentSession { private readonly timelineAssembler = new TimelineAssembler(); private persistedHistory: AgentTimelineItem[] = []; private historyPending = false; + private historyOffsetSessionId: string | null = null; + private historyReadOffsetBytes = 0; + private historyLineFragment = ""; private turnState: TurnState = "idle"; private preReplayMetadataSeen = false; private pendingAutonomousWakeReservations = 0; @@ -3074,6 +3078,9 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; this.persistedHistory = []; this.historyPending = false; + this.historyOffsetSessionId = null; + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; this.cachedRuntimeInfo = null; this.queryRestartNeeded = false; return true; @@ -3998,46 +4005,163 @@ class ClaudeAgentSession implements AgentSession { } } - private waitForLiveHistoryPoll(): Promise { - return new Promise((resolve) => setTimeout(resolve, 250)); + private async waitForLiveHistoryPoll(): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)); + if (this.claudeSessionId) { + this.loadPersistedHistory(this.claudeSessionId, { dispatchLive: true }); + } } - private loadPersistedHistory(sessionId: string) { + private loadPersistedHistory( + sessionId: string, + options?: { dispatchLive?: boolean } + ) { try { const historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { return; } - const content = fs.readFileSync(historyPath, "utf8"); - const timeline: AgentTimelineItem[] = []; - for (const line of content.split(/\n+/)) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const entry = JSON.parse(trimmed); - if (entry.isSidechain) { - continue; - } - if (entry.type === "user" && typeof entry.uuid === "string") { - this.rememberUserMessageId(entry.uuid); - } - const items = this.convertHistoryEntry(entry); - if (items.length > 0) { - timeline.push(...items); - } - } catch (error) { - // ignore malformed history line - } + if (this.historyOffsetSessionId !== sessionId) { + this.historyOffsetSessionId = sessionId; + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; } - if (timeline.length > 0) { - this.persistedHistory = timeline; - this.historyPending = true; + const content = fs.readFileSync(historyPath); + if (content.byteLength < this.historyReadOffsetBytes) { + this.historyReadOffsetBytes = 0; + this.historyLineFragment = ""; } + if (content.byteLength === this.historyReadOffsetBytes) { + return; + } + + const unreadChunk = content + .subarray(this.historyReadOffsetBytes) + .toString("utf8"); + this.historyReadOffsetBytes = content.byteLength; + this.ingestPersistedHistoryChunk(unreadChunk, { + dispatchLive: options?.dispatchLive ?? false, + }); } catch (error) { // ignore history load failures } } + private ingestPersistedHistoryChunk( + chunk: string, + options: { dispatchLive: boolean } + ): void { + if (!chunk) { + return; + } + + const combined = `${this.historyLineFragment}${chunk}`; + this.historyLineFragment = ""; + const lines = combined.split(/\r?\n/); + const trailing = lines.pop() ?? ""; + const timeline: AgentTimelineItem[] = []; + + for (const line of lines) { + this.ingestPersistedHistoryLine(line, { + dispatchLive: options.dispatchLive, + timeline, + }); + } + + if (trailing.trim().length > 0) { + const handled = this.ingestPersistedHistoryLine(trailing, { + dispatchLive: options.dispatchLive, + timeline, + }); + if (!handled) { + this.historyLineFragment = trailing; + } + } + + if (!options.dispatchLive && timeline.length > 0) { + this.persistedHistory = [...this.persistedHistory, ...timeline]; + this.historyPending = true; + } + } + + private ingestPersistedHistoryLine( + line: string, + options: { + dispatchLive: boolean; + timeline: AgentTimelineItem[]; + } + ): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return true; + } + + let entry: Record; + try { + entry = JSON.parse(trimmed) as Record; + } catch { + return false; + } + + if (entry.isSidechain) { + return true; + } + if (entry.type === "user" && typeof entry.uuid === "string") { + this.rememberUserMessageId(entry.uuid); + } + + if (options.dispatchLive) { + this.dispatchPersistedHistoryEntry(entry); + return true; + } + + const items = this.convertHistoryEntry(entry); + if (items.length > 0) { + options.timeline.push(...items); + } + return true; + } + + private dispatchPersistedHistoryEntry(entry: Record): void { + const liveMessage = this.normalizePersistedHistoryEntryToLiveMessage(entry); + if (liveMessage) { + this.routeSdkMessageFromPump(liveMessage); + return; + } + + const items = this.convertHistoryEntry(entry); + for (const item of items) { + this.pushEvent({ + type: "timeline", + item, + provider: "claude", + }); + } + } + + private normalizePersistedHistoryEntryToLiveMessage( + entry: Record + ): SDKMessage | null { + const taskNotificationMessage = + coerceTaskNotificationHistoryRecordToSystemMessage(entry); + if (taskNotificationMessage) { + return taskNotificationMessage as unknown as SDKMessage; + } + + const type = readTrimmedString(entry.type); + switch (type) { + case "assistant": + case "result": + case "stream_event": + case "system": + case "tool_progress": + case "user": + return entry as unknown as SDKMessage; + default: + return null; + } + } + private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; @@ -4624,13 +4748,9 @@ export function convertClaudeHistoryEntry( }]; } - if (entry.type === "system") { - const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall( - entry - ); - if (taskNotificationItem) { - return [taskNotificationItem]; - } + const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry); + if (taskNotificationItem) { + return [taskNotificationItem]; } if (entry.isCompactSummary) { diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index 1a1e25912..a983f7605 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -22,6 +22,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; +const APPENDED_TASK_NOTIFICATION_MARKER = "Appended background task completed"; +const APPENDED_ASSISTANT_MARKER = "APPENDED_BACKGROUND_ASSISTANT_MARKER"; function buildSdkQueryMock() { const events = [ @@ -70,6 +72,19 @@ function buildSdkQueryMock() { }; } +function buildIdleSdkQueryMock() { + return { + next: vi.fn(async () => ({ done: true, value: undefined })), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + }; +} + function collectTimelineText(events: AgentStreamEvent[]): string { const chunks: string[] = []; for (const event of events) { @@ -86,6 +101,28 @@ function collectTimelineText(events: AgentStreamEvent[]): string { return chunks.join("\n"); } +async function readNextEvent( + iterator: AsyncIterator, + timeoutMs: number +): Promise { + const outcome = await Promise.race([ + iterator.next().then((result) => ({ kind: "result" as const, result })), + new Promise<{ kind: "timeout" }>((resolve) => { + setTimeout(() => resolve({ kind: "timeout" }), timeoutMs); + }), + ]); + + if (outcome.kind === "timeout") { + throw new Error("Timed out waiting for live event"); + } + + if (outcome.result.done) { + throw new Error("Live event stream ended before appended transcript arrived"); + } + + return outcome.result.value; +} + describe("ClaudeAgentSession history replay regression", () => { let tempRoot: string; let cwd: string; @@ -215,6 +252,117 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); + test("emits appended transcript lines through streamLiveEvents after history was primed", async () => { + sdkMocks.query.mockImplementation(() => { + const mock = buildIdleSdkQueryMock(); + sdkMocks.lastQuery = mock; + return mock; + }); + + const logger = createTestLogger(); + const client = new ClaudeAgentClient({ logger }); + const handle: AgentPersistenceHandle = { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { + provider: "claude", + cwd, + }, + }; + + const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const historyPath = path.join( + configDir, + "projects", + sanitized, + "history-session.jsonl" + ); + + const session = await client.resumeSession(handle, { cwd }); + + try { + for await (const _event of session.streamHistory()) { + // Prime existing persisted history the same way agent-manager does. + } + + const liveEvents = session.streamLiveEvents(); + const iterator = liveEvents[Symbol.asyncIterator](); + + appendFileSync( + historyPath, + `\n${JSON.stringify({ + type: "queue-operation", + operation: "enqueue", + uuid: "appended-task-note-1", + content: [ + "", + "appended-bg-1", + "completed", + `${APPENDED_TASK_NOTIFICATION_MARKER}`, + "/tmp/appended-bg-1.txt", + "", + ].join("\n"), + })}\n${JSON.stringify({ + type: "assistant", + sessionId: "history-session", + cwd, + message: { + role: "assistant", + content: APPENDED_ASSISTANT_MARKER, + }, + })}`, + "utf8" + ); + + const appendedEvents: AgentStreamEvent[] = []; + for (let attempt = 0; attempt < 4; attempt += 1) { + appendedEvents.push(await readNextEvent(iterator, 1_500)); + const sawTaskNotification = appendedEvents.some( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ); + const sawAssistant = appendedEvents.some( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes(APPENDED_ASSISTANT_MARKER) + ); + if (sawTaskNotification && sawAssistant) { + break; + } + } + const timelineText = collectTimelineText(appendedEvents); + const taskNotificationEvent = appendedEvents.find( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ); + const turnStartedEvent = appendedEvents.find( + (event): event is Extract => + event.type === "turn_started" + ); + + expect(taskNotificationEvent).toBeTruthy(); + expect(turnStartedEvent).toBeTruthy(); + expect(timelineText).toContain(APPENDED_ASSISTANT_MARKER); + expect(taskNotificationEvent?.item.metadata).toMatchObject({ + taskId: "appended-bg-1", + status: "completed", + outputFile: "/tmp/appended-bg-1.txt", + }); + expect(taskNotificationEvent?.item.detail).toMatchObject({ + type: "plain_text", + label: APPENDED_TASK_NOTIFICATION_MARKER, + }); + } finally { + await session.close(); + } + }); + test("listCommands includes rewind command", async () => { const logger = createTestLogger(); const client = new ClaudeAgentClient({ logger }); diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index 17bcc9f9e..1017da89e 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -32,9 +32,10 @@ const TaskNotificationUserContentSchema = z.union([ z.array(TaskNotificationUserContentBlockSchema), ]); -const TaskNotificationSystemRecordSchema = z +const TaskNotificationHistoryRecordSchema = z .object({ - subtype: z.literal("task_notification"), + type: z.string().optional(), + subtype: z.string().optional(), uuid: z.string().optional(), message_id: z.string().optional(), task_id: z.string().optional(), @@ -57,13 +58,24 @@ export type MapTaskNotificationUserContentToToolCallInput = z.infer< >; type TaskNotificationUserContent = z.infer; -type TaskNotificationSystemRecord = z.infer; +type TaskNotificationHistoryRecord = z.infer; type TaskNotificationLifecycle = | { status: "completed"; error: null } | { status: "failed"; error: unknown } | { status: "canceled"; error: null }; +export type TaskNotificationSystemMessageLike = { + type: "system"; + subtype: "task_notification"; + uuid?: string; + task_id?: string; + status?: "completed" | "failed" | "stopped"; + summary?: string; + output_file?: string; + content?: string; +}; + type ReadTaskNotificationTagInput = { text: string; tagName: string; @@ -157,12 +169,18 @@ function parseTaskNotificationFromUserContent( function parseTaskNotificationFromSystemRecord( record: unknown ): TaskNotificationEnvelope | null { - const parsedRecord = TaskNotificationSystemRecordSchema.safeParse(record); + const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record); if (!parsedRecord.success) { return null; } - const systemRecord: TaskNotificationSystemRecord = parsedRecord.data; + const systemRecord: TaskNotificationHistoryRecord = parsedRecord.data; + const isSystemTaskNotification = + systemRecord.type === "system" && systemRecord.subtype === "task_notification"; + const isQueueOperation = systemRecord.type === "queue-operation"; + if (!isSystemTaskNotification && !isQueueOperation) { + return null; + } const rawText = toNonEmptyString(systemRecord.content); return TaskNotificationEnvelopeSchema.parse({ @@ -330,3 +348,31 @@ export function mapTaskNotificationSystemRecordToToolCall( } return toTaskNotificationToolCall(parsed); } + +export function coerceTaskNotificationHistoryRecordToSystemMessage( + record: unknown +): TaskNotificationSystemMessageLike | null { + const parsed = parseTaskNotificationFromSystemRecord(record); + if (!parsed) { + return null; + } + + const normalizedStatus = parsed.status?.toLowerCase() ?? null; + const status = + normalizedStatus === "failed" || normalizedStatus === "error" + ? "failed" + : normalizedStatus === "canceled" || normalizedStatus === "cancelled" + ? "stopped" + : "completed"; + + return { + type: "system", + subtype: "task_notification", + ...(parsed.messageId ? { uuid: parsed.messageId } : {}), + ...(parsed.taskId ? { task_id: parsed.taskId } : {}), + status, + ...(parsed.summary ? { summary: parsed.summary } : {}), + ...(parsed.outputFile ? { output_file: parsed.outputFile } : {}), + ...(parsed.rawText ? { content: parsed.rawText } : {}), + }; +} diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 7cf9e83d2..e2448aa7a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1109,36 +1109,44 @@ export async function getCheckoutShortstat( const configured = await getConfiguredBaseRefForCwd(cwd, context); const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd)); - if (!localBaseRef) { - return null; - } - const currentBranch = await getCurrentBranch(cwd); - if (currentBranch === localBaseRef) { - return null; - } - const comparisonBaseRef = await resolveBestComparisonBaseRef( - cwd, - normalizeLocalBranchRefName(localBaseRef) - ); + let diffTarget: string; - let mergeBase: string; - try { - const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, { + if (currentBranch && localBaseRef && currentBranch !== localBaseRef) { + // Feature branch: diff against the merge-base with the base branch + const comparisonBaseRef = await resolveBestComparisonBaseRef( cwd, - env: READ_ONLY_GIT_ENV, - }); - mergeBase = stdout.trim(); - if (!mergeBase) { + normalizeLocalBranchRefName(localBaseRef) + ); + + try { + const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const mergeBase = stdout.trim(); + if (!mergeBase) { + return null; + } + diffTarget = mergeBase; + } catch { return null; } - } catch { + } else if (currentBranch) { + // On the base branch (or no base ref configured): diff against remote tracking branch + const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`); + if (!hasOrigin) { + return null; + } + diffTarget = `origin/${currentBranch}`; + } else { return null; } try { - const { stdout } = await execAsync(`git diff --shortstat ${mergeBase} HEAD`, { + // Omit HEAD so the diff includes uncommitted (staged + unstaged) changes + const { stdout } = await execAsync(`git diff --shortstat ${diffTarget}`, { cwd, env: READ_ONLY_GIT_ENV, });