From 784899ffbfe4ef24f5103711bfd9c8ddbfe78ef2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:26:53 -0400 Subject: [PATCH 1/9] fix(chat): ended chats no longer resurrect via durable scheduled work Deliberate end (delete/archive chat, lane archive/delete, explicit dispose) now cancels the chat's durable schedules and ends the row as "disposed"; project close and graceful quit end live rows as "detached" so KV-persisted schedules survive for restart late-fire/cold resume. The scheduler's sessionState contract gains an "ended" state that cancels at reconcile, fire, claim, and upsert. Lane teardown also cancels schedules for sessions never rehydrated this run. finishSession clears pending native wakes, and terminal Claude teardown reaps the session's SDK subprocesses per-session. Co-Authored-By: Claude Fable 5 --- .../services/chat/agentChatService.test.ts | 413 +++++++++++++++++- .../main/services/chat/agentChatService.ts | 54 ++- .../chat/chatScheduledWorkScheduler.test.ts | 109 +++++ .../chat/chatScheduledWorkScheduler.ts | 2 +- .../chat/claudeSubprocessReaper.test.ts | 35 ++ .../services/chat/claudeSubprocessReaper.ts | 64 ++- docs/features/chat/README.md | 21 +- 7 files changed, 666 insertions(+), 32 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 57e5a40eb..889675583 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -799,6 +799,7 @@ import { createOrchestrationService } from "../orchestration/orchestrationServic import { runGit } from "../git/git"; import { deriveScheduledWorkSnapshots } from "../../../shared/chatScheduledWork"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; +import type { ChatScheduledWorkRecord, ChatScheduledWorkState } from "./chatScheduledWorkScheduler"; import { mapPermissionToCodex } from "./permissionMapping"; import { acquireCursorSdkConnection, releaseCursorSdkConnection } from "./cursorSdkPool"; import { acquireDroidSdkConnection } from "./droidSdkPool"; @@ -1211,7 +1212,7 @@ function createMockSessionService() { const sessionId = typeof args === "string" ? args : args?.sessionId; const row = sessions.get(sessionId); if (row) { - row.status = "ended"; + row.status = args?.status ?? "disposed"; row.endedAt = args?.endedAt ?? new Date().toISOString(); } }), @@ -1278,6 +1279,142 @@ function createMockProjectConfigService() { } as any; } +const SCHEDULED_WORK_STATE_KEY = "agent-chat:scheduled-work:v1"; +const SCHEDULE_TEST_START = Date.parse("2026-07-10T09:00:00.000Z"); + +function createScheduledWorkDb(initialState: ChatScheduledWorkState | null = null) { + const values = new Map(); + if (initialState) values.set(SCHEDULED_WORK_STATE_KEY, structuredClone(initialState)); + return { + db: { + getJson: vi.fn((key: string) => structuredClone(values.get(key) ?? null)), + setJson: vi.fn((key: string, value: unknown) => { + values.set(key, structuredClone(value)); + }), + }, + readState: (): ChatScheduledWorkState | null => { + const state = values.get(SCHEDULED_WORK_STATE_KEY); + return state ? structuredClone(state) as ChatScheduledWorkState : null; + }, + }; +} + +function storedWakeup( + sessionId: string, + overrides: Partial = {}, +): ChatScheduledWorkRecord { + return { + id: `wakeup:${sessionId}`, + sessionId, + kind: "wakeup", + prompt: "Check PR CI and report the result.", + reason: "Check PR CI", + fireAt: Date.now() + 60_000, + createdAt: Date.now(), + status: "scheduled", + pausedFlag: false, + lateFlag: false, + ...overrides, + }; +} + +function installClaudeWakeupFixture(args: { + sdkSessionId: string; + delaySeconds: number; + prompt?: string; +}) { + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + const handle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: args.sdkSessionId, + slash_commands: [], + }; + return; + } + yield { + type: "assistant", + message: { + content: [{ + type: "tool_use", + id: `tool-${args.sdkSessionId}`, + name: "ScheduleWakeup", + input: { + delaySeconds: args.delaySeconds, + reason: "Check PR CI", + prompt: args.prompt ?? "Check PR CI and report the result.", + }, + }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: args.sdkSessionId, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close, + sessionId: args.sdkSessionId, + setPermissionMode: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(handle as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(handle as any); + return { handle, send, close }; +} + +function installClaudeResponseFixture(args: { + sdkSessionId: string; + responseText: string; +}) { + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const handle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: args.sdkSessionId, + slash_commands: [], + }; + return; + } + yield { + type: "assistant", + message: { + content: [{ type: "text", text: args.responseText }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: args.sdkSessionId, + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: args.sdkSessionId, + setPermissionMode: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(handle as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(handle as any); + return { handle, send }; +} + function createService(overrides: Record = {}) { const logger = createLogger(); const laneService = createMockLaneService(); @@ -3031,6 +3168,7 @@ describe("createAgentChatService", () => { const claudeSubprocessReaper = { register: vi.fn(), spawnClaudeCodeProcess: vi.fn(() => spawnedProcess), + reapForSession: vi.fn(), reapAll: vi.fn(), liveRecords: vi.fn(() => []), }; @@ -3078,6 +3216,12 @@ describe("createAgentChatService", () => { cwd: expect.any(String), }), ); + + await service.dispose({ sessionId: session.id }); + expect(claudeSubprocessReaper.reapForSession).toHaveBeenCalledWith( + session.id, + "ended_session", + ); }); it("appends discovered project slash commands to the Claude system prompt", async () => { @@ -11138,6 +11282,140 @@ describe("createAgentChatService", () => { ); }); + it("dispose cancels durable schedules and emits cancelled scheduled_work_update", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-dispose-cancel", + delaySeconds: 60, + }); + const { service, sessionService } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "Check CI again later.", + }); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: session.id, status: "scheduled" }), + ]); + + await service.dispose({ sessionId: session.id }); + + expect(sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "disposed", + endedAt: expect.any(String), + })); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: session.id, status: "cancelled" }), + ]); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + sessionId: session.id, + event: expect.objectContaining({ + type: "scheduled_work_update", + status: "cancelled", + }), + }), + ])); + service.forceDisposeAll(); + }); + + it("a scheduled fire after dispose does not resume the session", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-dispose-no-fire", + delaySeconds: 1, + }); + const { service, sessionService } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "Wake once to check CI.", + }); + await service.dispose({ sessionId: session.id }); + const startedTurnsBeforeAdvance = events.filter((event) => + event.sessionId === session.id + && event.event.type === "status" + && event.event.turnStatus === "started" + ).length; + + await vi.advanceTimersByTimeAsync(65_000); + + expect(sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "disposed", + endedAt: expect.any(String), + })); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("cancelled"); + expect(events.filter((event) => + event.sessionId === session.id + && event.event.type === "status" + && event.event.turnStatus === "started" + )).toHaveLength(startedTurnsBeforeAdvance); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake != null + )).toBe(false); + service.forceDisposeAll(); + }); + + it("finishSession clears pending native scheduled wakes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + const events: AgentChatEventEnvelope[] = []; + installClaudeWakeupFixture({ + sdkSessionId: "sdk-pending-native-wake", + delaySeconds: 60, + prompt: "Stale native wake must not survive dispose.", + }); + const { service } = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "Queue a native wake.", + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("fired"); + expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(1); + + await service.dispose({ sessionId: session.id }); + + expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(0); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake?.reason === "Stale native wake must not survive dispose." + )).toBe(false); + service.forceDisposeAll(); + }); + it("evicts disposed chats from the live managed session cache", async () => { const { service } = createService(); const session = await service.createSession({ @@ -11265,6 +11543,7 @@ describe("createAgentChatService", () => { const claudeSubprocessReaper = { register: vi.fn(), spawnClaudeCodeProcess: vi.fn(), + reapForSession: vi.fn(), reapAll: vi.fn(), liveRecords: vi.fn(() => []), }; @@ -11274,6 +11553,138 @@ describe("createAgentChatService", () => { expect(claudeSubprocessReaper.reapAll).toHaveBeenCalledWith("dispose_all"); }); + + it("disposeAll ends sessions as detached and preserves scheduled work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + installClaudeWakeupFixture({ + sdkSessionId: "sdk-dispose-all-detached", + delaySeconds: 60, + }); + const { service, sessionService } = createService({ db: scheduledWork.db }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule work across restart.", + }); + + await service.disposeAll(); + + expect(sessionService.end).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.id, + status: "detached", + })); + expect(sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "detached", + endedAt: expect.any(String), + })); + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: session.id, status: "scheduled" }), + ]); + }); + + it("scheduled fire into a detached session still delivers by cold resume", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + const scheduledWork = createScheduledWorkDb(); + installClaudeWakeupFixture({ + sdkSessionId: "sdk-detached-cold-resume", + delaySeconds: 60, + prompt: "Deliver this wake after restart.", + }); + const first = createService({ db: scheduledWork.db }); + const session = await first.service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await first.service.runSessionTurn({ + sessionId: session.id, + text: "Schedule restart work.", + }); + await first.service.disposeAll(); + + const events: AgentChatEventEnvelope[] = []; + installClaudeResponseFixture({ + sdkSessionId: "sdk-detached-cold-resume", + responseText: "Cold scheduled work delivered.", + }); + const restarted = createService({ + db: scheduledWork.db, + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + await restarted.service.refreshScheduledWork(); + + await vi.advanceTimersByTimeAsync(60_000); + await vi.waitFor(() => { + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "user_message" + && event.event.metadata?.scheduledWake?.reason === "Check PR CI" + )).toBe(true); + }); + + expect(restarted.sessionService.get(session.id)).toEqual(expect.objectContaining({ + status: "running", + endedAt: null, + })); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "done" + && event.event.status === "completed" + )).toBe(true); + expect(scheduledWork.readState()?.schedules[0]?.status).toBe("done"); + restarted.service.forceDisposeAll(); + }); + }); + + describe("disposeForLane", () => { + it("cancels schedules for lane sessions including unmanaged ones", async () => { + vi.useFakeTimers(); + vi.setSystemTime(SCHEDULE_TEST_START); + mockState.sessions.set("unmanaged-lane-1", { + id: "unmanaged-lane-1", + laneId: "lane-1", + toolType: "claude-chat", + status: "running", + startedAt: new Date().toISOString(), + endedAt: null, + archivedAt: null, + }); + mockState.sessions.set("unmanaged-lane-2", { + id: "unmanaged-lane-2", + laneId: "lane-2", + toolType: "claude-chat", + status: "running", + startedAt: new Date().toISOString(), + endedAt: null, + archivedAt: null, + }); + const scheduledWork = createScheduledWorkDb({ + version: 1, + schedules: [ + storedWakeup("unmanaged-lane-1"), + storedWakeup("unmanaged-lane-2"), + storedWakeup("missing-session"), + ], + pausedSessionIds: [], + }); + const { service } = createService({ db: scheduledWork.db }); + + await expect(service.disposeForLane("lane-1")).resolves.toBe(0); + + expect(scheduledWork.readState()?.schedules).toEqual([ + expect.objectContaining({ sessionId: "missing-session", status: "cancelled" }), + expect.objectContaining({ sessionId: "unmanaged-lane-1", status: "cancelled" }), + expect.objectContaining({ sessionId: "unmanaged-lane-2", status: "scheduled" }), + ]); + service.forceDisposeAll(); + }); }); describe("forceDisposeAll", () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 44da697a8..5bab70be6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -12023,6 +12023,13 @@ export function createAgentChatService(args: { if (preserveProviderResumeState) persistChatState(managed); cancelClaudeWarmup(managed, managed.runtime, "teardown"); try { managed.runtime.query?.close(); } catch { /* ignore */ } + if ( + openCodeReason === "ended_session" + || openCodeReason === "handle_close" + || openCodeReason === "model_switch" + ) { + claudeSubprocessReaper.reapForSession(managed.session.id, openCodeReason); + } managed.runtime.inputPump?.close(); try { managed.runtime.warmQuery?.close(); } catch { /* ignore */ } managed.runtime.query = null; @@ -12221,6 +12228,7 @@ export function createAgentChatService(args: { status: TerminalSessionStatus, options?: { exitCode?: number | null; summary?: string | null } ): Promise => { + pendingNativeScheduledWakeBySession.delete(managed.session.id); if (managed.endedNotified) return; managed.endedNotified = true; clearSubagentSnapshots(managed.session.id); @@ -12233,6 +12241,16 @@ export function createAgentChatService(args: { } managed.localPendingInputs.clear(); + if (status === "disposed") { + await scheduledWorkReady; + if (scheduledWorkScheduler) { + await Promise.all( + scheduledWorkScheduler.list(managed.session.id).map((schedule) => + scheduledWorkScheduler!.cancel(schedule.id)), + ); + } + } + if (options?.summary !== undefined) { sessionService.setSummary(managed.session.id, options.summary); } @@ -28799,6 +28817,9 @@ export function createAgentChatService(args: { await scheduledWorkScheduler?.refreshGlobalPause(); }; + const pendingNativeScheduledWakeCountForTesting = (sessionId: string): number => + pendingNativeScheduledWakeBySession.get(sessionId)?.length ?? 0; + const hasActiveWorkloads = (): boolean => { for (const managed of managedSessions.values()) { if (managed.closed || managed.deleted) continue; @@ -28889,6 +28910,17 @@ export function createAgentChatService(args: { errors.push(`${sessionId}: ${error instanceof Error ? error.message : String(error)}`); } } + await scheduledWorkReady; + if (scheduledWorkScheduler) { + await Promise.all( + scheduledWorkScheduler.list() + .filter((schedule) => { + const row = sessionService.get(schedule.sessionId); + return !row || row.laneId === laneId; + }) + .map((schedule) => scheduledWorkScheduler!.cancel(schedule.id)), + ); + } if (errors.length > 0) { throw new Error(`Failed to close ${errors.length} chat session${errors.length === 1 ? "" : "s"}: ${errors.join("; ")}`); } @@ -30043,7 +30075,10 @@ export function createAgentChatService(args: { return await loadModelCatalogRequest(catalogArgs); }; - const dispose = async ({ sessionId }: AgentChatDisposeArgs): Promise => { + const disposeManagedSession = async ( + { sessionId }: AgentChatDisposeArgs, + terminalStatus: "disposed" | "detached", + ): Promise => { const managed = ensureManagedSession(sessionId); abortActiveBashControllers(managed, "Session disposed."); @@ -30139,11 +30174,15 @@ export function createAgentChatService(args: { cancelQueuedSteers(managed, managed.runtime, "disposed"); } - await finishSession(managed, "disposed", { + await finishSession(managed, terminalStatus, { summary: managed.preview ? `Session closed: ${managed.preview}` : "Session closed." }); }; + const dispose = async (args: AgentChatDisposeArgs): Promise => { + await disposeManagedSession(args, "disposed"); + }; + const deleteSession = async ({ sessionId }: AgentChatDeleteArgs): Promise => { const trimmedSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; if (!trimmedSessionId.length) { @@ -30256,7 +30295,7 @@ export function createAgentChatService(args: { scheduledWorkScheduler?.dispose(); for (const sessionId of [...managedSessions.keys()]) { try { - await dispose({ sessionId }); + await disposeManagedSession({ sessionId }, "detached"); } catch { // ignore shutdown errors } @@ -32828,7 +32867,9 @@ export function createAgentChatService(args: { sessionState: (sessionId) => { const row = sessionService.get(sessionId); if (!row) return "missing"; - return row.archivedAt ? "archived" : "active"; + if (row.archivedAt) return "archived"; + if (row.status === "running" || row.status === "detached") return "active"; + return "ended"; }, fire: async (schedule, context) => { const firedAt = new Date(schedule.lastFiredAt ?? Date.now()).toISOString(); @@ -32885,7 +32926,9 @@ export function createAgentChatService(args: { const row = sessionService.get(schedule.sessionId); if (!row || row.archivedAt) return; const managed = managedSessions.get(schedule.sessionId) - ?? (status === "fired" ? ensureManagedSession(schedule.sessionId) : null); + ?? (status === "fired" && (row.status === "running" || row.status === "detached") + ? ensureManagedSession(schedule.sessionId) + : null); if (!managed || managed.session.provider !== "claude") return; const eventStatus: ScheduledWorkEvent["status"] = status === "done" ? "completed" : status; @@ -32936,6 +32979,7 @@ export function createAgentChatService(args: { messageSession, setScheduledWorkPaused, refreshScheduledWork, + pendingNativeScheduledWakeCountForTesting, readTranscript, setOrchestrationFields, getCodexGoal, diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts index 54847b7aa..ccdcf2ba9 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.test.ts @@ -49,6 +49,115 @@ afterEach(() => { }); describe("createChatScheduledWorkScheduler", () => { + it("cancels persisted work for an ended session during start reconciliation", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let state: ChatScheduledWorkState | null = storedState([wakeup()]); + const transitions: string[] = []; + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => cloneState(state), + saveState: (next) => { + state = structuredClone(next); + }, + isGlobalPaused: () => false, + sessionState: () => "ended", + fire, + onTransition: (_schedule, status) => { + transitions.push(status); + }, + }); + + await scheduler.start(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(fire).not.toHaveBeenCalled(); + expect(transitions).toEqual(["cancelled"]); + expect(requireState(state).schedules[0]).toEqual(expect.objectContaining({ + id: "wake-1", + status: "cancelled", + })); + scheduler.dispose(); + }); + + it("cancels work when its session becomes ended before processDue fires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let sessionState: "active" | "ended" = "active"; + let state: ChatScheduledWorkState | null = null; + const transitions: string[] = []; + const fire = createFireMock(); + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => cloneState(state), + saveState: (next) => { + state = structuredClone(next); + }, + isGlobalPaused: () => false, + sessionState: () => sessionState, + fire, + onTransition: (_schedule, status) => { + transitions.push(status); + }, + }); + await scheduler.upsert(wakeup({ fireAt: START + 1_000 })); + + sessionState = "ended"; + await vi.advanceTimersByTimeAsync(1_000); + + expect(fire).not.toHaveBeenCalled(); + expect(transitions).toEqual(["scheduled", "cancelled"]); + expect(requireState(state).schedules[0]?.status).toBe("cancelled"); + scheduler.dispose(); + }); + + it("does not let an ended session claim a native scheduled fire", async () => { + vi.useFakeTimers(); + vi.setSystemTime(START); + let sessionState: "active" | "ended" = "active"; + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: () => undefined, + isGlobalPaused: () => false, + sessionState: () => sessionState, + fire: createFireMock(), + }); + await scheduler.upsert(wakeup({ fireAt: START })); + + sessionState = "ended"; + const claimed = scheduler.claimNativeFire("session-1", "native-turn-ended"); + + expect(claimed).toBeNull(); + expect(scheduler.list("session-1")[0]).toEqual(expect.objectContaining({ + id: "wake-1", + status: "scheduled", + })); + scheduler.dispose(); + }); + + it("persists a new schedule as cancelled when its session is ended", async () => { + let state: ChatScheduledWorkState | null = null; + const transitions: string[] = []; + const scheduler = createChatScheduledWorkScheduler({ + loadState: () => null, + saveState: (next) => { + state = structuredClone(next); + }, + isGlobalPaused: () => false, + sessionState: () => "ended", + fire: createFireMock(), + onTransition: (_schedule, status) => { + transitions.push(status); + }, + }); + + const schedule = await scheduler.upsert(wakeup()); + + expect(schedule.status).toBe("cancelled"); + expect(transitions).toEqual(["cancelled"]); + expect(requireState(state).schedules[0]?.status).toBe("cancelled"); + scheduler.dispose(); + }); + it("persists an arm and re-arms it in a fresh scheduler after restart", async () => { vi.useFakeTimers(); vi.setSystemTime(START); diff --git a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts index 906ecf6eb..8ca7ba2dc 100644 --- a/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts +++ b/apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts @@ -55,7 +55,7 @@ export type ChatScheduledWorkSchedulerOptions = { now?: () => number; timers?: ChatScheduledWorkTimerApi; isGlobalPaused(): boolean; - sessionState(sessionId: string): "active" | "archived" | "missing"; + sessionState(sessionId: string): "active" | "ended" | "archived" | "missing"; fire(schedule: ChatScheduledWorkRecord, context: { late: boolean }): Promise; onTransition?: ( schedule: ChatScheduledWorkRecord, diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts index f0df4a914..7b960826f 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts @@ -144,6 +144,41 @@ describe("createClaudeSubprocessReaper", () => { expect(child.killedWith).toEqual(["SIGTERM", "SIGKILL"]); }); + it("reapForSession terminates only the matching session's live subprocesses", () => { + vi.useFakeTimers(); + const logger = createLogger(); + const matchingChild = createProcess(2469); + const otherChild = createProcess(2470); + const reaper = createClaudeSubprocessReaper({ + logger, + killGraceMs: 25, + }); + reaper.register(matchingChild, { + sessionId: "chat-matching", + laneId: "lane-1", + cwd: "/tmp/lane-1", + }, "claude", []); + reaper.register(otherChild, { + sessionId: "chat-other", + laneId: "lane-2", + cwd: "/tmp/lane-2", + }, "claude", []); + + reaper.reapForSession("chat-matching", "ended_session"); + + expect(matchingChild.killedWith).toEqual(["SIGTERM"]); + expect(otherChild.killedWith).toEqual([]); + expect(reaper.liveRecords().map((record) => record.sessionId)).toEqual([ + "chat-matching", + "chat-other", + ]); + + vi.advanceTimersByTime(25); + + expect(matchingChild.killedWith).toEqual(["SIGTERM", "SIGKILL"]); + expect(otherChild.killedWith).toEqual([]); + }); + it("reaps subprocesses left behind by a crashed ADE owner", () => { vi.useFakeTimers(); const logger = createLogger(); diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts index 9dcb5c0bc..55630b234 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts @@ -22,6 +22,11 @@ export type ClaudeSubprocessRecord = ClaudeSubprocessMetadata & { }; type ClaudeChildProcess = ChildProcessByStdio; +type LiveClaudeSubprocess = { + record: ClaudeSubprocessRecord; + process: SpawnedProcess; + killTimer: ReturnType | null; +}; export type ClaudeSubprocessReaper = ReturnType; @@ -43,7 +48,7 @@ export function createClaudeSubprocessReaper(args: { ? null : args.registryPath ?? path.join(os.tmpdir(), "ade-claude-subprocesses.json"); const processKill = args.processKill ?? ((pid: number, signal?: NodeJS.Signals | 0) => process.kill(pid, signal as NodeJS.Signals | undefined)); - const live = new Map | null }>(); + const live = new Map(); const readRegistry = (): ClaudeSubprocessRecord[] => { if (!registryPath || !fs.existsSync(registryPath)) return []; @@ -226,43 +231,58 @@ export function createClaudeSubprocessReaper(args: { return child; }; - const reapAll = (reason: string): void => { - for (const [pid, entry] of live) { - const child = entry.process; + const terminateLiveEntry = ( + pid: number, + entry: LiveClaudeSubprocess, + reason: string, + ): void => { + const child = entry.process; + if (child.killed || child.exitCode !== null || entry.killTimer) return; + logger.warn("agent_chat.claude_subprocess_terminate", { + pid, + sessionId: entry.record.sessionId, + reason, + }); + try { + child.kill("SIGTERM"); + } catch { + // Best effort; the process may already be gone. + } + entry.killTimer = setTimer(() => { if (!child.killed && child.exitCode === null) { - logger.warn("agent_chat.claude_subprocess_terminate", { + logger.warn("agent_chat.claude_subprocess_kill", { pid, sessionId: entry.record.sessionId, reason, }); try { - child.kill("SIGTERM"); + child.kill("SIGKILL"); } catch { // Best effort; the process may already be gone. } - entry.killTimer = setTimer(() => { - if (!child.killed && child.exitCode === null) { - logger.warn("agent_chat.claude_subprocess_kill", { - pid, - sessionId: entry.record.sessionId, - reason, - }); - try { - child.kill("SIGKILL"); - } catch { - // Best effort; the process may already be gone. - } - } - removeRegistryPid(pid); - }, killGraceMs); - entry.killTimer.unref?.(); } + removeRegistryPid(pid); + }, killGraceMs); + entry.killTimer.unref?.(); + }; + + const reapForSession = (sessionId: string, reason: string): void => { + for (const [pid, entry] of live) { + if (entry.record.sessionId !== sessionId) continue; + terminateLiveEntry(pid, entry, reason); + } + }; + + const reapAll = (reason: string): void => { + for (const [pid, entry] of live) { + terminateLiveEntry(pid, entry, reason); } }; return { register, spawnClaudeCodeProcess, + reapForSession, reapAll, reapStaleRegistry, liveRecords: (): ClaudeSubprocessRecord[] => [...live.values()].map((entry) => ({ ...entry.record })), diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index f96d1f9b9..2c30c41e3 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -134,8 +134,17 @@ host was asleep or the runtime was down, an overdue one-shot fires once with then computes its next ordinary cron occurrence from the current time; ADE does not replay every missed interval. Paused schedules remain armed. Work that became overdue while either the chat or global pause was active follows -the same one-late-fire rule after resume. Archiving or deleting the owning -chat cancels its schedules. +the same one-late-fire rule after resume. + +Session teardown distinguishes deliberate end from runtime lifecycle. Deleting, +archiving, or explicitly disposing a chat, and archiving or deleting its lane, +cancels every durable schedule owned by that chat. Project close and graceful +app quit instead end live chat rows as `detached`; their schedules remain armed +so restart reconciliation can late-fire overdue work and cold-resume the chat. +The scheduler's `sessionState` contract treats `running` and `detached` rows as +`active`, any other non-archived terminal row as `ended`, archived rows as +`archived`, and absent rows as `missing`. Ended, archived, and missing owners +are cancelled during reconciliation or before delivery. Delivery reuses the session peer-message path with `kind: "wake"`. A live, idle Claude query is resumed through its existing idle reader; otherwise the @@ -516,7 +525,13 @@ happen to begin with `User request:`. 5. On completion the service emits `status: "completed" | "failed" | "interrupted"`, optionally emits a `turn_diff_summary`, flushes buffered text, and pulls the next queued steer. -6. `dispose({ sessionId })` ends the runtime and persists the final state. +6. `dispose({ sessionId })` deliberately ends the runtime, persists the final + state as `disposed`, and cancels the chat's durable scheduled work. Project + close and graceful app quit use the lifecycle variant: live rows become + `detached`, provider runtimes are torn down, and durable schedules remain for + restart reconciliation and cold resume. Lane archive/delete additionally + cancels schedules for every session owned by that lane, including sessions + that were not rehydrated into the current runtime. Parallel launch is a renderer-orchestrated workflow layered on the same session primitives: From 70047568099f0d7f55c0908cfb338abd48e2bfe9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:37:13 -0400 Subject: [PATCH 2/9] docs(chat): note the sync-saveState ordering assumption in finishSession schedule cancel Co-Authored-By: Claude Fable 5 --- apps/desktop/src/main/services/chat/agentChatService.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 5bab70be6..f654003a6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -12244,6 +12244,12 @@ export function createAgentChatService(args: { if (status === "disposed") { await scheduledWorkReady; if (scheduledWorkScheduler) { + // This cancel snapshot runs while the session row still reads "running", + // so a racing in-flight-turn upsert could slip past it. That is safe only + // because saveState is synchronous (better-sqlite3): the awaits resolve on + // microtasks, sessionService.end below runs before any timer fires, and + // the leaked schedule cancels at fire time via sessionState === "ended". + // If saveState ever becomes async I/O, mark the row terminal first. await Promise.all( scheduledWorkScheduler.list(managed.session.id).map((schedule) => scheduledWorkScheduler!.cancel(schedule.id)), From 3f57c6fff9fc4910ac244ab8947463b76ddb467f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:18:13 -0400 Subject: [PATCH 3/9] feat(chat): scale the actions pane to 60+ subagents; copy-whole-turn Shared stable partition (groupPaneSectionItems) + caps + Earlier/Show-all/ Clear-Restore semantics in shared/chatSubagents.ts and chatScheduledWork.ts, consumed by all three surfaces. Desktop pane owns a single scroller with sticky section headers, collapsible-when-worthy headers, per-session localStorage view/clear state, and generalizes the Schedule History fold into one Earlier idiom. ADE Code TUI renders the grouped row model with tagged mouse targets and keyboard controls; iOS mirrors the predicates, caps, fired/late decoding, and persisted disclosure state in the Chat Info sheet. Small sections render exactly as before (no new chrome). Also: one-click "Copy turn" on the last text block of multi-block assistant turns, keyed off turnId. Co-Authored-By: Claude Fable 5 --- .../tuiClient/__tests__/RightPane.test.tsx | 48 +- .../tuiClient/__tests__/subagentPane.test.ts | 51 +- apps/ade-cli/src/tuiClient/app.tsx | 122 +++- .../src/tuiClient/components/RightPane.tsx | 222 +++--- apps/ade-cli/src/tuiClient/subagentPane.ts | 6 + .../chat/AgentChatMessageList.test.tsx | 57 ++ .../components/chat/AgentChatMessageList.tsx | 59 +- .../components/chat/AgentChatPane.tsx | 4 +- .../chat/ChatSubagentsPanel.test.tsx | 94 ++- .../components/chat/ChatSubagentsPanel.tsx | 690 +++++++++++++++--- .../src/shared/chatScheduledWork.test.ts | 11 + apps/desktop/src/shared/chatScheduledWork.ts | 13 + apps/desktop/src/shared/chatSubagents.test.ts | 69 ++ apps/desktop/src/shared/chatSubagents.ts | 324 ++++++-- apps/ios/ADE/Models/RemoteModels.swift | 6 +- .../Views/Work/WorkChatRichCardViews.swift | 369 +++++++++- .../Work/WorkErrorAndMessageHelpers.swift | 4 +- .../ios/ADE/Views/Work/WorkEventMapping.swift | 4 +- apps/ios/ADE/Views/Work/WorkModels.swift | 4 +- .../Work/WorkSessionDestinationView.swift | 1 + .../ADE/Views/Work/WorkTimelineHelpers.swift | 35 +- .../ADE/Views/Work/WorkTranscriptParser.swift | 2 + apps/ios/ADETests/ADETests.swift | 36 +- docs/features/chat/README.md | 6 +- docs/features/chat/composer-and-ui.md | 19 +- 25 files changed, 1958 insertions(+), 298 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 8002767f7..ce0f4d6b6 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -191,6 +191,45 @@ describe("RightPane chat info", () => { expect(frame).toContain("agent-07"); }); + it("windows grouped roster rows while keeping Show all and Earlier affordances reachable", () => { + const snapshots: ChatInfoSnapshot["snapshots"] = [ + ...Array.from({ length: 13 }, (_, index) => ({ + id: `run-${index}`, + name: `running-${index}`, + kind: "subagent" as const, + status: "running" as const, + summary: "working", + })), + { id: "done-1", name: "completed-agent", kind: "subagent", status: "completed", summary: "done" }, + ]; + const collapsed = render( + , + ); + const collapsedFrame = stripAnsi(collapsed.lastFrame() ?? ""); + + expect(collapsedFrame).toContain("+ show all (1)"); + expect(collapsedFrame).toContain("▸ earlier (1)"); + expect(collapsedFrame).toMatch(/↑\s+\d+\s+earlier/); + expect(collapsedFrame).not.toContain("completed-agent"); + + const expanded = render( + , + ); + expect(stripAnsi(expanded.lastFrame() ?? "")).toContain("completed-agent"); + }); + it("separates foreground subagents from background tasks with section headers", () => { const result = render( { }); it("caps scheduled work rows in narrow chat info panes", () => { - const scheduledWork: ChatInfoSnapshot["scheduledWork"] = Array.from({ length: 7 }, (_, index) => { + const scheduledWork: ChatInfoSnapshot["scheduledWork"] = Array.from({ length: 12 }, (_, index) => { const ordinal = String(index + 1).padStart(2, "0"); return { id: `wake-${ordinal}`, @@ -355,9 +394,9 @@ describe("RightPane chat info", () => { expect(frame).toContain("SCHEDULE"); expect(frame).toContain("Wakeup 01"); - expect(frame).toContain("Wakeup 05"); - expect(frame).not.toContain("Wakeup 06"); - expect(frame).toContain("↓ 2 more"); + expect(frame).toContain("Wakeup 10"); + expect(frame).not.toContain("Wakeup 11"); + expect(frame).toContain("+ show all (2)"); expect(longestLine).toBeLessThanOrEqual(44); }); @@ -391,6 +430,7 @@ describe("RightPane chat info", () => { ], }), }} + subagentPaneViewState={{ earlierExpanded: { schedule: true } }} focused width={80} />, diff --git a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts index 9c66c681f..b01bca958 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts @@ -3,6 +3,7 @@ import { buildSubagentPaneRows, buildSubagentTranscriptEvents, selectedSubagentSnapshot, + SUBAGENT_PANE_ROSTER_CAPACITY, subagentIndexForPaneLine, subagentPaneSelectableLineOffsets, subagentTranscriptMessagesToEvents, @@ -12,6 +13,7 @@ import { resolveSubagentCapability } from "../../../../desktop/src/shared/subage import { renderChatLines } from "../format"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { RightPaneContent } from "../types"; +import type { SubagentSnapshot } from "../types"; const session: AgentChatSessionSummary = { sessionId: "s1", @@ -82,17 +84,54 @@ describe("subagent pane helpers", () => { const offsets = subagentPaneSelectableLineOffsets(content, 1); expect(offsets.length).toBe(5); - expect(subagentIndexForPaneLine(content, offsets[0]!, 1)).toBe(0); - expect(subagentIndexForPaneLine(content, offsets[1]!, 1)).toBe(1); - expect(subagentIndexForPaneLine(content, offsets[3]!, 1)).toBe(3); - expect(subagentIndexForPaneLine(content, offsets[4]! + 1, 1)).toBe(4); + expect(subagentIndexForPaneLine(content, offsets[0]!, 1)).toEqual({ type: "snapshot", index: 0 }); + expect(subagentIndexForPaneLine(content, offsets[1]!, 1)).toEqual({ type: "snapshot", index: 1 }); + expect(subagentIndexForPaneLine(content, offsets[3]!, 1)).toEqual({ type: "snapshot", index: 3 }); + expect(subagentIndexForPaneLine(content, offsets[4]!, 1)).toEqual({ type: "snapshot", index: 4 }); expect(subagentIndexForPaneLine(content, offsets[0]! - 2, 1)).toBeNull(); }); it("only accounts for detail lines on the selected subagent row", () => { const content = rosterPaneContent(); - expect(subagentPaneSelectableLineOffsets(content, 1)).toEqual([4, 8, 10, 13, 16]); - expect(subagentPaneSelectableLineOffsets(content, 2)).toEqual([4, 8, 9, 13, 16]); + expect(subagentPaneSelectableLineOffsets(content, 1)).toEqual([0, 2, 4, 5, 6]); + expect(subagentPaneSelectableLineOffsets(content, 2)).toEqual([0, 2, 3, 5, 6]); + }); + + it("emits grouped section, show-all, Earlier, and tagged action rows", () => { + const snapshots: SubagentSnapshot[] = Array.from({ length: 13 }, (_, index) => ({ + id: `run-${index}`, + name: `run ${index}`, + kind: "subagent" as const, + status: "running" as const, + summary: "working", + })); + snapshots.push({ id: "done", name: "done", kind: "subagent", status: "completed", summary: "done" }); + const content = { snapshots }; + const rows = buildSubagentPaneRows(content, {}); + + expect(rows.find((row) => row.kind === "section-header")).toMatchObject({ + section: "subagents", + activeCount: 13, + earlierCount: 1, + collapsible: true, + }); + expect(rows.find((row) => row.kind === "show-all")).toMatchObject({ hiddenCount: 1 }); + expect(rows.find((row) => row.kind === "earlier-toggle")).toMatchObject({ count: 1, expanded: false }); + + const actionTargets = Array.from({ length: 40 }, (_, line) => subagentIndexForPaneLine(content, line, 0, {})) + .filter((target) => target && target.type !== "snapshot"); + expect(actionTargets).toContainEqual({ type: "show-all", section: "subagents" }); + expect(actionTargets).toContainEqual({ type: "toggle-earlier", section: "subagents" }); + + const windowedTargets = Array.from({ length: 20 }, (_, line) => subagentIndexForPaneLine( + content, + line, + 12, + {}, + SUBAGENT_PANE_ROSTER_CAPACITY, + )).filter((target) => target && target.type !== "snapshot"); + expect(windowedTargets).toContainEqual({ type: "show-all", section: "subagents" }); + expect(windowedTargets).toContainEqual({ type: "toggle-earlier", section: "subagents" }); }); it("builds a focused transcript without unrelated subagent output", () => { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 61b1966a4..7f266eff9 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -309,10 +309,14 @@ import { copyToClipboard } from "../lib/clipboard"; import { buildSubagentPaneRows, buildSubagentTranscriptEvents, + SUBAGENT_PANE_ROSTER_CAPACITY, subagentIndexForPaneLine, subagentPaneContentFromRightPane, subagentTranscriptMessagesToEvents, + type SubagentPaneDisclosureSection, type SubagentPaneRow, + type SubagentPaneTarget, + type SubagentPaneViewState, } from "./subagentPane"; import { readClaudeStatusLineConfig, runClaudeStatusLineCommand } from "./statusline"; import { @@ -2893,6 +2897,46 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [formValues, setFormValues] = useState>({}); const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); + const [subagentPaneViewStateBySessionId, setSubagentPaneViewStateBySessionId] = useState>({}); + const subagentPaneViewState = activeSessionId ? (subagentPaneViewStateBySessionId[activeSessionId] ?? {}) : {}; + const updateSubagentPaneViewState = useCallback((update: (current: SubagentPaneViewState) => SubagentPaneViewState) => { + if (!activeSessionId) return; + setSubagentPaneViewStateBySessionId((current) => ({ + ...current, + [activeSessionId]: update(current[activeSessionId] ?? {}), + })); + }, [activeSessionId]); + const activateSubagentPaneTarget = useCallback((target: SubagentPaneTarget, resumeOffset: number) => { + if (target.type === "snapshot") { + setRightSelectionIndex(target.index + resumeOffset); + return; + } + if (target.type === "toggle-section") { + updateSubagentPaneViewState((current) => ({ + ...current, + collapsed: { ...current.collapsed, [target.section]: current.collapsed?.[target.section] !== true }, + })); + return; + } + if (target.type === "toggle-earlier") { + updateSubagentPaneViewState((current) => ({ + ...current, + earlierExpanded: { ...current.earlierExpanded, [target.section]: current.earlierExpanded?.[target.section] !== true }, + })); + return; + } + if (target.type === "show-all") { + updateSubagentPaneViewState((current) => ({ + ...current, + showAll: { ...current.showAll, [target.section]: true }, + })); + return; + } + updateSubagentPaneViewState((current) => ({ + ...current, + cleared: { ...current.cleared, [target.section]: [] }, + })); + }, [updateSubagentPaneViewState]); const [rightChatsClosedExpanded, setRightChatsClosedExpanded] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); @@ -3917,9 +3961,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // visible (0 = resume, 1 = main, …). Clamp prior selection back into range // when the roster shrinks (e.g., a subagent finishes and is reaped). const resumeOffset = rightPane.kind === "chat-info" ? chatInfoSelectionOffset(rightPane.info) : 0; - const rowCount = buildSubagentPaneRows(content).filter((row) => row.kind === "snapshot").length + resumeOffset; + const rowCount = buildSubagentPaneRows(content, subagentPaneViewState).filter((row) => row.kind === "snapshot").length + resumeOffset; setRightSelectionIndex((index) => Math.max(0, Math.min(Number.isFinite(index) ? Math.floor(index) : 0, rowCount))); - }, [rightPane]); + }, [rightPane, subagentPaneViewState]); useEffect(() => { if (!inspectedSubagentId) return; if (rightPane.kind !== "chat-info" || !rightOpen || !subagentSnapshots.some((snap) => snap.id === inspectedSubagentId)) { @@ -12157,10 +12201,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const resumeOffset = chatInfoSelectionOffset(rightPane.info); const subagentPaneTop = 4 + goalBannerRows + addModeRows + (resumeOffset ? CHAT_INFO_RESUME_ROW_LINES : 0); const subagentContent = subagentPaneContentFromRightPane(rightPane); - const nextIndex = subagentContent - ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset) + const target = subagentContent + ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset, subagentPaneViewState, SUBAGENT_PANE_ROSTER_CAPACITY) : null; - if (nextIndex != null) setRightSelectionIndex(nextIndex + resumeOffset); + if (target) activateSubagentPaneTarget(target, resumeOffset); } return; } @@ -12292,12 +12336,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const resumeOffset = chatInfoSelectionOffset(rightPane.info); const subagentPaneTop = 4 + goalBannerRows + addModeRows + (resumeOffset ? CHAT_INFO_RESUME_ROW_LINES : 0); const subagentContent = subagentPaneContentFromRightPane(rightPane); - const nextIndex = subagentContent - ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset) + const target = subagentContent + ? subagentIndexForPaneLine(subagentContent, mouse.y - subagentPaneTop, rightSelectionIndex - resumeOffset, subagentPaneViewState, SUBAGENT_PANE_ROSTER_CAPACITY) : null; - if (nextIndex != null) { - setRightSelectionIndex(nextIndex + resumeOffset); - } + if (target) activateSubagentPaneTarget(target, resumeOffset); setRightOpen(true); setPaneFocus("details"); } @@ -13486,15 +13528,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } const killWorkerKey = key.ctrl && !key.meta && input.toLowerCase() === "k"; + const chatInfoDisclosureKey = !key.ctrl && !key.meta && !key.shift + ? input.toLowerCase() + : ""; if ( pane === "details" && rightOpen && rightPane.kind === "chat-info" - && (key.upArrow || key.downArrow || key.return || killWorkerKey) + && (key.upArrow || key.downArrow || key.return || killWorkerKey || ["c", "e", "a", "x"].includes(chatInfoDisclosureKey)) ) { const subagentContent = subagentPaneContentFromRightPane(rightPane); if (!subagentContent) return; - const snapshotRows = buildSubagentPaneRows(subagentContent) + const paneRows = buildSubagentPaneRows(subagentContent, subagentPaneViewState); + const snapshotRows = paneRows .filter((row): row is Extract => row.kind === "snapshot"); // Selection: 0 = main row; 1..N = subagent rows — shifted down by one // when the resume row is visible (0 = resume, 1 = main, …). @@ -13503,6 +13549,48 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const resumeRowSelected = resumeOffset === 1 && rightSelectionIndex === 0; const selectedRow = rightSelectionIndex > resumeOffset ? snapshotRows[rightSelectionIndex - 1 - resumeOffset] : null; const selectedSnapshot: SubagentSnapshot | null = selectedRow ? selectedRow.snapshot : null; + const focusedSection: SubagentPaneDisclosureSection = selectedRow?.section + ?? (paneRows.find((row): row is Extract => row.kind === "section-header")?.section ?? "subagents"); + const focusedHeader = paneRows.find((row): row is Extract => ( + row.kind === "section-header" && row.section === focusedSection + )); + if (chatInfoDisclosureKey === "c") { + if (focusedHeader?.collapsible) { + activateSubagentPaneTarget({ type: "toggle-section", section: focusedSection }, resumeOffset); + if (!focusedHeader.collapsed) setRightSelectionIndex(resumeOffset); + } + return; + } + if (chatInfoDisclosureKey === "e") { + if (focusedHeader && (focusedHeader.earlierCount > 0 || focusedHeader.clearedCount > 0)) { + activateSubagentPaneTarget({ type: "toggle-earlier", section: focusedSection }, resumeOffset); + } + return; + } + if (chatInfoDisclosureKey === "a") { + if (paneRows.some((row) => row.kind === "show-all" && row.section === focusedSection)) { + activateSubagentPaneTarget({ type: "show-all", section: focusedSection }, resumeOffset); + } + return; + } + if (chatInfoDisclosureKey === "x") { + const clearIds = paneRows + .filter((row): row is Extract => ( + row.kind === "snapshot" && row.section === focusedSection && row.group === "earlier" + )) + .map((row) => row.snapshot.id); + if (clearIds.length) { + updateSubagentPaneViewState((current) => ({ + ...current, + cleared: { + ...current.cleared, + [focusedSection]: [...new Set([...(current.cleared?.[focusedSection] ?? []), ...clearIds])], + }, + })); + setRightSelectionIndex(resumeOffset); + } + return; + } // ^k — stop the selected Droid AGI worker. The Droid worker subagent id IS // its workerSessionId (see droidSdkEventMapper.mission_worker_started). if (killWorkerKey) { @@ -14949,13 +15037,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } if (subagentContent) { for (let y = rightBodyTop; y <= Math.max(rightBodyTop, rows - 2); y += 1) { - const index = subagentIndexForPaneLine(subagentContent, y - subagentPaneTop, rightSelectionIndex - resumeOffset); - if (index == null) continue; + const target = subagentIndexForPaneLine(subagentContent, y - subagentPaneTop, rightSelectionIndex - resumeOffset, subagentPaneViewState, SUBAGENT_PANE_ROSTER_CAPACITY); + if (!target) continue; + const targetKey = target.type === "snapshot" ? `${target.type}:${target.index}` : `${target.type}:${target.section}`; addTarget({ - id: `right:chat-info:${index + resumeOffset}:${y}`, + id: `right:chat-info:${targetKey}:${y}`, rect: { x: rightStartColumn, y, w: rightPaneWidth, h: 1 }, onClick: () => { - setRightSelectionIndex(index + resumeOffset); + activateSubagentPaneTarget(target, resumeOffset); setRightOpen(true); setPaneFocus("details"); }, @@ -15504,6 +15593,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, activeProvider={activeCommandProvider as AdeCodeProvider} width={rightPaneWidth} scrollOffsetRows={rightPaneScrollOffsetRows} + subagentPaneViewState={subagentPaneViewState} modelPickerInputs={rightPaneModelPickerInputs} onModelPickerMeasureOrigin={handlePickerMeasureOrigin} /> diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 6da0e76e2..1ab4d36ee 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -20,10 +20,24 @@ import { } from "../externalSessionBrowser"; import { formatRelativePastTime } from "../relativeTime"; import { + isEarlierBackgroundItem, + isEarlierScheduleItem, backgroundCommandLabel, compactRelativeDuration, } from "../../../../desktop/src/shared/chatScheduledWork"; -import { buildSubagentPaneRows, type SubagentPaneRow } from "../subagentPane"; +import { + BACKGROUND_ACTIVE_CAP, + SCHEDULE_ACTIVE_CAP, + capPaneSectionItems, + groupPaneSectionItems, +} from "../../../../desktop/src/shared/chatSubagents"; +import { + buildSubagentPaneRows, + SUBAGENT_PANE_ROSTER_CAPACITY, + type SubagentPaneRow, + type SubagentPaneViewState, + windowSubagentPaneRows, +} from "../subagentPane"; import { ModelPickerPane } from "./ModelPicker/ModelPickerPane"; import { buildModelPickerLayout } from "./ModelPicker/modelPickerLayout"; import { TokenBar } from "./FooterControls"; @@ -794,36 +808,29 @@ export function ChatInfoResumeRow({ selected }: { selected: boolean }) { ); } -function rosterWindow(rowCount: number, selected: number, capacity: number): { start: number; end: number } { - if (rowCount <= capacity) return { start: 0, end: rowCount }; - const half = Math.floor(capacity / 2); - let start = Math.max(0, selected - half); - let end = start + capacity; - if (end > rowCount) { - end = rowCount; - start = end - capacity; - } - return { start, end }; -} - function ChatInfoRoster({ info, selectedIndex, brandColor, width, + viewState, }: { info: ChatInfoSnapshot; selectedIndex: number; brandColor: string; width: number; + viewState: SubagentPaneViewState; }) { const inner = Math.max(10, width - 4); - const snapshotRows = buildSubagentPaneRows(info) + const paneRows = buildSubagentPaneRows(info, viewState); + const snapshotRows = paneRows .filter((row): row is Extract => row.kind === "snapshot"); - const runCount = snapshotRows.filter((row) => row.snapshot.status === "running").length; - const doneCount = snapshotRows.filter((row) => row.snapshot.status === "completed").length; - const failedCount = snapshotRows.filter((row) => row.snapshot.status === "failed").length; - const bgCount = snapshotRows.filter((row) => row.section === "background").length; + const clearedIds = new Set(Object.values(viewState.cleared ?? {}).flatMap((ids) => [...(ids ?? [])])); + const countedSnapshots = info.snapshots.filter((snapshot) => !clearedIds.has(snapshot.id)); + const runCount = countedSnapshots.filter((snapshot) => snapshot.status === "running").length; + const doneCount = countedSnapshots.filter((snapshot) => snapshot.status === "completed").length; + const failedCount = countedSnapshots.filter((snapshot) => snapshot.status === "failed").length; + const bgCount = countedSnapshots.filter((snapshot) => snapshot.background === true).length; // Selection convention: 0 = main row; 1..N = subagent rows (1-indexed). // A negative index means the selection sits ABOVE the roster (the resume // row) — nothing in the roster highlights. @@ -840,13 +847,22 @@ function ChatInfoRoster({ bgCount ? `${bgCount} bg` : null, ].filter((value): value is string => value !== null).join(" · "); - const ROSTER_CAPACITY = 5; const subagentSelectedIndex = mainSelected ? -1 : selected - 1; const selectedSnapshot = !mainSelected ? (snapshotRows[subagentSelectedIndex]?.snapshot ?? null) : null; - const window = rosterWindow(snapshotRows.length, Math.max(0, subagentSelectedIndex), ROSTER_CAPACITY); - const visibleSlice = snapshotRows.slice(window.start, window.end); - const hiddenBefore = window.start; - const hiddenAfter = snapshotRows.length - window.end; + const selectedSection = !mainSelected ? snapshotRows[subagentSelectedIndex]?.section ?? null : null; + const selectedHeader = selectedSection + ? paneRows.find((row): row is Extract => row.kind === "section-header" && row.section === selectedSection) + : null; + const disclosureHints = selectedHeader ? [ + ...(selectedHeader.collapsible ? ["c section"] : []), + ...(selectedHeader.earlierCount > 0 || selectedHeader.clearedCount > 0 ? ["e earlier"] : []), + ...(paneRows.some((row) => row.kind === "show-all" && row.section === selectedSection) ? ["a all"] : []), + ] : []; + const { visibleRows: visibleSlice, hiddenBefore, hiddenAfter } = windowSubagentPaneRows( + paneRows, + selected, + SUBAGENT_PANE_ROSTER_CAPACITY, + ); return ( @@ -861,19 +877,31 @@ function ChatInfoRoster({ {showingMain ? "viewing" : "return ↵"} - {snapshotRows.length === 0 ? ( + {info.snapshots.length === 0 ? ( {" "}no subagents yet ) : ( <> {hiddenBefore > 0 ? ( {` ↑ ${hiddenBefore} earlier`} ) : null} - {visibleSlice.map((row, sliceIndex) => { - const rosterIndex = window.start + sliceIndex; - const previousSection = rosterIndex === 0 - ? "main" - : snapshotRows[rosterIndex - 1]?.section; - const showSection = row.section !== previousSection; + {visibleSlice.map((row) => { + if (row.kind === "section-header") { + return ; + } + if (row.kind === "earlier-toggle") { + return ( + + {` ${row.expanded ? "▾" : "▸"} earlier (${row.count})${row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}`} + + ); + } + if (row.kind === "show-all") { + return {` + show all (${row.hiddenCount})`}; + } + if (row.kind === "restore-cleared") { + return {` restore (${row.count})`}; + } + const rosterIndex = snapshotRows.findIndex((candidate) => candidate.key === row.key); const isSelected = !mainSelected && subagentSelectedIndex === rosterIndex; const kind = subagentAgentKind(row.snapshot.status); // Background rows get a cyan glyph tint so the eye can sort them out @@ -889,7 +917,6 @@ function ChatInfoRoster({ const detail = isSelected ? selectedRosterDetail(row.snapshot, info.capability) : null; return ( - {showSection ? : null} {isSelected ? theme.rail : " "} {` ${theme.agentStatusGlyph(kind)}`} @@ -912,7 +939,7 @@ function ChatInfoRoster({ )} - {rosterFooterHint(info, mainSelected, selectedSnapshot)} + {rosterFooterHint(info, mainSelected, selectedSnapshot, disclosureHints)} {info.mission ? : null} @@ -927,6 +954,7 @@ function rosterFooterHint( info: ChatInfoSnapshot, mainSelected: boolean, selectedSnapshot: SubagentSnapshot | null, + disclosureHints: string[], ): string { const parts = ["↑↓ focus"]; if (mainSelected) { @@ -942,6 +970,7 @@ function rosterFooterHint( ) { parts.push("^k kill"); } + parts.push(...disclosureHints); parts.push("esc → main"); return parts.join(" · "); } @@ -949,14 +978,16 @@ function rosterFooterHint( // Section heading for the roster — matches the 2-line allowance built into // `subagentPaneSelectableLineOffsets` (one blank-margin line + one title line) // so the mouse-click line-math stays accurate. -function RosterSectionHead({ section }: { section: SubagentPaneRow["section"] }) { - let label = "subagents"; - if (section === "background") label = "background"; - else if (section === "teammates") label = "teammates"; - const color = section === "background" ? theme.color.tool : theme.color.t4; +function RosterSectionHead({ row }: { row: Extract }) { + const color = row.section === "background" ? theme.color.tool : theme.color.t4; + const count = row.earlierCount + ? `${row.activeCount} · ${row.earlierCount} earlier` + : `${row.activeCount}`; return ( - {label} + + {row.collapsible ? (row.collapsed ? "▸ " : "▾ ") : ""}{row.label.toLowerCase()} {count}{row.clearedCount ? ` · ${row.clearedCount} hidden` : ""} + ); } @@ -1019,7 +1050,6 @@ function ChatInfoMissionBlock({ mission, width, brandColor }: { mission: Mission // Desktop ChatTasksPanel parity: latest todo_update snapshot. Rendered BELOW // the roster (like Mission) so the roster's click line-math stays intact. const TASKS_VISIBLE_CAP = 6; -const SCHEDULE_VISIBLE_CAP = 5; function scheduleStatusColor(status: ChatScheduledWorkSnapshot["status"]): string { if (status === "running" || status === "fired") return theme.color.running; @@ -1058,65 +1088,89 @@ function nextWakeCountdown(value: string | null | undefined, nowMs: number): str return compactRelativeDuration(Math.max(60_000, timestampMs - nowMs)); } -function ChatInfoScheduleBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { +function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: ChatInfoSnapshot; brandColor: string; width: number; viewState: SubagentPaneViewState }) { const nextWake = nextWakeCountdown(info.nextWakeAt, Date.now()); if (!info.scheduledWork.length && !nextWake) return null; const inner = Math.max(10, width - 4); - const visible = info.scheduledWork.slice(0, SCHEDULE_VISIBLE_CAP); - const hiddenAfter = info.scheduledWork.length - visible.length; + const clearedIds = new Set(viewState.cleared?.schedule ?? []); + const grouped = groupPaneSectionItems(info.scheduledWork, { + isEarlier: isEarlierScheduleItem, + isCleared: (item) => clearedIds.has(item.id), + isPinned: () => false, + }); + const capped = viewState.showAll?.schedule + ? { visible: grouped.active, hiddenCount: 0 } + : capPaneSectionItems(grouped.active, SCHEDULE_ACTIVE_CAP, (item) => item.status === "failed"); + const earlierExpanded = viewState.earlierExpanded?.schedule === true; + const renderItem = (item: ChatScheduledWorkSnapshot, earlier: boolean) => { + const detail = scheduleLineDetail(item); + const label = `${scheduleKindLabel(item.kind)} · ${item.status}${item.late ? " · late" : ""}`; + const titleBudget = Math.max(6, inner - label.length - 4); + return ( + + + {scheduleStatusGlyph(item.status)} {endTruncate(item.title, titleBudget)} {label} + + {detail ? ( + + {" "}{endTruncate(detail, inner - 2)} + + ) : null} + + ); + }; return ( - + {nextWake ? ( {` ⏰ next wake ${nextWake}`} ) : null} - {visible.map((item) => { - const detail = scheduleLineDetail(item); - // Mirror desktop's history row: a fired-behind-schedule wake reads `· late`. - const label = `${scheduleKindLabel(item.kind)} · ${item.status}${item.late ? " · late" : ""}`; - const titleBudget = Math.max(6, inner - label.length - 4); - return ( - - - {scheduleStatusGlyph(item.status)} {endTruncate(item.title, titleBudget)} {label} - - {detail ? ( - - {" "}{endTruncate(detail, inner - 2)} - - ) : null} - - ); - })} - {hiddenAfter > 0 ? {` ↓ ${hiddenAfter} more`} : null} + {capped.visible.map((item) => renderItem(item, false))} + {capped.hiddenCount > 0 ? {` + show all (${capped.hiddenCount})`} : null} + {grouped.earlier.length > 0 || grouped.clearedCount > 0 ? ( + {` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})${grouped.clearedCount ? ` · ${grouped.clearedCount} hidden` : ""}`} + ) : null} + {earlierExpanded ? grouped.earlier.map((item) => renderItem(item, true)) : null} + {earlierExpanded && grouped.clearedCount > 0 ? {` restore (${grouped.clearedCount})`} : null} ); } // Background command tasks — mirrors the desktop actions-pane Background // section. Each row: `$ status` (ASCII, one line). -function ChatInfoBackgroundBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { +function ChatInfoBackgroundBlock({ info, brandColor, width, viewState }: { info: ChatInfoSnapshot; brandColor: string; width: number; viewState: SubagentPaneViewState }) { if (!info.backgroundWork.length) return null; const inner = Math.max(10, width - 4); - const visible = info.backgroundWork.slice(0, SCHEDULE_VISIBLE_CAP); - const hiddenAfter = info.backgroundWork.length - visible.length; + const clearedIds = new Set(viewState.cleared?.background ?? []); + const grouped = groupPaneSectionItems(info.backgroundWork, { + isEarlier: isEarlierBackgroundItem, + isCleared: (item) => clearedIds.has(item.id), + isPinned: () => false, + }); + const capped = viewState.showAll?.background + ? { visible: grouped.active, hiddenCount: 0 } + : capPaneSectionItems(grouped.active, BACKGROUND_ACTIVE_CAP, (item) => item.status === "failed"); + const earlierExpanded = viewState.earlierExpanded?.background === true; + const renderItem = (item: ChatScheduledWorkSnapshot) => { + const raw = (item.title || item.prompt || item.summary || "").trim(); + const label = backgroundCommandLabel(raw) || raw || "background command"; + const status = ` ${item.status}`; + const labelBudget = Math.max(6, inner - status.length - 4); + return ( + + {"$ "}{endTruncate(label, labelBudget)} {item.status} + + ); + }; return ( - - {visible.map((item) => { - const raw = (item.title || item.prompt || item.summary || "").trim(); - const label = backgroundCommandLabel(raw) || raw || "background command"; - const status = ` ${item.status}`; - const labelBudget = Math.max(6, inner - status.length - 4); - return ( - - {"$ "}{endTruncate(label, labelBudget)} {item.status} - - ); - })} - {hiddenAfter > 0 ? {` ↓ ${hiddenAfter} more`} : null} + + {capped.visible.map(renderItem)} + {capped.hiddenCount > 0 ? {` + show all (${capped.hiddenCount})`} : null} + {grouped.earlier.length > 0 || grouped.clearedCount > 0 ? {` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})`} : null} + {earlierExpanded ? grouped.earlier.map(renderItem) : null} ); } @@ -1181,10 +1235,12 @@ function ChatInfoPane({ info, selectedIndex, width, + subagentPaneViewState, }: { info: ChatInfoSnapshot; selectedIndex: number; width: number; + subagentPaneViewState: SubagentPaneViewState; }) { const brand = theme.provider(info.provider); // With the resume row visible the selection space shifts by one (0 = resume, @@ -1197,10 +1253,10 @@ function ChatInfoPane({ - + - - + + ); @@ -2147,6 +2203,7 @@ function RightPaneComponent({ modelPickerInputs, onModelPickerMeasureOrigin, scrollOffsetRows = 0, + subagentPaneViewState = {}, }: { content: RightPaneContent; formValues?: Record; @@ -2156,6 +2213,7 @@ function RightPaneComponent({ activeProvider?: AdeCodeProvider | null; width?: number; scrollOffsetRows?: number; + subagentPaneViewState?: SubagentPaneViewState; /** Reports the model-picker's measured content origin for click hit-testing. */ onModelPickerMeasureOrigin?: (origin: { x: number; y: number; width: number }) => void; /** Data passed in by app.tsx for the model-picker content kind. */ @@ -2310,7 +2368,7 @@ function RightPaneComponent({ ) : null} {content.kind === "chat-info" ? ( - + ) : null} {content.kind === "model-picker" && modelPickerInputs ? ( diff --git a/apps/ade-cli/src/tuiClient/subagentPane.ts b/apps/ade-cli/src/tuiClient/subagentPane.ts index 1b0a20665..3300c47cf 100644 --- a/apps/ade-cli/src/tuiClient/subagentPane.ts +++ b/apps/ade-cli/src/tuiClient/subagentPane.ts @@ -6,13 +6,19 @@ export { buildSubagentTranscriptEvents, isLifecycleEventForSnapshot, selectedSubagentSnapshot, + SUBAGENT_PANE_ROSTER_CAPACITY, subagentIndexForPaneLine, subagentPaneSelectableLineOffsets, subagentTranscriptMessagesToEvents, + windowSubagentPaneRows, } from "../../../desktop/src/shared/chatSubagents"; export type { + SubagentPaneDisclosureSection, SubagentPaneRow, SubagentPaneSection, + SubagentPaneTarget, + SubagentPaneViewSection, + SubagentPaneViewState, } from "../../../desktop/src/shared/chatSubagents"; export type SubagentPaneContent = SharedSubagentPaneContent & { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 3d0e6decc..8225256eb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -434,6 +434,63 @@ describe("AgentChatMessageList transcript rendering", () => { }); }); + it("copies a multi-block assistant turn from the last text row", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "text", text: "First block.", itemId: "text-1", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { type: "text", text: "Second block.", itemId: "text-2", turnId: "turn-1" }, + }, + ]); + + expect(screen.getAllByRole("button", { name: "Copy message" })).toHaveLength(2); + const turnButton = screen.getByRole("button", { name: "Copy whole turn" }); + fireEvent.click(turnButton); + await waitFor(() => expect(writeText).toHaveBeenCalledWith("First block.\n\nSecond block.")); + }); + + it("does not add turn-copy chrome for single-block or legacy null-turn text", () => { + const { rerender } = renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "text", text: "One block.", itemId: "text-1", turnId: "turn-1" }, + }, + ]); + expect(screen.queryByRole("button", { name: "Copy whole turn" })).toBeNull(); + + rerender( + + + , + ); + expect(screen.queryByRole("button", { name: "Copy whole turn" })).toBeNull(); + }); + it("copies assistant code blocks from the transcript", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 1aaf6311c..65ca71ee6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -287,6 +287,30 @@ function getEventTurnId(event: AgentChatEvent): string | null { return turnId.length ? turnId : null; } +export type AssistantTurnCopyInfo = { + text: string; + lastTextEventKey: string; + textEventCount: number; +}; + +export function deriveAssistantTurnCopyMap( + rows: readonly TranscriptRenderEnvelope[], +): Map { + const result = new Map(); + for (const row of rows) { + if (row.event.type !== "text") continue; + const turnId = getEventTurnId(row.event); + if (!turnId) continue; + const existing = result.get(turnId); + result.set(turnId, { + text: existing ? `${existing.text}\n\n${row.event.text}` : row.event.text, + lastTextEventKey: row.key, + textEventCount: (existing?.textEventCount ?? 0) + 1, + }); + } + return result; +} + function basenamePathLabel(value: string): string { const normalized = normalizePath(value); const basename = normalized.split("/").pop()?.trim(); @@ -622,9 +646,13 @@ type RenderEnvelope = { function MessageCopyButton({ value, className, + label = "Copy", + title = "Copy message", }: { value: string; className?: string; + label?: string; + title?: string; }) { const [copied, setCopied] = useState(false); const mountedRef = useRef(true); @@ -673,11 +701,11 @@ function MessageCopyButton({ className, )} onClick={handleCopy} - title={copied ? "Copied" : "Copy message"} - aria-label={copied ? "Copied" : "Copy message"} + title={copied ? "Copied" : title} + aria-label={copied ? "Copied" : title} > {copied ? : } - {copied ? "Copied" : "Copy"} + {copied ? "Copied" : label} ); } @@ -2626,6 +2654,7 @@ function renderEvent( mosaic?: MosaicRenderContext; /** Scroll a row into view by its stable render key (subagent jump affordances). */ onScrollToRowKey?: (rowKey: string) => void; + assistantTurnCopy?: { text: string } | null; } ) { const event = envelope.event; @@ -2768,8 +2797,15 @@ function renderEvent( > {/* Unbubbled assistant prose — plain markdown on the flat canvas (Codex/t3 reference). */}
-
+
+ {options?.assistantTurnCopy ? ( + + ) : null}
@@ -4129,6 +4165,7 @@ type EventRowProps = { mosaic?: MosaicRenderContext; anchored?: boolean; onScrollToRowKey?: (rowKey: string) => void; + assistantTurnCopy?: { text: string } | null; }; const EventRow = React.memo(function EventRow({ @@ -4161,6 +4198,7 @@ const EventRow = React.memo(function EventRow({ mosaic, anchored, onScrollToRowKey, + assistantTurnCopy, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded @@ -4223,6 +4261,7 @@ const EventRow = React.memo(function EventRow({ turnDiffSummaries, mosaic, onScrollToRowKey, + assistantTurnCopy, })} {envelope.event.type === "done" ? ( { + const byRowKey = new Map(); + for (const info of deriveAssistantTurnCopyMap(rows).values()) { + if (info.textEventCount >= 2) byRowKey.set(info.lastTextEventKey, info); + } + return byRowKey; + }, [rows]); const groupedRows = useMemo(() => groupChatTranscriptRows(rows), [rows]); const groupedRowKeys = useMemo(() => groupedRows.map((row) => row.key), [groupedRows]); const prevGroupedRowKeysRef = useRef(null); @@ -5336,6 +5382,7 @@ function AgentChatMessageListMain({ const rowTurnActive = Boolean(currentTurn && activeTurnId && currentTurn === activeTurnId) && !sessionEnded; const anchored = envelope.key === anchoredRowKey; + const assistantTurnCopy = assistantTurnCopyByRowKey.get(envelope.key) ?? null; if (virtualized) { return ( @@ -5372,6 +5419,7 @@ function AgentChatMessageListMain({ mosaic={mosaic} anchored={anchored} onScrollToRowKey={scrollToRowKey} + assistantTurnCopy={assistantTurnCopy} /> ); } @@ -5407,9 +5455,10 @@ function AgentChatMessageListMain({ mosaic={mosaic} anchored={anchored} onScrollToRowKey={scrollToRowKey} + assistantTurnCopy={assistantTurnCopy} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic, scrollToRowKey]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic, scrollToRowKey]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index db8114943..f4809d5c5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -9326,6 +9326,7 @@ export function AgentChatPane({ const proofSessionId = selectedSessionId ?? ""; const agentsTabContent = selectedSubagentPaneAvailable || selectedTodoItems.length > 0 || selectedScheduledWorkSnapshots.length > 0 ? ( { setSubagentView({ taskId: selection.taskId, @@ -10698,7 +10700,7 @@ export function AgentChatPane({ const SIDE_PANE_FADE = { duration: 0.16, ease: [0.4, 0, 0.2, 1] as const }; const FLOATING_PANE_CARD_CLASS = - "ade-floating-side-pane flex w-full flex-col overflow-y-auto rounded-xl border border-white/[0.07] bg-[color:var(--work-sidebar-bg,#161618)] shadow-[0_20px_60px_-30px_rgba(0,0,0,0.8)]"; + "ade-floating-side-pane flex w-full flex-col overflow-hidden rounded-xl border border-white/[0.07] bg-[color:var(--work-sidebar-bg,#161618)] shadow-[0_20px_60px_-30px_rgba(0,0,0,0.8)]"; const renderFloatingPane = (content: React.ReactNode) => ( { />, ); + fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); fireEvent.click(screen.getByTitle("Audit chat renderer")); await waitFor(() => expect(probeSubagentTranscript).toHaveBeenCalledTimes(1)); @@ -414,6 +415,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { // Background section shows smart labels (cwd stripped from the collapsed row). expect(screen.getByText("npx vitest run t")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); expect(screen.getByText("npm run build")).toBeTruthy(); // Schedule row present. @@ -516,7 +518,7 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(screen.getByText("paused")).toBeTruthy(); }); - it("moves fired one-shot wakeups into collapsed history and marks late fires", () => { + it("moves fired one-shot wakeups into the collapsed Earlier group and marks late fires", () => { const firedAt = new Date(2026, 4, 12, 8, 41).toISOString(); render( { ); expect(screen.getByTitle("Recurring CI check")).toBeTruthy(); - const historyToggle = screen.getByRole("button", { name: "History (1)" }); - expect(historyToggle.getAttribute("aria-expanded")).toBe("false"); + const earlierToggle = screen.getByRole("button", { name: "Earlier (1)" }); + expect(earlierToggle.getAttribute("aria-expanded")).toBe("false"); expect(screen.queryByText("✓ Check PR CI · fired 8:41 AM · late")).toBeNull(); - fireEvent.click(historyToggle); + fireEvent.click(earlierToggle); - expect(historyToggle.getAttribute("aria-expanded")).toBe("true"); + expect(earlierToggle.getAttribute("aria-expanded")).toBe("true"); expect(screen.getByText("✓ Check PR CI · fired 8:41 AM · late")).toBeTruthy(); expect(screen.queryByText("done")).toBeNull(); }); @@ -597,7 +599,89 @@ describe("ChatSubagentsPanel (pane variant)", () => { />, ); + fireEvent.click(screen.getByRole("button", { name: /Earlier \(1\)/i })); expect(screen.getByText("done")).toBeTruthy(); expect(screen.queryByText("running")).toBeNull(); }); + + it("keeps the small case free of collapse, Earlier, Show all, and Clear chrome", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: /Subagents/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /Earlier/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /Show all/i })).toBeNull(); + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + }); + + it("caps active rows, exempts failed rows, and reveals all rows once", () => { + const snapshots = Array.from({ length: 14 }, (_, index): ChatSubagentSnapshot => ({ + ...baseSnapshot, + taskId: `running-${index}`, + description: `Running ${index}`, + background: false, + })); + snapshots.push({ + ...baseSnapshot, + taskId: "failed-beyond-cap", + description: "Failed beyond cap", + status: "failed", + background: false, + }); + + render(); + + expect(screen.getByTitle("Failed beyond cap")).toBeTruthy(); + expect(screen.queryByTitle("Running 13")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show all (2 running)" })); + expect(screen.getByTitle("Running 13")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Show all/i })).toBeNull(); + }); + + it("clears and restores Earlier rows with the normalized per-session storage shape", () => { + const sessionId = "pane-persistence"; + window.localStorage.removeItem(`ade.chat.paneUi.v1:${sessionId}`); + window.localStorage.removeItem(`ade.chat.paneCleared.v1:${sessionId}`); + const completed = ["done-1", "done-2"].map((taskId): ChatSubagentSnapshot => ({ + ...baseSnapshot, + taskId, + description: taskId, + status: "completed", + background: false, + })); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(screen.getByText("Subagents · all clear")).toBeTruthy(); + expect(JSON.parse(window.localStorage.getItem(`ade.chat.paneCleared.v1:${sessionId}`) ?? "null")).toEqual({ + subagents: ["done-1", "done-2"], + background: [], + schedule: [], + }); + fireEvent.click(screen.getByRole("button", { name: "Restore (2)" })); + expect(screen.getByRole("button", { name: "Earlier (2)" })).toBeTruthy(); + }); + + it("owns the pane scroller and uses sticky opaque section headers", () => { + render(); + + const scroller = screen.getByTestId("chat-subagents-pane-scroll"); + expect(scroller.className).toContain("overflow-y-auto"); + const header = screen.getByText("Subagents").closest("div"); + expect(header?.className).toContain("sticky"); + expect(header?.className).toContain("--work-sidebar-bg"); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 1f41aeb85..8f55f55c9 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, motion } from "motion/react"; import { CaretDown, @@ -17,11 +17,23 @@ import type { ChatScheduledWorkSnapshot, ChatSubagentSnapshot } from "./chatExec import { derivePlan } from "./chatExecutionSummary"; import type { TodoItemSnapshot } from "./chatExecutionSummary"; import { ChatTaskList } from "./ChatTasksPanel"; -import type { ChatInfoPlanStep } from "../../../shared/chatSubagents"; -import { isBackgroundShellCommand } from "../../../shared/chatSubagents"; +import type { ChatInfoPlanStep, PaneSectionKey } from "../../../shared/chatSubagents"; +import { + BACKGROUND_ACTIVE_CAP, + PROGRESS_CAP, + SCHEDULE_ACTIVE_CAP, + SUBAGENTS_ACTIVE_CAP, + TASKS_CAP, + capPaneSectionItems, + groupPaneSectionItems, + isBackgroundShellCommand, + isEarlierSubagentSnapshot, +} from "../../../shared/chatSubagents"; import { backgroundCommandCwd, backgroundCommandLabel, + isEarlierBackgroundItem, + isEarlierScheduleItem, isFiredOneShotWakeup, scheduledNextFireLabel, } from "../../../shared/chatScheduledWork"; @@ -32,6 +44,117 @@ import { CodexGoalCard } from "./codex/CodexGoalCard"; import { ChatSubagentGlyph, chatSubagentColor, chatSubagentDisplayName } from "./chatSubagentIdentity"; const GLYPH_SIZE = 16; +const PANE_UI_STORAGE_PREFIX = "ade.chat.paneUi.v1"; +const PANE_CLEARED_STORAGE_PREFIX = "ade.chat.paneCleared.v1"; +const PANE_STORAGE_ENTRY_CAP = 100; + +type PaneUiStorageState = { + collapsed: Partial>; + earlier: Partial, boolean>>; +}; + +type PaneClearedStorageState = Record, string[]>; + +type PaneStorage = Pick; + +const EMPTY_PANE_UI_STATE: PaneUiStorageState = { collapsed: {}, earlier: {} }; +const EMPTY_PANE_CLEARED_STATE: PaneClearedStorageState = { subagents: [], background: [], schedule: [] }; +const PANE_SECTION_KEYS: PaneSectionKey[] = ["progress", "tasks", "subagents", "background", "schedule"]; +const PANE_EARLIER_SECTION_KEYS = ["subagents", "background", "schedule"] as const; + +export function chatPaneUiStorageKey(sessionId: string): string { + return `${PANE_UI_STORAGE_PREFIX}:${sessionId}`; +} + +export function chatPaneClearedStorageKey(sessionId: string): string { + return `${PANE_CLEARED_STORAGE_PREFIX}:${sessionId}`; +} + +function booleanMap(value: unknown, keys: readonly T[]): Partial> { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const record = value as Record; + const result: Partial> = {}; + for (const key of keys) { + if (typeof record[key] === "boolean") result[key] = record[key] as boolean; + } + return result; +} + +export function parseChatPaneUiState(raw: string | null): PaneUiStorageState { + if (!raw) return EMPTY_PANE_UI_STATE; + try { + const parsed = JSON.parse(raw) as { collapsed?: unknown; earlier?: unknown }; + return { + collapsed: booleanMap(parsed.collapsed, PANE_SECTION_KEYS), + earlier: booleanMap(parsed.earlier, PANE_EARLIER_SECTION_KEYS), + }; + } catch { + return EMPTY_PANE_UI_STATE; + } +} + +export function parseChatPaneClearedState(raw: string | null): PaneClearedStorageState { + if (!raw) return EMPTY_PANE_CLEARED_STATE; + try { + const parsed = JSON.parse(raw) as Record; + return Object.fromEntries(PANE_EARLIER_SECTION_KEYS.map((section) => [ + section, + Array.isArray(parsed[section]) + ? [...new Set(parsed[section].filter((id): id is string => typeof id === "string" && id.length > 0))] + : [], + ])) as PaneClearedStorageState; + } catch { + return EMPTY_PANE_CLEARED_STATE; + } +} + +export function cleanupChatPaneStorage(storage: PaneStorage): void { + for (const prefix of [PANE_UI_STORAGE_PREFIX, PANE_CLEARED_STORAGE_PREFIX]) { + const keys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key?.startsWith(`${prefix}:`)) keys.push(key); + } + for (const key of keys.slice(0, Math.max(0, keys.length - PANE_STORAGE_ENTRY_CAP))) { + storage.removeItem(key); + } + } +} + +function readPaneUiState(sessionId?: string | null): PaneUiStorageState { + if (!sessionId || typeof window === "undefined") return EMPTY_PANE_UI_STATE; + try { + return parseChatPaneUiState(window.localStorage.getItem(chatPaneUiStorageKey(sessionId))); + } catch { + return EMPTY_PANE_UI_STATE; + } +} + +function readPaneClearedState(sessionId?: string | null): PaneClearedStorageState { + if (!sessionId || typeof window === "undefined") return EMPTY_PANE_CLEARED_STATE; + try { + return parseChatPaneClearedState(window.localStorage.getItem(chatPaneClearedStorageKey(sessionId))); + } catch { + return EMPTY_PANE_CLEARED_STATE; + } +} + +function paneSectionHint(args: { + activeCount: number; + earlierCount: number; + clearedCount: number; + runningCount?: number; + failedCount?: number; +}): string { + const active = args.runningCount && args.failedCount + ? `${args.runningCount} running · ${args.failedCount} failed` + : `${args.activeCount}`; + return [ + active, + ...(args.earlierCount ? [`${args.earlierCount} earlier`] : []), + ...(args.clearedCount ? [`${args.clearedCount} hidden`] : []), + ].join(" · "); +} type GlyphCategory = "subagent" | "background"; @@ -73,15 +196,59 @@ function SectionHeader({ action, tone = "neutral", emphasized = false, + sticky = false, + collapsible = false, + collapsed = false, + onToggle, }: { label: string; hint?: string; action?: ReactNode; tone?: SectionTone; emphasized?: boolean; + sticky?: boolean; + collapsible?: boolean; + collapsed?: boolean; + onToggle?: () => void; }) { + const wrapperClass = cn( + "flex items-center justify-between px-3.5", + emphasized ? "pb-1.5 pt-3" : "pb-1 pt-2.5", + sticky && "sticky top-0 z-[5] bg-[color:var(--work-sidebar-bg,#161618)]", + ); + if (collapsible) { + return ( +
+ + {action ? {action} : null} +
+ ); + } return ( -
+
+ {open ? ( + + {children} + + ) : null} + + ); +} + +function EarlierToggle({ + count, + clearedCount, + expanded, + onToggle, +}: { + count: number; + clearedCount: number; + expanded: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function ShowAllButton({ hiddenLabel, onClick }: { hiddenLabel: string; onClick: () => void }) { + return ( + + ); +} + +function PaneTextAction({ children, onClick }: { children: ReactNode; onClick: () => void }) { + return ( + + ); +} + /* ── Progress bar — 1 px hairline rule ── */ function ProgressBar({ percent }: { percent: number }) { @@ -580,6 +813,7 @@ export type SubagentSelection = { }; export function ChatSubagentsPanel({ + sessionId, snapshots, events, onSelectSubagent, @@ -601,6 +835,7 @@ export function ChatSubagentsPanel({ schedulesPaused = false, onToggleSchedulesPaused, }: { + sessionId?: string | null; snapshots: ChatSubagentSnapshot[]; events: AgentChatEventEnvelope[]; onSelectSubagent?: (selection: SubagentSelection) => void; @@ -633,7 +868,9 @@ export function ChatSubagentsPanel({ onToggleSchedulesPaused?: () => void; }) { const [expanded, setExpanded] = useState(false); - const [scheduleHistoryExpanded, setScheduleHistoryExpanded] = useState(false); + const [paneUi, setPaneUi] = useState(() => readPaneUiState(sessionId)); + const [paneCleared, setPaneCleared] = useState(() => readPaneClearedState(sessionId)); + const [showAll, setShowAll] = useState>>({}); // Which agent's inline details drawer is open (agents with no transcript). const [expandedTaskId, setExpandedTaskId] = useState(null); // Which agent we're currently probing for a transcript (shows a row spinner). @@ -641,17 +878,77 @@ export function ChatSubagentsPanel({ // Cached probe outcomes per task so repeat clicks are instant. Running agents // are never cached — their transcript can appear after a later poll. const [probeResults, setProbeResults] = useState>({}); + const paneScrollRef = useRef(null); - const plan = useMemo(() => derivePlan(events), [events]); + useEffect(() => { + setPaneUi(readPaneUiState(sessionId)); + setPaneCleared(readPaneClearedState(sessionId)); + setShowAll({}); + }, [sessionId]); - const { activeScheduleItems, scheduleHistoryItems } = useMemo(() => { - const active: ChatScheduledWorkSnapshot[] = []; - const history: ChatScheduledWorkSnapshot[] = []; - for (const item of scheduleItems) { - (isFiredOneShotWakeup(item) ? history : active).push(item); + useEffect(() => { + if (typeof window === "undefined") return; + try { + cleanupChatPaneStorage(window.localStorage); + } catch { + // Renderer storage is best-effort; disclosure remains available in memory. } - return { activeScheduleItems: active, scheduleHistoryItems: history }; - }, [scheduleItems]); + }, []); + + const updatePaneUi = useCallback((update: (current: PaneUiStorageState) => PaneUiStorageState) => { + setPaneUi((current) => { + const next = update(current); + if (sessionId) { + try { + window.localStorage.setItem(chatPaneUiStorageKey(sessionId), JSON.stringify(next)); + } catch { + // Keep the in-memory state when localStorage is blocked. + } + } + return next; + }); + }, [sessionId]); + + const updatePaneCleared = useCallback((update: (current: PaneClearedStorageState) => PaneClearedStorageState) => { + setPaneCleared((current) => { + const next = update(current); + if (sessionId) { + try { + window.localStorage.setItem(chatPaneClearedStorageKey(sessionId), JSON.stringify(next)); + } catch { + // Keep the in-memory state when localStorage is blocked. + } + } + return next; + }); + }, [sessionId]); + + const toggleSection = useCallback((section: PaneSectionKey) => { + updatePaneUi((current) => ({ + ...current, + collapsed: { ...current.collapsed, [section]: current.collapsed[section] !== true }, + })); + }, [updatePaneUi]); + + const toggleEarlier = useCallback((section: "subagents" | "background" | "schedule") => { + updatePaneUi((current) => ({ + ...current, + earlier: { ...current.earlier, [section]: current.earlier[section] !== true }, + })); + }, [updatePaneUi]); + + const clearEarlier = useCallback((section: "subagents" | "background" | "schedule", ids: string[]) => { + updatePaneCleared((current) => ({ + ...current, + [section]: [...new Set([...current[section], ...ids])], + })); + }, [updatePaneCleared]); + + const restoreCleared = useCallback((section: "subagents" | "background" | "schedule") => { + updatePaneCleared((current) => ({ ...current, [section]: [] })); + }, [updatePaneCleared]); + + const plan = useMemo(() => derivePlan(events), [events]); const { subagents, runningCount, completedCount, bgRunningCount } = useMemo(() => { // ONE merged subagent list — foreground + background-run agents together. @@ -688,18 +985,75 @@ export function ChatSubagentsPanel({ }; }, [snapshots]); + const pinnedSubagentIds = useMemo(() => new Set( + [selectedTaskId, expandedTaskId].filter((id): id is string => Boolean(id)), + ), [expandedTaskId, selectedTaskId]); + const clearedSubagentIds = useMemo(() => new Set(paneCleared.subagents), [paneCleared.subagents]); + const clearedBackgroundIds = useMemo(() => new Set(paneCleared.background), [paneCleared.background]); + const clearedScheduleIds = useMemo(() => new Set(paneCleared.schedule), [paneCleared.schedule]); + + const subagentGroups = useMemo(() => groupPaneSectionItems(subagents, { + isEarlier: isEarlierSubagentSnapshot, + isCleared: (snapshot) => clearedSubagentIds.has(snapshot.taskId), + isPinned: (snapshot) => pinnedSubagentIds.has(snapshot.taskId), + }), [clearedSubagentIds, pinnedSubagentIds, subagents]); + const backgroundGroups = useMemo(() => groupPaneSectionItems(backgroundItems, { + isEarlier: isEarlierBackgroundItem, + isCleared: (snapshot) => clearedBackgroundIds.has(snapshot.id), + isPinned: () => false, + }), [backgroundItems, clearedBackgroundIds]); + const scheduleGroups = useMemo(() => groupPaneSectionItems(scheduleItems, { + isEarlier: isEarlierScheduleItem, + isCleared: (snapshot) => clearedScheduleIds.has(snapshot.id), + isPinned: () => false, + }), [clearedScheduleIds, scheduleItems]); + + const cappedSubagents = useMemo(() => ( + showAll.subagents + ? { visible: subagentGroups.active, hiddenCount: 0 } + : capPaneSectionItems(subagentGroups.active, SUBAGENTS_ACTIVE_CAP, (snapshot) => ( + snapshot.status === "failed" || pinnedSubagentIds.has(snapshot.taskId) + )) + ), [pinnedSubagentIds, showAll.subagents, subagentGroups.active]); + const cappedBackground = useMemo(() => ( + showAll.background + ? { visible: backgroundGroups.active, hiddenCount: 0 } + : capPaneSectionItems(backgroundGroups.active, BACKGROUND_ACTIVE_CAP, (snapshot) => snapshot.status === "failed") + ), [backgroundGroups.active, showAll.background]); + const cappedSchedule = useMemo(() => ( + showAll.schedule + ? { visible: scheduleGroups.active, hiddenCount: 0 } + : capPaneSectionItems(scheduleGroups.active, SCHEDULE_ACTIVE_CAP, (snapshot) => snapshot.status === "failed") + ), [scheduleGroups.active, showAll.schedule]); + + useEffect(() => { + if (!selectedTaskId) return; + if (paneUi.collapsed.subagents) { + updatePaneUi((current) => ({ + ...current, + collapsed: { ...current.collapsed, subagents: false }, + })); + } + const frame = window.requestAnimationFrame(() => { + const target = [...(paneScrollRef.current?.querySelectorAll("[data-subagent-task-id]") ?? [])] + .find((element) => element.dataset.subagentTaskId === selectedTaskId); + target?.scrollIntoView?.({ block: "nearest" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [paneUi.collapsed.subagents, selectedTaskId, updatePaneUi]); + const headerSummary = useMemo(() => { const parts: string[] = []; if (runningCount) parts.push(`${runningCount} running`); if (bgRunningCount) parts.push(`${bgRunningCount} bg`); - if (backgroundItems.length) parts.push(`${backgroundItems.length} background`); - if (activeScheduleItems.length) parts.push(`${activeScheduleItems.length} scheduled`); - if (scheduleHistoryItems.length) parts.push(`${scheduleHistoryItems.length} history`); + if (backgroundGroups.active.length) parts.push(`${backgroundGroups.active.length} background`); + if (scheduleGroups.active.length) parts.push(`${scheduleGroups.active.length} scheduled`); + if (scheduleGroups.earlier.length) parts.push(`${scheduleGroups.earlier.length} earlier`); if (completedCount) parts.push(`${completedCount} done`); if (!parts.length && subagents.length) parts.push(`${subagents.length} tracked`); if (!parts.length) parts.push("idle"); return parts.join(" · "); - }, [runningCount, bgRunningCount, backgroundItems.length, activeScheduleItems.length, scheduleHistoryItems.length, completedCount, subagents.length]); + }, [backgroundGroups.active.length, completedCount, runningCount, bgRunningCount, scheduleGroups.active.length, scheduleGroups.earlier.length, subagents.length]); const takeover = (snap: ChatSubagentSnapshot) => { setExpandedTaskId(null); @@ -780,8 +1134,16 @@ export function ChatSubagentsPanel({ const planComplete = plan?.steps.filter((step) => step.status === "completed").length ?? 0; const planTotal = plan?.steps.length ?? 0; const planPercent = planTotal > 0 ? Math.round((planComplete / planTotal) * 100) : 0; + const progressCollapsible = planTotal > PROGRESS_CAP; + const progressCollapsed = progressCollapsible && paneUi.collapsed.progress === true; + const visiblePlanSteps = plan && !showAll.progress ? plan.steps.slice(0, PROGRESS_CAP) : plan?.steps ?? []; + const hiddenPlanCount = Math.max(0, planTotal - visiblePlanSteps.length); const taskComplete = todoItems.filter((item) => item.status === "completed").length; const taskActive = todoItems.filter((item) => item.status === "in_progress").length; + const tasksCollapsible = todoItems.length > TASKS_CAP; + const tasksCollapsed = tasksCollapsible && paneUi.collapsed.tasks === true; + const visibleTodoItems = showAll.tasks ? todoItems : todoItems.slice(0, TASKS_CAP); + const hiddenTaskCount = todoItems.length - visibleTodoItems.length; const taskHint = todoItems.length ? [ `${taskComplete}/${todoItems.length} complete`, @@ -789,6 +1151,24 @@ export function ChatSubagentsPanel({ ].join(" · ") : undefined; + const subagentRunningCount = subagentGroups.active.filter((item) => item.status === "running").length; + const subagentFailedCount = subagentGroups.active.filter((item) => item.status === "failed").length; + const backgroundRunningCount = backgroundGroups.active.filter((item) => item.status === "running").length; + const backgroundFailedCount = backgroundGroups.active.filter((item) => item.status === "failed").length; + const scheduleRunningCount = scheduleGroups.active.filter((item) => item.status === "running").length; + const scheduleFailedCount = scheduleGroups.active.filter((item) => item.status === "failed").length; + + const subagentsCollapsible = subagentGroups.earlier.length > 0 || subagentGroups.active.length > SUBAGENTS_ACTIVE_CAP; + const backgroundCollapsible = backgroundGroups.earlier.length > 0 || backgroundGroups.active.length > BACKGROUND_ACTIVE_CAP; + const scheduleCollapsible = scheduleGroups.earlier.length > 0 || scheduleGroups.active.length > SCHEDULE_ACTIVE_CAP; + const subagentsCollapsed = subagentsCollapsible && paneUi.collapsed.subagents === true; + const backgroundCollapsed = backgroundCollapsible && paneUi.collapsed.background === true; + const scheduleCollapsed = scheduleCollapsible && paneUi.collapsed.schedule === true; + const subagentsAllClear = subagentGroups.active.length === 0 && subagentGroups.earlier.length === 0 && subagentGroups.clearedCount > 0; + const backgroundAllClear = backgroundGroups.active.length === 0 && backgroundGroups.earlier.length === 0 && backgroundGroups.clearedCount > 0; + const scheduleAllClear = scheduleGroups.active.length === 0 && scheduleGroups.earlier.length === 0 && scheduleGroups.clearedCount > 0; + const stickyHeaders = variant === "pane"; + const hasGoal = Boolean(goal?.objective?.trim()); const hasTasks = todoItems.length > 0; const hasSubagents = subagents.length > 0; @@ -797,7 +1177,7 @@ export function ChatSubagentsPanel({ const hasAnything = hasGoal || Boolean(plan) || hasTasks || hasSubagents || hasBackground || hasScheduled; const body = ( -
+
{/* ── Goal (Codex chat goal) ───────────────────────────────── */} {hasGoal && goal ? ( toggleSection("progress")} /> - -
    - {plan.steps.map((step, index) => { + + +
      + {visiblePlanSteps.map((step, index) => { const isCompleted = step.status === "completed"; const isInProgress = step.status === "in_progress"; const isFailed = step.status === "failed"; @@ -841,7 +1226,11 @@ export function ChatSubagentsPanel({ ); })} -
    +
+ {hiddenPlanCount > 0 ? ( + setShowAll((current) => ({ ...current, progress: true }))} /> + ) : null} + ) : null} @@ -858,8 +1247,17 @@ export function ChatSubagentsPanel({ hint={taskHint} tone="workflow" emphasized + sticky={stickyHeaders} + collapsible={tasksCollapsible} + collapsed={tasksCollapsed} + onToggle={() => toggleSection("tasks")} /> - + + + {hiddenTaskCount > 0 ? ( + setShowAll((current) => ({ ...current, tasks: true }))} /> + ) : null} + ) : null} @@ -873,24 +1271,101 @@ export function ChatSubagentsPanel({ > toggleSection("subagents")} + action={subagentsAllClear ? ( + restoreCleared("subagents")}>Restore ({subagentGroups.clearedCount}) + ) : subagentGroups.earlier.length > 0 ? ( + clearEarlier("subagents", subagentGroups.earlier.map((item) => item.taskId))}>Clear + ) : null} /> -
- {subagents.map((snap) => ( - handleRowClick(snap)} + + {subagentsAllClear ? ( +
Subagents · all clear
+ ) : null} +
+ + {cappedSubagents.visible.map((snap) => ( + + handleRowClick(snap)} + /> + + ))} + +
+ {cappedSubagents.hiddenCount > 0 ? ( + setShowAll((current) => ({ ...current, subagents: true }))} /> - ))} -
+ ) : null} + {subagentGroups.earlier.length > 0 || subagentGroups.clearedCount > 0 ? ( +
+ toggleEarlier("subagents")} + /> + +
+ + {subagentGroups.earlier.map((snap) => ( + + handleRowClick(snap)} + /> + + ))} + +
+ {subagentGroups.clearedCount > 0 ? ( +
restoreCleared("subagents")}>Restore ({subagentGroups.clearedCount})
+ ) : null} +
+
+ ) : null} + ) : null} @@ -902,12 +1377,51 @@ export function ChatSubagentsPanel({ (hasGoal || plan || hasTasks || hasSubagents) && "border-t border-white/[0.04]", )} > - -
- {backgroundItems.map((item) => ( - - ))} -
+ toggleSection("background")} + action={backgroundAllClear ? ( + restoreCleared("background")}>Restore ({backgroundGroups.clearedCount}) + ) : backgroundGroups.earlier.length > 0 ? ( + clearEarlier("background", backgroundGroups.earlier.map((item) => item.id))}>Clear + ) : null} + /> + + {backgroundAllClear ?
Background · all clear
: null} +
+ + {cappedBackground.visible.map((item) => ( + + + + ))} + +
+ {cappedBackground.hiddenCount > 0 ? setShowAll((current) => ({ ...current, background: true }))} /> : null} + {backgroundGroups.earlier.length > 0 || backgroundGroups.clearedCount > 0 ? ( +
+ toggleEarlier("background")} /> + +
+ {backgroundGroups.earlier.map((item) => )} +
+ {backgroundGroups.clearedCount > 0 ?
restoreCleared("background")}>Restore ({backgroundGroups.clearedCount})
: null} +
+
+ ) : null} +
) : null} @@ -921,30 +1435,45 @@ export function ChatSubagentsPanel({ > - {schedulesPaused - ? - : } - - ) : null} + sticky={stickyHeaders} + collapsible={scheduleCollapsible} + collapsed={scheduleCollapsed} + onToggle={() => toggleSection("schedule")} + action={( + <> + {scheduleAllClear ? ( + restoreCleared("schedule")}>Restore ({scheduleGroups.clearedCount}) + ) : scheduleGroups.earlier.length > 0 ? ( + clearEarlier("schedule", scheduleGroups.earlier.map((item) => item.id))}>Clear + ) : null} + {onToggleSchedulesPaused ? ( + + ) : null} + + )} /> - {activeScheduleItems.length ? ( + + {scheduleAllClear ?
Schedule · all clear
: null} + {cappedSchedule.visible.length ? (
- {activeScheduleItems.map((item) => ( + {cappedSchedule.visible.map((item) => ( ) : null} - {scheduleHistoryItems.length ? ( + {cappedSchedule.hiddenCount > 0 ? setShowAll((current) => ({ ...current, schedule: true }))} /> : null} + {scheduleGroups.earlier.length > 0 || scheduleGroups.clearedCount > 0 ? (
- - {scheduleHistoryExpanded ? ( + toggleEarlier("schedule")} /> +
- {scheduleHistoryItems.map((item) => ( - - ))} + {scheduleGroups.earlier.map((item) => isFiredOneShotWakeup(item) + ? + : )}
- ) : null} + {scheduleGroups.clearedCount > 0 ?
restoreCleared("schedule")}>Restore ({scheduleGroups.clearedCount})
: null} +
) : null} + ) : null} @@ -993,8 +1513,10 @@ export function ChatSubagentsPanel({ if (variant === "pane") { return ( -
- {body} +
+
+ {body} +
); } diff --git a/apps/desktop/src/shared/chatScheduledWork.test.ts b/apps/desktop/src/shared/chatScheduledWork.test.ts index 2b0ba9c2b..325c790b6 100644 --- a/apps/desktop/src/shared/chatScheduledWork.test.ts +++ b/apps/desktop/src/shared/chatScheduledWork.test.ts @@ -8,6 +8,8 @@ import { deriveScheduleHistory, deriveScheduleItems, deriveScheduledWorkSnapshots, + isEarlierBackgroundItem, + isEarlierScheduleItem, nextCronFireAt, scheduledNextFireLabel, type ChatScheduledWorkSnapshot, @@ -35,6 +37,15 @@ function snapshot(overrides: Partial): ChatScheduledW } describe("chatScheduledWork helpers", () => { + it("uses the shared Earlier membership for background and schedule rows", () => { + expect(isEarlierBackgroundItem(snapshot({ kind: "background_task", status: "completed" }))).toBe(true); + expect(isEarlierBackgroundItem(snapshot({ kind: "background_task", status: "failed" }))).toBe(false); + expect(isEarlierScheduleItem(snapshot({ kind: "wakeup", status: "fired", recurring: false }))).toBe(true); + expect(isEarlierScheduleItem(snapshot({ kind: "wakeup", status: "fired", recurring: true }))).toBe(false); + expect(isEarlierScheduleItem(snapshot({ kind: "cron", status: "cancelled" }))).toBe(true); + expect(isEarlierScheduleItem(snapshot({ kind: "cron", status: "missed" }))).toBe(false); + }); + it("partitions schedule kinds from background tasks", () => { const kinds = ["wakeup", "cron", "loop", "remote_trigger", "background_task"] as const; const events = kinds.map((kind, index) => envelope({ diff --git a/apps/desktop/src/shared/chatScheduledWork.ts b/apps/desktop/src/shared/chatScheduledWork.ts index f0a7948d0..aec85caa6 100644 --- a/apps/desktop/src/shared/chatScheduledWork.ts +++ b/apps/desktop/src/shared/chatScheduledWork.ts @@ -130,6 +130,19 @@ export function isFiredOneShotWakeup(snapshot: ChatScheduledWorkSnapshot): boole && ONE_SHOT_HISTORY_STATUSES.has(snapshot.status); } +export function isEarlierBackgroundItem(snapshot: ChatScheduledWorkSnapshot): boolean { + return snapshot.status === "completed" + || snapshot.status === "cancelled" + || snapshot.status === "stopped"; +} + +export function isEarlierScheduleItem(snapshot: ChatScheduledWorkSnapshot): boolean { + return isFiredOneShotWakeup(snapshot) + || snapshot.status === "completed" + || snapshot.status === "cancelled" + || snapshot.status === "stopped"; +} + export function deriveScheduleHistory(events: AgentChatEventEnvelope[]): ChatScheduledWorkSnapshot[] { return deriveScheduleItems(events).filter(isFiredOneShotWakeup); } diff --git a/apps/desktop/src/shared/chatSubagents.test.ts b/apps/desktop/src/shared/chatSubagents.test.ts index 9cfb5b642..07cc59c9f 100644 --- a/apps/desktop/src/shared/chatSubagents.test.ts +++ b/apps/desktop/src/shared/chatSubagents.test.ts @@ -1,13 +1,82 @@ import { describe, expect, it } from "vitest"; import type { AgentChatEvent } from "./types/chat"; import { + buildSubagentPaneRows, + groupPaneSectionItems, + isEarlierSubagentSnapshot, deriveSubagentTimelineRows, isBackgroundShellCommand, isRealSubagent, preferSubagentSummary, subagentAgentKey, + subagentIndexForPaneLine, + type SubagentSnapshot, } from "./chatSubagents"; +function paneSnapshot(id: string, status: SubagentSnapshot["status"], overrides: Partial = {}): SubagentSnapshot { + return { + id, + name: id, + kind: "subagent", + status, + summary: id, + ...overrides, + }; +} + +describe("chat pane scalability helpers", () => { + it("partitions in source order while pins override earlier and cleared membership", () => { + const running = paneSnapshot("running", "running"); + const completed = paneSnapshot("completed", "completed"); + const stopped = paneSnapshot("stopped", "stopped"); + const failed = paneSnapshot("failed", "failed"); + + expect(groupPaneSectionItems([running, completed, stopped, failed], { + isEarlier: isEarlierSubagentSnapshot, + isCleared: (item) => item.id === "stopped" || item.id === "completed", + isPinned: (item) => item.id === "completed", + })).toEqual({ + active: [running, completed, failed], + earlier: [], + clearedCount: 1, + }); + }); + + it("emits grouped disclosure rows with caps, clear state, and tagged targets", () => { + const snapshots = Array.from({ length: 14 }, (_, index) => paneSnapshot(`running-${index}`, "running")); + snapshots.splice(4, 0, paneSnapshot("failed", "failed")); + snapshots.push(paneSnapshot("done", "completed")); + + const rows = buildSubagentPaneRows({ snapshots }, {}); + expect(rows.find((row) => row.kind === "section-header")).toMatchObject({ + activeCount: 15, + earlierCount: 1, + collapsible: true, + hasClear: true, + }); + expect(rows.filter((row) => row.kind === "snapshot" && row.group === "active")).toHaveLength(12); + expect(rows.find((row) => row.kind === "show-all")).toMatchObject({ hiddenCount: 3 }); + expect(rows.find((row) => row.kind === "earlier-toggle")).toMatchObject({ count: 1, expanded: false }); + + const showAllLine = rows.slice(0, rows.findIndex((row) => row.kind === "show-all")) + .reduce((line, row) => line + (row.kind === "section-header" || row.kind === "main" ? 2 : 1), 0); + expect(subagentIndexForPaneLine({ snapshots }, showAllLine, 0, {})).toEqual({ + type: "show-all", + section: "subagents", + }); + + const clearedRows = buildSubagentPaneRows({ snapshots }, { + earlierExpanded: { subagents: true }, + cleared: { subagents: ["done"] }, + }); + expect(clearedRows.find((row) => row.kind === "earlier-toggle")).toMatchObject({ + count: 0, + clearedCount: 1, + }); + expect(clearedRows.find((row) => row.kind === "restore-cleared")).toMatchObject({ count: 1 }); + }); +}); + describe("chatSubagents timeline helpers", () => { it("normalizes agent keys and prefers meaningful, richer summaries", () => { expect(subagentAgentKey({ agentId: " agent-1 ", taskId: "task-1" })).toBe("agent-1"); diff --git a/apps/desktop/src/shared/chatSubagents.ts b/apps/desktop/src/shared/chatSubagents.ts index 751fc7379..64c7ce742 100644 --- a/apps/desktop/src/shared/chatSubagents.ts +++ b/apps/desktop/src/shared/chatSubagents.ts @@ -56,21 +56,95 @@ export type ChatInfoPlan = { live: boolean; } | null; +export const SUBAGENTS_ACTIVE_CAP = 12; +export const BACKGROUND_ACTIVE_CAP = 8; +export const SCHEDULE_ACTIVE_CAP = 10; +export const PROGRESS_CAP = 14; +export const TASKS_CAP = 12; +export const SUBAGENT_PANE_ROSTER_CAPACITY = 5; + +export type PaneSectionKey = "progress" | "tasks" | "subagents" | "background" | "schedule"; + +export function groupPaneSectionItems(items: T[], opts: { + isEarlier: (item: T) => boolean; + isCleared: (item: T) => boolean; + isPinned: (item: T) => boolean; +}): { active: T[]; earlier: T[]; clearedCount: number } { + const active: T[] = []; + const earlier: T[] = []; + let clearedCount = 0; + + for (const item of items) { + if (opts.isPinned(item)) { + active.push(item); + } else if (opts.isCleared(item)) { + clearedCount += 1; + } else if (opts.isEarlier(item)) { + earlier.push(item); + } else { + active.push(item); + } + } + + return { active, earlier, clearedCount }; +} + +export function capPaneSectionItems( + items: T[], + cap: number, + isExempt: (item: T) => boolean, +): { visible: T[]; hiddenCount: number } { + if (items.length <= cap) return { visible: items, hiddenCount: 0 }; + const visible = items.filter((item, index) => index < cap || isExempt(item)); + return { visible, hiddenCount: items.length - visible.length }; +} + +export function isEarlierSubagentSnapshot(snapshot: Pick): boolean { + return snapshot.status === "completed" || snapshot.status === "stopped"; +} + export type SubagentPaneSection = "main" | "subagents" | "teammates" | "background"; +export type SubagentPaneDisclosureSection = Exclude; +export type SubagentPaneViewSection = SubagentPaneDisclosureSection | "schedule"; + +export type SubagentPaneViewState = { + collapsed?: Partial>; + earlierExpanded?: Partial>; + showAll?: Partial>; + cleared?: Partial>>; + pinnedIds?: readonly string[] | ReadonlySet; +}; + export type SubagentPaneRow = | { kind: "main"; key: "main"; section: "main"; label: string } - | { kind: "snapshot"; key: string; section: Exclude; snapshot: SubagentSnapshot }; + | { + kind: "section-header"; + key: string; + section: SubagentPaneDisclosureSection; + label: string; + activeCount: number; + earlierCount: number; + clearedCount: number; + collapsible: boolean; + collapsed: boolean; + hasClear: boolean; + } + | { + kind: "snapshot"; + key: string; + section: SubagentPaneDisclosureSection; + snapshot: SubagentSnapshot; + group?: "active" | "earlier"; + } + | { kind: "earlier-toggle"; key: string; section: SubagentPaneDisclosureSection; count: number; expanded: boolean; clearedCount: number } + | { kind: "show-all"; key: string; section: SubagentPaneDisclosureSection; hiddenCount: number } + | { kind: "restore-cleared"; key: string; section: SubagentPaneDisclosureSection; count: number }; export type SubagentPaneContent = { snapshots: SubagentSnapshot[]; }; -// Vertical offset of the first selectable roster row in the rendered chat-info -// pane (header + status + plan + goal occupy the preceding lines). Used only by -// the mouse-click → row mapper. -const SUBAGENT_PANE_TABLE_START_LINE = 4; - function textField(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -824,82 +898,238 @@ export function subagentActivitySummaryFromEvents(events: AgentChatEventEnvelope return { totalCount: snapshots.size, runningCount }; } -export function buildSubagentPaneRows(content: SubagentPaneContent): SubagentPaneRow[] { +export function buildSubagentPaneRows( + content: SubagentPaneContent, + viewState?: SubagentPaneViewState, +): SubagentPaneRow[] { const foregroundSubagents = content.snapshots.filter((snap) => ( snap.kind === "subagent" && snap.background !== true )); - const runningWeight = (snap: SubagentSnapshot): number => (snap.status === "running" ? 0 : 1); - const sortedForegroundSubagents = [...foregroundSubagents].sort( - (left, right) => runningWeight(left) - runningWeight(right), - ); const teammates = content.snapshots.filter((snap) => snap.kind === "teammate"); const background = content.snapshots.filter((snap) => snap.kind === "subagent" && snap.background === true); - return [ - { kind: "main", key: "main", section: "main", label: "main" }, - ...sortedForegroundSubagents.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "subagents" as const, snapshot })), - ...teammates.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "teammates" as const, snapshot })), - ...background.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "background" as const, snapshot })), + if (!viewState) { + const runningWeight = (snap: SubagentSnapshot): number => (snap.status === "running" ? 0 : 1); + const sortedForegroundSubagents = [...foregroundSubagents].sort( + (left, right) => runningWeight(left) - runningWeight(right), + ); + + return [ + { kind: "main", key: "main", section: "main", label: "main" }, + ...sortedForegroundSubagents.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "subagents" as const, snapshot })), + ...teammates.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "teammates" as const, snapshot })), + ...background.map((snapshot) => ({ kind: "snapshot" as const, key: snapshot.id, section: "background" as const, snapshot })), + ]; + } + + const rows: SubagentPaneRow[] = [{ kind: "main", key: "main", section: "main", label: "main" }]; + const pinnedIds = new Set(viewState.pinnedIds ?? []); + const sections: Array<{ + section: SubagentPaneDisclosureSection; + label: string; + items: SubagentSnapshot[]; + cap: number; + scalable: boolean; + }> = [ + { section: "subagents", label: "SUBAGENTS", items: foregroundSubagents, cap: SUBAGENTS_ACTIVE_CAP, scalable: true }, + { section: "teammates", label: "TEAMMATES", items: teammates, cap: Number.POSITIVE_INFINITY, scalable: false }, + { section: "background", label: "BACKGROUND", items: background, cap: BACKGROUND_ACTIVE_CAP, scalable: true }, ]; + + for (const { section, label, items, cap, scalable } of sections) { + if (!items.length) continue; + const clearedIds = new Set(viewState.cleared?.[section] ?? []); + const grouped = scalable + ? groupPaneSectionItems(items, { + isEarlier: isEarlierSubagentSnapshot, + isCleared: (snapshot) => clearedIds.has(snapshot.id), + isPinned: (snapshot) => pinnedIds.has(snapshot.id), + }) + : { active: items, earlier: [], clearedCount: 0 }; + const collapsible = scalable && (grouped.earlier.length > 0 || grouped.active.length > cap); + const collapsed = collapsible && viewState.collapsed?.[section] === true; + rows.push({ + kind: "section-header", + key: `section:${section}`, + section, + label, + activeCount: grouped.active.length, + earlierCount: grouped.earlier.length, + clearedCount: grouped.clearedCount, + collapsible, + collapsed, + hasClear: grouped.earlier.length > 0, + }); + if (collapsed) continue; + + const capped = viewState.showAll?.[section] + ? { visible: grouped.active, hiddenCount: 0 } + : capPaneSectionItems(grouped.active, cap, (snapshot) => ( + snapshot.status === "failed" || pinnedIds.has(snapshot.id) + )); + rows.push(...capped.visible.map((snapshot) => ({ + kind: "snapshot" as const, + key: snapshot.id, + section, + snapshot, + group: "active" as const, + }))); + if (capped.hiddenCount > 0) { + rows.push({ kind: "show-all", key: `show-all:${section}`, section, hiddenCount: capped.hiddenCount }); + } + + const earlierExpanded = viewState.earlierExpanded?.[section] === true; + if (grouped.earlier.length > 0 || grouped.clearedCount > 0) { + rows.push({ + kind: "earlier-toggle", + key: `earlier:${section}`, + section, + count: grouped.earlier.length, + expanded: earlierExpanded, + clearedCount: grouped.clearedCount, + }); + } + if (earlierExpanded) { + rows.push(...grouped.earlier.map((snapshot) => ({ + kind: "snapshot" as const, + key: snapshot.id, + section, + snapshot, + group: "earlier" as const, + }))); + if (grouped.clearedCount > 0) { + rows.push({ kind: "restore-cleared", key: `restore:${section}`, section, count: grouped.clearedCount }); + } + } else if (grouped.active.length === 0 && grouped.earlier.length === 0 && grouped.clearedCount > 0) { + rows.push({ kind: "restore-cleared", key: `restore:${section}`, section, count: grouped.clearedCount }); + } + } + + return rows; } export function selectedSubagentSnapshot( content: SubagentPaneContent, selectedIndex: number, + viewState?: SubagentPaneViewState, ): SubagentSnapshot | null { - const row = buildSubagentPaneRows(content)[selectedIndex] ?? null; + const row = buildSubagentPaneRows(content, viewState) + .filter((candidate) => candidate.kind === "main" || candidate.kind === "snapshot")[selectedIndex] ?? null; return row?.kind === "snapshot" ? row.snapshot : null; } +function subagentPaneRowLineSpan(row: SubagentPaneRow, selected: boolean): number { + if (row.kind === "section-header") return 2; + if (row.kind === "main") return 2; + if (row.kind === "snapshot") { + return selected && (row.snapshot.lastToolName || row.snapshot.summary) ? 2 : 1; + } + return 1; +} + +export function windowSubagentPaneRows( + rows: readonly SubagentPaneRow[], + selectedIndex: number, + capacity = SUBAGENT_PANE_ROSTER_CAPACITY, +): { visibleRows: Exclude[]; hiddenBefore: number; hiddenAfter: number } { + const rosterRows = rows.filter((row): row is Exclude => row.kind !== "main"); + if (rosterRows.length <= capacity) { + return { visibleRows: rosterRows, hiddenBefore: 0, hiddenAfter: 0 }; + } + const snapshotRows = rows.filter((row): row is Extract => ( + row.kind === "snapshot" + )); + const selectedKey = selectedIndex > 0 ? snapshotRows[selectedIndex - 1]?.key : null; + const selectedRosterIndex = Math.max(0, rosterRows.findIndex((row) => row.key === selectedKey)); + const half = Math.floor(capacity / 2); + let start = Math.max(0, selectedRosterIndex - half); + let end = start + capacity; + if (end > rosterRows.length) { + end = rosterRows.length; + start = end - capacity; + } + return { + visibleRows: rosterRows.slice(start, end), + hiddenBefore: start, + hiddenAfter: rosterRows.length - end, + }; +} + export function subagentPaneSelectableLineOffsets( content: SubagentPaneContent, selectedIndex = 0, + viewState?: SubagentPaneViewState, ): number[] { - const rows = buildSubagentPaneRows(content); + const rows = buildSubagentPaneRows(content, viewState); const offsets: number[] = []; - let line = SUBAGENT_PANE_TABLE_START_LINE; - - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index]!; - const previous = rows[index - 1]; - const showSection = row.section !== "main" && previous?.section !== row.section; - if (showSection) line += 2; - offsets.push(line); - line += 1; - const selectedSnapshotHasDetail = row.kind === "snapshot" - && index === selectedIndex - && (row.snapshot.lastToolName || row.snapshot.summary); - if (row.kind === "main" || selectedSnapshotHasDetail) { - line += 1; + let line = 0; + let selectableIndex = 0; + + for (const row of rows) { + const selectable = row.kind === "main" || row.kind === "snapshot"; + const selected = selectable && selectableIndex === selectedIndex; + if (selectable) { + offsets.push(line); + selectableIndex += 1; } + line += subagentPaneRowLineSpan(row, selected); } return offsets; } +export type SubagentPaneTarget = + | { type: "snapshot"; index: number } + | { type: "toggle-section"; section: SubagentPaneDisclosureSection } + | { type: "toggle-earlier"; section: SubagentPaneDisclosureSection } + | { type: "show-all"; section: SubagentPaneDisclosureSection } + | { type: "restore"; section: SubagentPaneDisclosureSection }; + export function subagentIndexForPaneLine( content: SubagentPaneContent, line: number, selectedIndex = 0, -): number | null { + viewState?: SubagentPaneViewState, + windowCapacity?: number, +): SubagentPaneTarget | null { if (!Number.isFinite(line)) return null; - const offsets = subagentPaneSelectableLineOffsets(content, selectedIndex); - if (!offsets.length) return null; - const first = offsets[0]!; - const last = offsets[offsets.length - 1]!; - if (line < first - 1 || line > last + 1) return null; - - let bestIndex = 0; - let bestDistance = Number.POSITIVE_INFINITY; - for (let index = 0; index < offsets.length; index += 1) { - const distance = Math.abs(line - offsets[index]!); - if (distance < bestDistance) { - bestIndex = index; - bestDistance = distance; + const rows = buildSubagentPaneRows(content, viewState); + const snapshotIndexByKey = new Map( + rows + .filter((row): row is Extract => row.kind === "snapshot") + .map((row, index) => [row.key, index + 1]), + ); + const windowed = windowCapacity == null + ? { visibleRows: rows.filter((row) => row.kind !== "main"), hiddenBefore: 0, hiddenAfter: 0 } + : windowSubagentPaneRows(rows, selectedIndex, windowCapacity); + const visibleRows: Array = [ + rows.find((row) => row.kind === "main") ?? null, + ...(windowed.hiddenBefore > 0 ? [null] : []), + ...windowed.visibleRows, + ...(windowed.hiddenAfter > 0 ? [null] : []), + ]; + let rowLine = 0; + for (const row of visibleRows) { + const selectableIndex = row?.kind === "main" + ? 0 + : row?.kind === "snapshot" + ? snapshotIndexByKey.get(row.key) ?? -1 + : -1; + const selected = selectableIndex === selectedIndex; + const span = row ? subagentPaneRowLineSpan(row, selected) : 1; + if (line >= rowLine && line < rowLine + span) { + if (!row) return null; + if (row.kind === "main" || row.kind === "snapshot") return { type: "snapshot", index: selectableIndex }; + if (row.kind === "section-header" && row.collapsible) return { type: "toggle-section", section: row.section }; + if (row.kind === "earlier-toggle") return { type: "toggle-earlier", section: row.section }; + if (row.kind === "show-all") return { type: "show-all", section: row.section }; + if (row.kind === "restore-cleared") return { type: "restore", section: row.section }; + return null; } + rowLine += span; } - return bestIndex; + return null; } export function isLifecycleEventForSnapshot(event: AgentChatEvent, snapshot: SubagentSnapshot): boolean { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 2e7d362b6..6e3753b7b 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1935,7 +1935,7 @@ enum AgentChatEvent: Decodable, Equatable { case subagentStarted(taskId: String, agentId: String?, agentType: String?, parentAgentId: String?, parentToolUseId: String?, description: String, background: Bool?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) case subagentProgress(taskId: String, agentId: String?, agentType: String?, parentAgentId: String?, parentToolUseId: String?, description: String?, summary: String, usage: AgentChatSubagentUsage?, lastToolName: String?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) case subagentResult(taskId: String, agentId: String?, agentType: String?, parentAgentId: String?, parentToolUseId: String?, status: AgentChatSubagentStatus, summary: String, usage: AgentChatSubagentUsage?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) - case scheduledWorkUpdate(id: String, kind: String, status: String, origin: String?, title: String?, summary: String?, prompt: String?, reason: String?, cron: String?, nextRunAt: String?, lastRunAt: String?, recurring: Bool?, durable: Bool?, sourceToolUseId: String?, sourceTaskId: String?, turnId: String?, error: String?) + case scheduledWorkUpdate(id: String, kind: String, status: String, origin: String?, title: String?, summary: String?, prompt: String?, reason: String?, cron: String?, nextRunAt: String?, lastRunAt: String?, firedAt: String?, late: Bool?, recurring: Bool?, durable: Bool?, sourceToolUseId: String?, sourceTaskId: String?, turnId: String?, error: String?) case transcriptRetraction(messageIds: [String], reason: String?, replacementMessageId: String?, turnId: String?) case structuredQuestion(question: String, options: [AgentChatStructuredQuestionOption]?, itemId: String, turnId: String?) case toolUseSummary(summary: String, toolUseIds: [String], turnId: String?) @@ -2041,6 +2041,8 @@ extension AgentChatEvent { case cron case nextRunAt case lastRunAt + case firedAt + case late case recurring case durable case sourceToolUseId @@ -2353,6 +2355,8 @@ extension AgentChatEvent { cron: try container.decodeIfPresent(String.self, forKey: .cron), nextRunAt: try container.decodeIfPresent(String.self, forKey: .nextRunAt), lastRunAt: try container.decodeIfPresent(String.self, forKey: .lastRunAt), + firedAt: try container.decodeIfPresent(String.self, forKey: .firedAt), + late: try container.decodeIfPresent(Bool.self, forKey: .late), recurring: try container.decodeIfPresent(Bool.self, forKey: .recurring), durable: try container.decodeIfPresent(Bool.self, forKey: .durable), sourceToolUseId: try container.decodeIfPresent(String.self, forKey: .sourceToolUseId), diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index e7ca155bf..0331c1e15 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2555,7 +2555,8 @@ struct WorkChatInfoActivePopup: View { func workScheduledWorkIsActive(_ item: WorkScheduledWorkSnapshot) -> Bool { let status = item.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return status == "scheduled" || status == "running" || status == "fired" + return !workScheduleItemIsEarlier(item) + && ["scheduled", "paused", "running", "fired", "failed", "missed"].contains(status) } func workScheduledWorkActiveCount(_ snapshots: [WorkScheduledWorkSnapshot]) -> Int { @@ -2569,6 +2570,7 @@ func workScheduledWorkActiveCount(_ snapshots: [WorkScheduledWorkSnapshot]) -> I /// `deriveScheduleItems` / `deriveBackgroundItems`). Empty sections are hidden; /// a shared empty state renders only when all three are empty. struct WorkChatInfoDetailsSheet: View { + let sessionId: String let subagentSnapshots: [WorkSubagentSnapshot] let scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] let provider: String? @@ -2576,17 +2578,49 @@ struct WorkChatInfoDetailsSheet: View { let probingTaskId: String? @Binding var expandedTaskIds: Set let onSelect: @MainActor (WorkSubagentSnapshot) async -> Void + @AppStorage private var paneUiRaw: String + @AppStorage private var paneClearedRaw: String + @State private var showAllSections: Set = [] + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let subagentsCap = 12 + private let backgroundCap = 8 + private let scheduleCap = 10 + + init( + sessionId: String, + subagentSnapshots: [WorkSubagentSnapshot], + scheduledWorkSnapshots: [WorkScheduledWorkSnapshot], + provider: String?, + selectedTaskId: String?, + probingTaskId: String?, + expandedTaskIds: Binding>, + onSelect: @escaping @MainActor (WorkSubagentSnapshot) async -> Void + ) { + self.sessionId = sessionId + self.subagentSnapshots = subagentSnapshots + self.scheduledWorkSnapshots = scheduledWorkSnapshots + self.provider = provider + self.selectedTaskId = selectedTaskId + self.probingTaskId = probingTaskId + self._expandedTaskIds = expandedTaskIds + self.onSelect = onSelect + self._paneUiRaw = AppStorage( + wrappedValue: #"{"collapsed":{},"earlier":{}}"#, + "ade.chat.paneUi.v1:\(sessionId)" + ) + self._paneClearedRaw = AppStorage( + wrappedValue: #"{"subagents":[],"background":[],"schedule":[]}"#, + "ade.chat.paneCleared.v1:\(sessionId)" + ) + } /// Real subagents only (command-shaped historical snapshots are classified /// out via the shared background-shell predicate), foreground + background - /// merged into one list; running agents sort first. + /// merged into one list. Source order is stable; terminal rows move into the + /// Earlier partition without reordering survivors. private var subagents: [WorkSubagentSnapshot] { - let real = workChatInfoSubagents(subagentSnapshots) - return real.sorted { lhs, rhs in - let lhsWeight = lhs.status == .running ? 0 : 1 - let rhsWeight = rhs.status == .running ? 0 : 1 - return lhsWeight < rhsWeight - } + workChatInfoSubagents(subagentSnapshots) } private var backgroundItems: [WorkScheduledWorkSnapshot] { @@ -2601,7 +2635,137 @@ struct WorkChatInfoDetailsSheet: View { subagents.isEmpty && backgroundItems.isEmpty && scheduleItems.isEmpty } + private func jsonObject(_ raw: String) -> [String: Any] { + guard let data = raw.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return object + } + + private func jsonString(_ object: [String: Any], fallback: String) -> String { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + let value = String(data: data, encoding: .utf8) else { + return fallback + } + return value + } + + private func paneFlag(_ field: String, section: String) -> Bool { + let object = jsonObject(paneUiRaw) + let values = object[field] as? [String: Any] + return values?[section] as? Bool ?? false + } + + private func setPaneFlag(_ field: String, section: String, value: Bool) { + var object = jsonObject(paneUiRaw) + var values = object[field] as? [String: Any] ?? [:] + values[section] = value + object[field] = values + paneUiRaw = jsonString(object, fallback: #"{"collapsed":{},"earlier":{}}"#) + } + + private func clearedIds(_ section: String) -> Set { + let object = jsonObject(paneClearedRaw) + return Set((object[section] as? [String] ?? []).filter { !$0.isEmpty }) + } + + private func clear(_ section: String, ids: [String]) { + var object = jsonObject(paneClearedRaw) + let existing = object[section] as? [String] ?? [] + object[section] = (existing + ids).reduce(into: [String]()) { result, id in + if !id.isEmpty, !result.contains(id) { result.append(id) } + } + for key in ["subagents", "background", "schedule"] where object[key] == nil { + object[key] = [] + } + paneClearedRaw = jsonString(object, fallback: #"{"subagents":[],"background":[],"schedule":[]}"#) + } + + private func restore(_ section: String) { + var object = jsonObject(paneClearedRaw) + object[section] = [] + for key in ["subagents", "background", "schedule"] where object[key] == nil { + object[key] = [] + } + paneClearedRaw = jsonString(object, fallback: #"{"subagents":[],"background":[],"schedule":[]}"#) + } + + private func withPaneAnimation(_ changes: () -> Void) { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18), changes) + } + + private func partitionSubagents(_ items: [WorkSubagentSnapshot]) -> (active: [WorkSubagentSnapshot], earlier: [WorkSubagentSnapshot], clearedCount: Int) { + let cleared = clearedIds("subagents") + let pinned = Set([selectedTaskId].compactMap { $0 }).union(expandedTaskIds) + var active: [WorkSubagentSnapshot] = [] + var earlier: [WorkSubagentSnapshot] = [] + var clearedCount = 0 + for item in items { + if pinned.contains(item.taskId) { + active.append(item) + } else if cleared.contains(item.taskId) { + clearedCount += 1 + } else if workSubagentIsEarlier(item) { + earlier.append(item) + } else { + active.append(item) + } + } + return (active, earlier, clearedCount) + } + + private func partitionScheduled( + _ items: [WorkScheduledWorkSnapshot], + section: String, + isEarlier: (WorkScheduledWorkSnapshot) -> Bool + ) -> (active: [WorkScheduledWorkSnapshot], earlier: [WorkScheduledWorkSnapshot], clearedCount: Int) { + let cleared = clearedIds(section) + var active: [WorkScheduledWorkSnapshot] = [] + var earlier: [WorkScheduledWorkSnapshot] = [] + var clearedCount = 0 + for item in items { + if cleared.contains(item.id) { + clearedCount += 1 + } else if isEarlier(item) { + earlier.append(item) + } else { + active.append(item) + } + } + return (active, earlier, clearedCount) + } + + private func capped(_ items: [T], cap: Int, showAll: Bool, isExempt: (T) -> Bool) -> (visible: [T], hiddenCount: Int) { + guard !showAll, items.count > cap else { return (items, 0) } + let visible = items.enumerated().compactMap { index, item in + index < cap || isExempt(item) ? item : nil + } + return (visible, items.count - visible.count) + } + + private func sectionHint(active: Int, earlier: Int, hidden: Int, running: Int, failed: Int) -> String { + let activeLabel = running > 0 && failed > 0 ? "\(running) running · \(failed) failed" : "\(active)" + return ([activeLabel] + + (earlier > 0 ? ["\(earlier) earlier"] : []) + + (hidden > 0 ? ["\(hidden) hidden"] : [])) + .joined(separator: " · ") + } + var body: some View { + let subagentPartition = partitionSubagents(subagents) + let backgroundPartition = partitionScheduled(backgroundItems, section: "background", isEarlier: workBackgroundItemIsEarlier) + let schedulePartition = partitionScheduled(scheduleItems, section: "schedule", isEarlier: workScheduleItemIsEarlier) + let visibleSubagents = capped(subagentPartition.active, cap: subagentsCap, showAll: showAllSections.contains("subagents")) { + $0.status == .failed || selectedTaskId == $0.taskId || expandedTaskIds.contains($0.taskId) + } + let visibleBackground = capped(backgroundPartition.active, cap: backgroundCap, showAll: showAllSections.contains("background")) { + $0.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "failed" + } + let visibleSchedule = capped(schedulePartition.active, cap: scheduleCap, showAll: showAllSections.contains("schedule")) { + $0.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "failed" + } NavigationStack { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { @@ -2614,29 +2778,100 @@ struct WorkChatInfoDetailsSheet: View { .padding(.top, 24) } else { if !subagents.isEmpty { - section(title: "Subagents", count: subagents.count) { + section( + title: "Subagents", + hint: sectionHint( + active: subagentPartition.active.count, + earlier: subagentPartition.earlier.count, + hidden: subagentPartition.clearedCount, + running: subagentPartition.active.count { $0.status == .running }, + failed: subagentPartition.active.count { $0.status == .failed } + ), + key: "subagents", + collapsible: !subagentPartition.earlier.isEmpty || subagentPartition.active.count > subagentsCap, + clearIds: subagentPartition.earlier.map(\.taskId), + clearedCount: subagentPartition.clearedCount, + allClear: subagentPartition.active.isEmpty && subagentPartition.earlier.isEmpty && subagentPartition.clearedCount > 0 + ) { VStack(spacing: 6) { - ForEach(subagents) { snapshot in + if subagentPartition.active.isEmpty && subagentPartition.earlier.isEmpty && subagentPartition.clearedCount > 0 { + allClearRow("Subagents") + } + ForEach(visibleSubagents.visible) { snapshot in subagentRow(snapshot) } + showAllButton(section: "subagents", hiddenCount: visibleSubagents.hiddenCount) + earlierButton(section: "subagents", count: subagentPartition.earlier.count, clearedCount: subagentPartition.clearedCount) + if paneFlag("earlier", section: "subagents") { + ForEach(subagentPartition.earlier) { snapshot in subagentRow(snapshot) } + restoreButton(section: "subagents", count: subagentPartition.clearedCount) + } } } } if !backgroundItems.isEmpty { - section(title: "Background", count: backgroundItems.count) { + section( + title: "Background", + hint: sectionHint( + active: backgroundPartition.active.count, + earlier: backgroundPartition.earlier.count, + hidden: backgroundPartition.clearedCount, + running: backgroundPartition.active.count { $0.status.lowercased() == "running" }, + failed: backgroundPartition.active.count { $0.status.lowercased() == "failed" } + ), + key: "background", + collapsible: !backgroundPartition.earlier.isEmpty || backgroundPartition.active.count > backgroundCap, + clearIds: backgroundPartition.earlier.map(\.id), + clearedCount: backgroundPartition.clearedCount, + allClear: backgroundPartition.active.isEmpty && backgroundPartition.earlier.isEmpty && backgroundPartition.clearedCount > 0 + ) { VStack(spacing: 8) { - ForEach(backgroundItems) { item in + if backgroundPartition.active.isEmpty && backgroundPartition.earlier.isEmpty && backgroundPartition.clearedCount > 0 { allClearRow("Background") } + ForEach(visibleBackground.visible) { item in WorkBackgroundWorkRow(item: item) } + showAllButton(section: "background", hiddenCount: visibleBackground.hiddenCount) + earlierButton(section: "background", count: backgroundPartition.earlier.count, clearedCount: backgroundPartition.clearedCount) + if paneFlag("earlier", section: "background") { + ForEach(backgroundPartition.earlier) { item in WorkBackgroundWorkRow(item: item) } + restoreButton(section: "background", count: backgroundPartition.clearedCount) + } } } } if !scheduleItems.isEmpty { - section(title: "Schedule", count: scheduleItems.count) { + section( + title: "Schedule", + hint: sectionHint( + active: schedulePartition.active.count, + earlier: schedulePartition.earlier.count, + hidden: schedulePartition.clearedCount, + running: schedulePartition.active.count { $0.status.lowercased() == "running" }, + failed: schedulePartition.active.count { $0.status.lowercased() == "failed" } + ), + key: "schedule", + collapsible: !schedulePartition.earlier.isEmpty || schedulePartition.active.count > scheduleCap, + clearIds: schedulePartition.earlier.map(\.id), + clearedCount: schedulePartition.clearedCount, + allClear: schedulePartition.active.isEmpty && schedulePartition.earlier.isEmpty && schedulePartition.clearedCount > 0 + ) { VStack(spacing: 8) { - ForEach(scheduleItems) { item in + if schedulePartition.active.isEmpty && schedulePartition.earlier.isEmpty && schedulePartition.clearedCount > 0 { allClearRow("Schedule") } + ForEach(visibleSchedule.visible) { item in WorkScheduledWorkRow(item: item) } + showAllButton(section: "schedule", hiddenCount: visibleSchedule.hiddenCount) + earlierButton(section: "schedule", count: schedulePartition.earlier.count, clearedCount: schedulePartition.clearedCount) + if paneFlag("earlier", section: "schedule") { + ForEach(schedulePartition.earlier) { item in + if workScheduleItemIsFiredOneShotWakeup(item) { + WorkScheduledWorkRow(item: item).opacity(0.55).allowsHitTesting(false) + } else { + WorkScheduledWorkRow(item: item) + } + } + restoreButton(section: "schedule", count: schedulePartition.clearedCount) + } } } } @@ -2663,21 +2898,105 @@ struct WorkChatInfoDetailsSheet: View { } @ViewBuilder - private func section(title: String, count: Int, @ViewBuilder content: () -> Content) -> some View { + private func section( + title: String, + hint: String, + key: String, + collapsible: Bool, + clearIds: [String], + clearedCount: Int, + allClear: Bool, + @ViewBuilder content: () -> Content + ) -> some View { + let collapsed = collapsible && paneFlag("collapsed", section: key) VStack(alignment: .leading, spacing: 8) { - HStack { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - .textCase(.uppercase) - Spacer(minLength: 0) - Text("\(count)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) + HStack(spacing: 8) { + if collapsible { + Button { + withPaneAnimation { setPaneFlag("collapsed", section: key, value: !collapsed) } + } label: { + HStack(spacing: 6) { + Image(systemName: collapsed ? "chevron.right" : "chevron.down") + .font(.caption2.bold()) + Text(title) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + Spacer(minLength: 0) + Text(hint).font(.caption2.weight(.semibold)) + } + .foregroundStyle(ADEColor.textMuted) + .contentShape(.rect) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, minHeight: 44) + } else { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .textCase(.uppercase) + Spacer(minLength: 0) + Text(allClear ? "all clear" : hint) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + if allClear { + Button("Restore (\(clearedCount))") { restore(key) } + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } else if !clearIds.isEmpty { + Button("Clear") { clear(key, ids: clearIds) } + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } } - content() + if !collapsed { content() } } } + + @ViewBuilder + private func showAllButton(section: String, hiddenCount: Int) -> some View { + if hiddenCount > 0 { + Button("Show all (\(hiddenCount))") { showAllSections.insert(section) } + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .frame(minHeight: 44) + } + } + + @ViewBuilder + private func earlierButton(section: String, count: Int, clearedCount: Int) -> some View { + if count > 0 || clearedCount > 0 { + let expanded = paneFlag("earlier", section: section) + Button { + withPaneAnimation { setPaneFlag("earlier", section: section, value: !expanded) } + } label: { + Label( + "Earlier (\(count))\(clearedCount > 0 ? " · \(clearedCount) hidden" : "")", + systemImage: expanded ? "chevron.down" : "chevron.right" + ) + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .frame(minHeight: 44) + } + } + + @ViewBuilder + private func restoreButton(section: String, count: Int) -> some View { + if count > 0 { + Button("Restore (\(count))") { restore(section) } + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .frame(minHeight: 44) + } + } + + private func allClearRow(_ title: String) -> some View { + Text("\(title) · all clear") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } } /// A single subagent roster row inside Chat Info. Reuses the drawer row shape diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 00f5a9a1b..0f8479f7d 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -1760,7 +1760,7 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { return ["subagent_progress", turnId ?? "", taskId, agentId ?? "", agentType ?? "", parentToolUseId ?? "", description ?? "", summary, toolName ?? "", label ?? "", model ?? "", reasoningEffort ?? ""].joined(separator: "|") case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, let label, let model, let reasoningEffort, let turnId): return ["subagent_result", turnId ?? "", taskId, agentId ?? "", agentType ?? "", parentToolUseId ?? "", status, summary, label ?? "", model ?? "", reasoningEffort ?? ""].joined(separator: "|") - case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): + case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let firedAt, let late, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): var parts = ["scheduled_work_update", id, turnId ?? "", kind, status] parts.append(origin ?? "") parts.append(title ?? "") @@ -1770,6 +1770,8 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { parts.append(cron ?? "") parts.append(nextRunAt ?? "") parts.append(lastRunAt ?? "") + parts.append(firedAt ?? "") + parts.append(late.map { $0 ? "1" : "0" } ?? "") parts.append(recurring.map { $0 ? "1" : "0" } ?? "") parts.append(durable.map { $0 ? "1" : "0" } ?? "") parts.append(sourceToolUseId ?? "") diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index a8c8038c2..1c94c8384 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -241,7 +241,7 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { reasoningEffort: reasoningEffort, turnId: turnId ) - case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): + case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let firedAt, let late, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): return .scheduledWorkUpdate( id: id, kind: kind, @@ -254,6 +254,8 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { cron: cron, nextRunAt: nextRunAt, lastRunAt: lastRunAt, + firedAt: firedAt, + late: late, recurring: recurring, durable: durable, sourceToolUseId: sourceToolUseId, diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 3c731aefa..2b53e6d4f 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -635,6 +635,8 @@ struct WorkScheduledWorkSnapshot: Identifiable, Equatable { let cron: String? let nextRunAt: String? let lastRunAt: String? + let firedAt: String? + let late: Bool? let recurring: Bool? let durable: Bool? let sourceToolUseId: String? @@ -880,7 +882,7 @@ enum WorkChatEvent: Equatable { case subagentStarted(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String, background: Bool, label: String?, model: String?, reasoningEffort: String?, turnId: String?) case subagentProgress(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String?, summary: String, toolName: String?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) case subagentResult(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, status: String, summary: String, label: String?, model: String?, reasoningEffort: String?, turnId: String?) - case scheduledWorkUpdate(id: String, kind: String, status: String, origin: String?, title: String?, summary: String?, prompt: String?, reason: String?, cron: String?, nextRunAt: String?, lastRunAt: String?, recurring: Bool?, durable: Bool?, sourceToolUseId: String?, sourceTaskId: String?, turnId: String?, error: String?) + case scheduledWorkUpdate(id: String, kind: String, status: String, origin: String?, title: String?, summary: String?, prompt: String?, reason: String?, cron: String?, nextRunAt: String?, lastRunAt: String?, firedAt: String?, late: Bool?, recurring: Bool?, durable: Bool?, sourceToolUseId: String?, sourceTaskId: String?, turnId: String?, error: String?) case transcriptRetraction(messageIds: [String], reason: String?, replacementMessageId: String?, turnId: String?) case structuredQuestion(question: String, options: [WorkPendingQuestionOption], itemId: String, turnId: String?) case approvalRequest(description: String, detail: String?, itemId: String, turnId: String?) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 14aeb079e..cf9431ae3 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -795,6 +795,7 @@ struct WorkSessionDestinationView: View { } .sheet(isPresented: $chatInfoPresented) { WorkChatInfoDetailsSheet( + sessionId: sessionId, subagentSnapshots: subagentSnapshots, scheduledWorkSnapshots: scheduledWorkSnapshots, provider: subagentProvider, diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 4f2d2a10a..ee131b660 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -206,7 +206,7 @@ private func combineWorkChatEventSignature(_ event: WorkChatEvent, into hasher: combineOptionalText(model, into: &hasher) combineOptionalText(reasoningEffort, into: &hasher) combineOptional(turnId, into: &hasher) - case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): + case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let firedAt, let late, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): hasher.combine(id) hasher.combine(kind) hasher.combine(status) @@ -218,6 +218,8 @@ private func combineWorkChatEventSignature(_ event: WorkChatEvent, into hasher: combineOptional(cron, into: &hasher) combineOptional(nextRunAt, into: &hasher) combineOptional(lastRunAt, into: &hasher) + combineOptional(firedAt, into: &hasher) + combineOptional(late, into: &hasher) combineOptional(recurring, into: &hasher) combineOptional(durable, into: &hasher) combineOptional(sourceToolUseId, into: &hasher) @@ -1006,6 +1008,8 @@ func buildWorkScheduledWorkSnapshots(from transcript: [WorkChatEnvelope]) -> [Wo let cron, let nextRunAt, let lastRunAt, + let firedAt, + let late, let recurring, let durable, let sourceToolUseId, @@ -1036,6 +1040,8 @@ func buildWorkScheduledWorkSnapshots(from transcript: [WorkChatEnvelope]) -> [Wo cron: nonEmptyWorkTimelineText(cron) ?? existing?.snapshot.cron, nextRunAt: nonEmptyWorkTimelineText(nextRunAt) ?? existing?.snapshot.nextRunAt, lastRunAt: nonEmptyWorkTimelineText(lastRunAt) ?? existing?.snapshot.lastRunAt, + firedAt: nonEmptyWorkTimelineText(firedAt) ?? existing?.snapshot.firedAt, + late: late ?? existing?.snapshot.late, recurring: recurring ?? existing?.snapshot.recurring, durable: durable ?? existing?.snapshot.durable, sourceToolUseId: nonEmptyWorkTimelineText(sourceToolUseId) ?? existing?.snapshot.sourceToolUseId, @@ -1118,6 +1124,29 @@ func workChatInfoBackgroundItems( } } +func workSubagentIsEarlier(_ snapshot: WorkSubagentSnapshot) -> Bool { + snapshot.status == .succeeded || snapshot.status == .stopped +} + +func workBackgroundItemIsEarlier(_ snapshot: WorkScheduledWorkSnapshot) -> Bool { + let status = snapshot.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return status == "completed" || status == "cancelled" || status == "stopped" +} + +func workScheduleItemIsFiredOneShotWakeup(_ snapshot: WorkScheduledWorkSnapshot) -> Bool { + let kind = snapshot.kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let status = snapshot.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return (kind == "wakeup" || kind == "loop") + && snapshot.recurring != true + && (status == "fired" || status == "completed") +} + +func workScheduleItemIsEarlier(_ snapshot: WorkScheduledWorkSnapshot) -> Bool { + if workScheduleItemIsFiredOneShotWakeup(snapshot) { return true } + let status = snapshot.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return status == "completed" || status == "cancelled" || status == "stopped" +} + func workChatInfoSubagents( _ snapshots: [WorkSubagentSnapshot] ) -> [WorkSubagentSnapshot] { @@ -2491,7 +2520,7 @@ private func eventCard( // badge, and the Subagents drawer. Rendering every lifecycle envelope as // a normal event card makes mobile chats look much longer than desktop. return nil - case .scheduledWorkUpdate(_, let kind, let status, _, let title, let summary, let prompt, let reason, let cron, let nextRunAt, _, _, _, _, _, let turnId, let error): + case .scheduledWorkUpdate(_, let kind, let status, _, let title, let summary, let prompt, let reason, let cron, let nextRunAt, _, _, _, _, _, _, _, let turnId, let error): // Background shell commands are owned by the Chat Info pane's Background // section (and a compact timeline finish chip). Mirrors desktop, which // stops rendering an inline scheduled-work card for background_task. @@ -2873,7 +2902,7 @@ private func workTurnId(for event: WorkChatEvent) -> String? { .subagentStarted(_, _, _, _, _, _, _, _, _, let turnId), .subagentProgress(_, _, _, _, _, _, _, _, _, _, let turnId), .subagentResult(_, _, _, _, _, _, _, _, _, let turnId), - .scheduledWorkUpdate(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, let turnId, _), + .scheduledWorkUpdate(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, let turnId, _), .transcriptRetraction(_, _, _, let turnId), .structuredQuestion(_, _, _, let turnId), .approvalRequest(_, _, _, let turnId), diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index 0b5cc8310..9b356530e 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -247,6 +247,8 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { cron: optionalString(eventDict["cron"]), nextRunAt: optionalString(eventDict["nextRunAt"]), lastRunAt: optionalString(eventDict["lastRunAt"]), + firedAt: optionalString(eventDict["firedAt"]), + late: workBoolValue(eventDict["late"]), recurring: workBoolValue(eventDict["recurring"]), durable: workBoolValue(eventDict["durable"]), sourceToolUseId: optionalString(eventDict["sourceToolUseId"]), diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index aef8d9edb..32f25c1ca 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -9741,9 +9741,8 @@ final class ADETests: XCTestCase { /// Durable-wakeup parity (desktop 93d7f889): the host now emits `paused`, /// `fired`, and `late` on scheduled_work_update. iOS carries `status` as a raw - /// String, so new/unknown statuses must decode without crashing and must not - /// be treated as active. Extra host fields (`firedAt`, `late`) that iOS does - /// not model must be ignored, not fatal. + /// String, so new/unknown statuses must decode without crashing. Host + /// `firedAt` / `late` fields are retained for the Earlier history row. func testParseWorkChatTranscriptToleratesPausedAndUnknownScheduleStatuses() { let raw = """ {"sessionId":"chat-1","timestamp":"2026-07-08T00:00:01.000Z","sequence":1,"event":{"type":"scheduled_work_update","id":"cron-1","kind":"cron","status":"paused","origin":"schedule_cron","title":"Nightly checks","cron":"0 9 * * *","turnId":"turn-1"}} @@ -9763,20 +9762,23 @@ final class ADETests: XCTestCase { ) XCTAssertEqual(byId["cron-1"]?.status, "paused") XCTAssertEqual(byId["wakeup-2"]?.status, "fired") + XCTAssertEqual(byId["wakeup-2"]?.firedAt, "2026-07-08T00:00:02.000Z") + XCTAssertEqual(byId["wakeup-2"]?.late, true) XCTAssertEqual(byId["future-1"]?.status, "totally_new_status") - // Paused schedules are dormant, not active — they must not inflate the - // Chat Info active badge count. + // Paused schedules stay in the active partition but read dormant in UI. XCTAssertTrue(workScheduledWorkIsPaused("paused")) XCTAssertFalse(workScheduledWorkIsPaused("scheduled")) if let paused = byId["cron-1"] { - XCTAssertFalse(workScheduledWorkIsActive(paused)) + XCTAssertTrue(workScheduledWorkIsActive(paused)) } else { XCTFail("Expected paused snapshot") } - // `fired` still counts as active (an in-flight wakeup turn). + // Fired one-shot wakeups move to Earlier, while recurring fires stay active. if let fired = byId["wakeup-2"] { - XCTAssertTrue(workScheduledWorkIsActive(fired)) + XCTAssertTrue(workScheduleItemIsFiredOneShotWakeup(fired)) + XCTAssertTrue(workScheduleItemIsEarlier(fired)) + XCTAssertFalse(workScheduledWorkIsActive(fired)) } else { XCTFail("Expected fired snapshot") } @@ -9787,6 +9789,24 @@ final class ADETests: XCTestCase { XCTAssertEqual(Set(scheduleItems.map(\.id)), ["cron-1", "wakeup-2", "future-1"]) } + func testChatInfoEarlierMembershipMatchesDesktopPredicates() throws { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-07-08T00:00:01.000Z","sequence":1,"event":{"type":"scheduled_work_update","id":"bg-done","kind":"background_task","status":"completed","title":"Done"}} + {"sessionId":"chat-1","timestamp":"2026-07-08T00:00:02.000Z","sequence":2,"event":{"type":"scheduled_work_update","id":"bg-failed","kind":"background_task","status":"failed","title":"Failed"}} + {"sessionId":"chat-1","timestamp":"2026-07-08T00:00:03.000Z","sequence":3,"event":{"type":"scheduled_work_update","id":"wake-one","kind":"wakeup","status":"fired","recurring":false,"title":"One shot"}} + {"sessionId":"chat-1","timestamp":"2026-07-08T00:00:04.000Z","sequence":4,"event":{"type":"scheduled_work_update","id":"wake-recurring","kind":"wakeup","status":"fired","recurring":true,"title":"Recurring"}} + {"sessionId":"chat-1","timestamp":"2026-07-08T00:00:05.000Z","sequence":5,"event":{"type":"scheduled_work_update","id":"cron-cancelled","kind":"cron","status":"cancelled","title":"Cancelled"}} + """ + let snapshots = buildWorkScheduledWorkSnapshots(from: parseWorkChatTranscript(raw)) + let byId = Dictionary(uniqueKeysWithValues: snapshots.map { ($0.id, $0) }) + + XCTAssertTrue(workBackgroundItemIsEarlier(try XCTUnwrap(byId["bg-done"]))) + XCTAssertFalse(workBackgroundItemIsEarlier(try XCTUnwrap(byId["bg-failed"]))) + XCTAssertTrue(workScheduleItemIsEarlier(try XCTUnwrap(byId["wake-one"]))) + XCTAssertFalse(workScheduleItemIsEarlier(try XCTUnwrap(byId["wake-recurring"]))) + XCTAssertTrue(workScheduleItemIsEarlier(try XCTUnwrap(byId["cron-cancelled"]))) + } + func testWorkTimelineKeepsSubagentsOutOfMainActivityBundles() { // The two activity updates are consecutive so they cluster into one bundle; // the real subagent's spawn row is a hard timeline boundary that sits diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 2c30c41e3..d1bcfb57d 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -59,8 +59,8 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts` | Resolves the OpenCode CLI: PATH first, then the bundled `node_modules/.bin/opencode`. Cache entries are re-validated with `canRunBinaryCandidate` on every lookup so user installs after launch are picked up; missing-binary lookups are intentionally not cached. `clearOpenCodeBinaryCache()` is wired into the AI integration's full cache reset. | | `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize retired Sonnet 4.6 / basic Opus 4.7 ids to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | -| `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `normalizeSubagentLifecycleEvent` (canonicalizes legacy `subagent_*` and dotted `subagent.*` envelopes), `buildSubagentPaneRows`, `selectedSubagentSnapshot`, `subagentIndexForPaneLine`, `subagentPaneSelectableLineOffsets`, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. It also owns the shared subagent-vs-background classification (`isBackgroundShellCommand`, `isRealSubagent`, `subagentAgentKey`), summary-quality helpers (`preferSubagentSummary`, `longerSubagentText`, `preferredSubagentAgentType`, `SUBAGENT_PLACEHOLDER_SUMMARY`), and `deriveSubagentTimelineRows` → `SubagentTimelineRow` (`spawn` / `result` / `background_chip`), the inline transcript timeline that desktop `SubagentActivityCards` and iOS `buildWorkSubagentTimelineRows` both mirror. Both the desktop `ChatSubagentsPanel` and the `apps/ade-cli/src/tuiClient/subagentPane.ts` / `chatInfo.ts` modules re-export from here so the desktop pane and the terminal TUI render the same roster, transcript filter, and plan summary. | -| `apps/desktop/src/shared/chatScheduledWork.ts` | Cross-target scheduled-work derivation. Folds `scheduled_work_update` envelopes into stable snapshots for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work, then partitions them by surface: `deriveScheduleItems` / `deriveActiveScheduleItems` / `deriveScheduleHistory` return the schedule kinds (`wakeup` / `cron` / `loop` / `remote_trigger`) for the Schedule block, while `deriveBackgroundItems` returns `background_task` rows for a separate Background block. Also owns next-fire labels (`scheduledNextFireLabel`, `compactRelativeDuration`, `nextCronFireAt`) and the `backgroundCommandLabel` / `backgroundCommandCwd` helpers that turn a wrapped background shell command into a readable `$
) : null} + {mainTranscriptView ? ( +
+
+
Full session transcript (SDK)
+
Provider-fidelity view — ADE events (approvals, schedules, notices) are not shown.
+
+ +
+ ) : null} {/* Codex chat goal is rendered in the Agents tab via ChatSubagentsPanel; the in-chat banner was removed so the chat header stays clean and goal context lives next to subagents + progress where it belongs. */} 0, )} loadingOlderHistory={Boolean( !subagentView + && !mainTranscriptView && selectedSessionId && olderHistoryLoadingBySession[selectedSessionId], )} - onLoadOlderHistory={!subagentView && selectedSessionId ? loadOlderHistoryForSelectedSession : undefined} + onLoadOlderHistory={!subagentView && !mainTranscriptView && selectedSessionId ? loadOlderHistoryForSelectedSession : undefined} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} laneId={laneId} @@ -10969,8 +11069,8 @@ export function AgentChatPane({ }} onCodexRecovery={(args: AgentChatRecoverCodexTurnArgs) => window.ade.agentChat.recoverCodexTurn(args)} - mosaic={subagentView ? undefined : mosaicContext} - scrollToRowKeyRequest={subagentView ? null : wakeJumpRequest} + mosaic={subagentView || mainTranscriptView ? undefined : mosaicContext} + scrollToRowKeyRequest={subagentView || mainTranscriptView ? null : wakeJumpRequest} /> {sessionDelta ? (
diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 8f55f55c9..739e3d3a6 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -834,6 +834,7 @@ export function ChatSubagentsPanel({ backgroundItems = [], schedulesPaused = false, onToggleSchedulesPaused, + onViewMainTranscript, }: { sessionId?: string | null; snapshots: ChatSubagentSnapshot[]; @@ -866,6 +867,8 @@ export function ChatSubagentsPanel({ schedulesPaused?: boolean; /** Pause or resume all durable schedules for this chat. */ onToggleSchedulesPaused?: () => void; + /** Opens the provider-fidelity transcript for the parent Claude session. */ + onViewMainTranscript?: () => void; }) { const [expanded, setExpanded] = useState(false); const [paneUi, setPaneUi] = useState(() => readPaneUiState(sessionId)); @@ -1508,6 +1511,18 @@ export function ChatSubagentsPanel({
) : null} + {onViewMainTranscript ? ( +
+ +
+ ) : null} +
); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx index fed840122..84e752b3a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.test.tsx @@ -106,6 +106,20 @@ describe("SessionCard orchestration identity", () => { expect(screen.queryByLabelText("Awaiting your input")).toBeNull(); }); + + it("renders a Claude session tag beside the title", () => { + render( + , + ); + + expect(screen.getByText("customer-ready").getAttribute("title")).toBe("customer-ready"); + }); }); describe("SessionCard auto-naming status", () => { diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index d53397c0e..c08ff967a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -365,6 +365,17 @@ export const SessionCard = React.memo(function SessionCard({ > {primaryText} + {session.claudeTag?.trim() ? ( + + {session.claudeTag.trim()} + + ) : null} {attentionBadge ? : null}
{importedFrom ? ( diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx new file mode 100644 index 000000000..b121ec597 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -0,0 +1,76 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { SessionContextMenu } from "./SessionContextMenu"; + +afterEach(cleanup); + +function makeSession(overrides: Partial = {}): TerminalSessionSummary { + return { + id: "chat-1", + laneId: "lane-1", + laneName: "Lane 1", + ptyId: null, + tracked: true, + pinned: false, + goal: null, + toolType: "claude-chat", + title: "Claude chat", + status: "running", + startedAt: "2026-07-10T12:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "idle", + resumeCommand: null, + ...overrides, + }; +} + +function renderMenu(session: TerminalSessionSummary, onSetChatTag = vi.fn()) { + const onClose = vi.fn(); + render( + , + ); + return { onClose, onSetChatTag }; +} + +describe("SessionContextMenu Claude tags", () => { + it("opens an inline input and treats an empty submit as clearing the tag", () => { + const session = makeSession({ claudeTag: "review-ready" }); + const { onClose, onSetChatTag } = renderMenu(session); + + fireEvent.click(screen.getByRole("button", { name: "Set tag…" })); + const input = screen.getByRole("textbox", { name: "Set Claude session tag" }) as HTMLInputElement; + expect(input.value).toBe("review-ready"); + fireEvent.change(input, { target: { value: " " } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSetChatTag).toHaveBeenCalledWith(session, null); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not offer SDK tags for non-Claude chat sessions", () => { + renderMenu(makeSession({ toolType: "codex-chat" })); + expect(screen.queryByRole("button", { name: "Set tag…" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx index 71fdaa0e2..5bfed2538 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -20,6 +20,7 @@ type SessionContextMenuProps = { onGoToLane: (session: TerminalSessionSummary) => void; onCopySessionId: (id: string) => void; onRename: (session: TerminalSessionSummary, newTitle: string) => void; + onSetChatTag?: (session: TerminalSessionSummary, tag: string | null) => void; onCopySessionDeepLink?: (session: TerminalSessionSummary) => void; onOpenSessionInWeb?: (session: TerminalSessionSummary) => void; onTogglePinned?: (session: TerminalSessionSummary) => void; @@ -40,6 +41,7 @@ export function SessionContextMenu({ onGoToLane, onCopySessionId, onRename, + onSetChatTag, onCopySessionDeepLink, onOpenSessionInWeb, onTogglePinned, @@ -48,28 +50,30 @@ export function SessionContextMenu({ onRemoveFromGrid, }: SessionContextMenuProps) { const [renaming, setRenaming] = useState(false); + const [tagging, setTagging] = useState(false); const [draft, setDraft] = useState(""); const inputRef = useRef(null); const finalizedRef = useRef(false); const { ref: menuRef, position: clampedPosition } = useClampedFixedPosition( menu ? { x: menu.x, y: menu.y } : null, - renaming, + renaming || tagging, ); - // Reset rename state when menu changes + // Reset inline edit state when the target menu changes. useEffect(() => { setRenaming(false); + setTagging(false); setDraft(""); finalizedRef.current = false; }, [menu]); - // Focus input when entering rename mode + // Focus whichever inline editor was opened. useEffect(() => { - if (renaming && inputRef.current) { + if ((renaming || tagging) && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } - }, [renaming]); + }, [renaming, tagging]); if (!menu) return null; @@ -87,6 +91,13 @@ export function SessionContextMenu({ } onClose(); }; + const commitTag = () => { + if (finalizedRef.current) return; + finalizedRef.current = true; + const trimmed = draft.trim(); + onSetChatTag?.(session, trimmed.length ? trimmed : null); + onClose(); + }; return ( <> @@ -122,7 +133,26 @@ export function SessionContextMenu({ />
)} - {!renaming && ( + {tagging && ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); commitTag(); } + if (e.key === "Escape") { e.preventDefault(); finalizedRef.current = true; onClose(); } + }} + onBlur={commitTag} + className="w-full rounded border border-border/30 bg-transparent px-2 py-1 text-xs text-[--color-fg] outline-none focus:border-[--color-accent]" + placeholder="Tag (empty clears)..." + maxLength={48} + /> +
+ )} + {!renaming && !tagging && ( )} + {!renaming && !tagging && session.toolType === "claude-chat" && onSetChatTag ? ( + + ) : null} {isRunning && session.ptyId && !isChat ? ( )} - {!renaming && !tagging && session.toolType === "claude-chat" && onSetChatTag ? ( + {/* Tag writes need a live Claude SDK runtime (updateSession throws for + ended sessions), so only offer the item while the session runs. */} + {!renaming && !tagging && session.toolType === "claude-chat" && isRunning && onSetChatTag ? ( - ) : null} - - )} - /> - - {scheduleAllClear ?
Schedule · all clear
: null} - {cappedSchedule.visible.length ? ( -
- {cappedSchedule.visible.map((item) => ( - - ))} -
- ) : null} - {cappedSchedule.hiddenCount > 0 ? setShowAll((current) => ({ ...current, schedule: true }))} /> : null} - {scheduleGroups.earlier.length > 0 || scheduleGroups.clearedCount > 0 ? ( -
- toggleEarlier("schedule")} /> - -
- {scheduleGroups.earlier.map((item) => isFiredOneShotWakeup(item) - ? - : )} -
- {scheduleGroups.clearedCount > 0 ?
restoreCleared("schedule")}>Restore ({scheduleGroups.clearedCount})
: null} -
-
- ) : null} -
- + {onToggleSchedulesPaused ? ( + + ) : null}} + idOf={(item) => item.id} + renderActiveRow={(item) => } + renderEarlierRow={(item) => isFiredOneShotWakeup(item) + ? + : } + onToggleCollapsed={() => toggleSection("schedule")} onToggleEarlier={() => toggleEarlier("schedule")} + onClear={(ids) => clearEarlier("schedule", ids)} onRestore={() => restoreCleared("schedule")} + onShowAll={() => setShowAll((current) => ({ ...current, schedule: true }))} showAll={showAll.schedule === true} + hasPrecedingSection={Boolean(hasGoal || plan || hasTasks || hasSubagents || hasBackground)} + animateActiveRows={false} keepEmptyActiveList={false} + /> ) : null} {/* ── Single-agent empty state ─────────────────────────────── */} diff --git a/apps/desktop/src/shared/chatSubagents.ts b/apps/desktop/src/shared/chatSubagents.ts index 5896caa2f..9225b8809 100644 --- a/apps/desktop/src/shared/chatSubagents.ts +++ b/apps/desktop/src/shared/chatSubagents.ts @@ -56,6 +56,9 @@ export type ChatInfoPlan = { live: boolean; } | null; +// iOS mirrors these caps, the pane storage-key/empty-state shapes, and the +// section-hint format in WorkChatRichCardViews.swift (WorkChatInfoDetailsSheet) +// — keep the twins in sync when changing any of them. export const SUBAGENTS_ACTIVE_CAP = 12; export const BACKGROUND_ACTIVE_CAP = 8; export const SCHEDULE_ACTIVE_CAP = 10; diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 0331c1e15..54d30edb8 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2583,6 +2583,10 @@ struct WorkChatInfoDetailsSheet: View { @State private var showAllSections: Set = [] @Environment(\.accessibilityReduceMotion) private var reduceMotion + // Mirrors SUBAGENTS_ACTIVE_CAP / BACKGROUND_ACTIVE_CAP / SCHEDULE_ACTIVE_CAP, + // the pane storage-key/empty-state shapes, and the section-hint format in + // apps/desktop/src/shared/chatSubagents.ts + ChatSubagentsPanel.tsx — keep + // the twins in sync when changing any of them. private let subagentsCap = 12 private let backgroundCap = 8 private let scheduleCap = 10 @@ -2793,20 +2797,7 @@ struct WorkChatInfoDetailsSheet: View { clearedCount: subagentPartition.clearedCount, allClear: subagentPartition.active.isEmpty && subagentPartition.earlier.isEmpty && subagentPartition.clearedCount > 0 ) { - VStack(spacing: 6) { - if subagentPartition.active.isEmpty && subagentPartition.earlier.isEmpty && subagentPartition.clearedCount > 0 { - allClearRow("Subagents") - } - ForEach(visibleSubagents.visible) { snapshot in - subagentRow(snapshot) - } - showAllButton(section: "subagents", hiddenCount: visibleSubagents.hiddenCount) - earlierButton(section: "subagents", count: subagentPartition.earlier.count, clearedCount: subagentPartition.clearedCount) - if paneFlag("earlier", section: "subagents") { - ForEach(subagentPartition.earlier) { snapshot in subagentRow(snapshot) } - restoreButton(section: "subagents", count: subagentPartition.clearedCount) - } - } + scalableSectionBody(title: "Subagents", sectionKey: "subagents", spacing: 6, partition: subagentPartition, visible: visibleSubagents) { snapshot, _ in subagentRow(snapshot) } } } if !backgroundItems.isEmpty { @@ -2825,18 +2816,7 @@ struct WorkChatInfoDetailsSheet: View { clearedCount: backgroundPartition.clearedCount, allClear: backgroundPartition.active.isEmpty && backgroundPartition.earlier.isEmpty && backgroundPartition.clearedCount > 0 ) { - VStack(spacing: 8) { - if backgroundPartition.active.isEmpty && backgroundPartition.earlier.isEmpty && backgroundPartition.clearedCount > 0 { allClearRow("Background") } - ForEach(visibleBackground.visible) { item in - WorkBackgroundWorkRow(item: item) - } - showAllButton(section: "background", hiddenCount: visibleBackground.hiddenCount) - earlierButton(section: "background", count: backgroundPartition.earlier.count, clearedCount: backgroundPartition.clearedCount) - if paneFlag("earlier", section: "background") { - ForEach(backgroundPartition.earlier) { item in WorkBackgroundWorkRow(item: item) } - restoreButton(section: "background", count: backgroundPartition.clearedCount) - } - } + scalableSectionBody(title: "Background", sectionKey: "background", spacing: 8, partition: backgroundPartition, visible: visibleBackground) { item, _ in WorkBackgroundWorkRow(item: item) } } } if !scheduleItems.isEmpty { @@ -2855,23 +2835,12 @@ struct WorkChatInfoDetailsSheet: View { clearedCount: schedulePartition.clearedCount, allClear: schedulePartition.active.isEmpty && schedulePartition.earlier.isEmpty && schedulePartition.clearedCount > 0 ) { - VStack(spacing: 8) { - if schedulePartition.active.isEmpty && schedulePartition.earlier.isEmpty && schedulePartition.clearedCount > 0 { allClearRow("Schedule") } - ForEach(visibleSchedule.visible) { item in + scalableSectionBody(title: "Schedule", sectionKey: "schedule", spacing: 8, partition: schedulePartition, visible: visibleSchedule) { item, isEarlier in + if isEarlier && workScheduleItemIsFiredOneShotWakeup(item) { + WorkScheduledWorkRow(item: item).opacity(0.55).allowsHitTesting(false) + } else { WorkScheduledWorkRow(item: item) } - showAllButton(section: "schedule", hiddenCount: visibleSchedule.hiddenCount) - earlierButton(section: "schedule", count: schedulePartition.earlier.count, clearedCount: schedulePartition.clearedCount) - if paneFlag("earlier", section: "schedule") { - ForEach(schedulePartition.earlier) { item in - if workScheduleItemIsFiredOneShotWakeup(item) { - WorkScheduledWorkRow(item: item).opacity(0.55).allowsHitTesting(false) - } else { - WorkScheduledWorkRow(item: item) - } - } - restoreButton(section: "schedule", count: schedulePartition.clearedCount) - } } } } @@ -2897,6 +2866,25 @@ struct WorkChatInfoDetailsSheet: View { ) } + @ViewBuilder + private func scalableSectionBody( + title: String, sectionKey: String, spacing: CGFloat, + partition: (active: [Item], earlier: [Item], clearedCount: Int), + visible: (visible: [Item], hiddenCount: Int), + @ViewBuilder row: @escaping (Item, _ isEarlier: Bool) -> Row + ) -> some View { + VStack(spacing: spacing) { + if partition.active.isEmpty && partition.earlier.isEmpty && partition.clearedCount > 0 { allClearRow(title) } + ForEach(visible.visible) { item in row(item, false) } + showAllButton(section: sectionKey, hiddenCount: visible.hiddenCount) + earlierButton(section: sectionKey, count: partition.earlier.count, clearedCount: partition.clearedCount) + if paneFlag("earlier", section: sectionKey) { + ForEach(partition.earlier) { item in row(item, true) } + restoreButton(section: sectionKey, count: partition.clearedCount) + } + } + } + @ViewBuilder private func section( title: String, From b207dcd2751d41567739c39ab6d55c3fb674c0b4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:22:36 -0400 Subject: [PATCH 8/9] =?UTF-8?q?test(chat):=20/test=20parity=20pass=20?= =?UTF-8?q?=E2=80=94=20docs,=20CLI=20--tag,=20TUI=20historyInvalidated=20+?= =?UTF-8?q?=20tag=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs: document the Set tag session action, iOS Chat Info caps/Earlier parity, and drop a stale ChatSubagentStrip reference. CLI: --tag flag on personal chat update (empty clears), with tests. TUI: refetch history on session_meta_updated historyInvalidated (mirrors desktop's post-repair reload) and render a #tag suffix in Drawer chat rows following the wake-chip convention. Mobile parity audit: no Swift changes required (all contract deltas decode tolerantly). Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/cli.test.ts | 22 +++++++++++++++++++ apps/ade-cli/src/cli.ts | 6 +++++ apps/ade-cli/src/tuiClient/app.tsx | 12 ++++++++++ .../src/tuiClient/components/Drawer.tsx | 8 ++++++- docs/features/chat/composer-and-ui.md | 8 +++---- .../sync-and-multi-device/ios-companion.md | 6 ++++- .../terminals-and-sessions/ui-surfaces.md | 11 ++++++++-- 7 files changed, 65 insertions(+), 8 deletions(-) diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 541d3a7ec..1b010b0dd 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2414,6 +2414,8 @@ describe("ADE CLI", () => { "--personal", "--title", "Trip planning", + "--tag", + "review-ready", "--reasoning-effort", "high", "--fast", @@ -2424,12 +2426,32 @@ describe("ADE CLI", () => { args: { sessionId: "personal-1", title: "Trip planning", + tag: "review-ready", reasoningEffort: "high", fastMode: true, }, }, }); + // `--tag ""` clears the Claude session tag (empty string is forwarded, not dropped). + const clearTag = expectExecutePlan(buildCliPlan([ + "chat", + "update", + "personal-1", + "--personal", + "--tag", + "", + ])); + expect(clearTag.steps[0]).toMatchObject({ + params: { + action: "updateSession", + args: { + sessionId: "personal-1", + tag: "", + }, + }, + }); + expect(() => buildCliPlan([ "chat", "list", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index b1af49791..322e2423c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1522,6 +1522,8 @@ const HELP_BY_COMMAND: Record = { $ ade chat steer --personal --text "focus on the tradeoffs" $ ade chat models --personal --provider codex $ ade chat update --personal --title "Trip planning" + $ ade chat update --personal --tag "review-ready" + Claude-only; --tag "" clears it $ ade chat message --kind auto --text "status" Deliver via auto | queue | wake | interrupt-replace $ ade chat steer --text "context" Steer/queue context into an active turn @@ -7295,6 +7297,9 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { if (sub === "update" || sub === "configure") { const model = readValue(args, ["--model", "--model-id"]); const title = readValue(args, ["--title"]); + // Claude-only session tag mirrored to the SDK. Pass `--tag ""` to clear it. + // Rejected at runtime for non-Claude sessions or before a Claude turn exists. + const tag = readValue(args, ["--tag"]); const provider = readValue(args, ["--provider"]); const reasoningEffort = readValue(args, ["--reasoning-effort", "--effort"]); const permissionMode = readValue(args, ["--permission-mode", "--permissions"]); @@ -7305,6 +7310,7 @@ function buildPersonalChatPlan(sub: string, args: string[]): CliPlan { steps: [personalChatStep("updateSession", collectGenericObjectArgs(args, { sessionId, ...(title !== null ? { title } : {}), + ...(tag !== null ? { tag } : {}), ...(provider ? { provider } : {}), ...(model ? { model, modelId: model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 71a6e6e23..8855c86d8 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -7613,6 +7613,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }; }); } + // A backend self-heal/splice-repair rewrote persisted envelope history for + // the active chat (session_meta_updated · historyInvalidated). Our in-memory + // event buffer is now stale relative to the repaired turns and stays that + // way until a reconnect/gap, so refetch history — mirroring desktop + // AgentChatPane's loadHistory(force) on the same signal. + if ( + envelope.event.type === "session_meta_updated" + && envelope.event.historyInvalidated === true + && isActiveSessionEvent + ) { + void refreshStateRef.current({ hydrateHistory: true }).catch(() => undefined); + } // A cross-client mode change (iOS/desktop re-moding the session the TUI is // viewing) arrives as a transient session_meta_updated carrying the new // permission/interaction fields. The composer footer reads modelState, not diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index cbd7f0bb5..c220612ff 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -630,9 +630,14 @@ function ChatRow({ // NextWakeChip parity); reserve its width so the title never collides with it. const wake = chatNextWakeLabel(session); const wakeReserve = wake ? wake.length + 3 : 0; + // A tagged Claude chat shows a compact trailing "#tag" marker (desktop + // SessionCard claudeTag pill parity); reserve its width like the wake chip so + // the title truncates ahead of it instead of colliding. + const tag = session.claudeTag?.trim() ? `#${truncate(session.claudeTag.trim(), 16)}` : null; + const tagReserve = tag ? tag.length + 1 : 0; // The age used to reserve trailing room; without it the title can run wider, // keeping just a little space for the spinner + active dot. - const label = truncate(formatSessionLabel(session), drawerChatLabelWidth(max, wakeReserve)); + const label = truncate(formatSessionLabel(session), drawerChatLabelWidth(max, wakeReserve + tagReserve)); // Selection/hover wins with violet; awaiting-input tints amber as a calm // "needs you" signal; otherwise the title sits a touch dimmer under a // non-selected lane (dimTitle) than under the focused one. @@ -647,6 +652,7 @@ function ChatRow({ {running ? {" "} : {dot.glyph} } {exec.glyph} {label} + {tag ? {` ${tag}`} : null} {wake ? {` ⏰${wake}`} : null} {running ? : null} {session.sessionId === activeSessionId ? ( diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 7b06d277f..4d4bfa847 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -30,7 +30,7 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/shared/chatScheduledWork.ts` | Pure scheduled-work derivation. Folds `scheduled_work_update` envelopes into Chat Info schedule rows for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work; defines the shared Background/Schedule Earlier predicates (including fired one-shot wakeups); and formats next-fire labels. Shared by desktop, ADE Code, and mirrored by iOS. | | `ChatFileChangesPanel.tsx` | Turn-level file change summary with lazy diff expansion. | | `RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Undo confirmation for provider-backed file rewind. Builds a message-scoped file list from provider dry-run output plus turn diff summaries, then renders per-file expandable diffs before applying `rewindFiles`. Claude uses SDK file checkpoints; Codex uses `thread/rollback` for the latest user message and restores files through ADE's git plan. | -| `ChatSubagentsPanel.tsx`, `ChatSubagentStrip.tsx` | Chat Info panels. They render the Codex goal card, latest plan, tasks, schedule, and subagent/background rosters. Large sections cap active rows and add Show all; terminal rows move into one Earlier fold; Clear/Restore is a visual per-session filter. Failed and pinned rows remain active, survivors keep source order, and the pane variant owns a single scroller with sticky section headers. The Schedule header keeps the per-chat pause/play action beside Clear. For Codex sessions the goal card stays above plan/subagent progress so the current objective stays visible without crowding the chat header. | +| `ChatSubagentsPanel.tsx` | Chat Info panel. It renders the Codex goal card, latest plan, tasks, schedule, and subagent/background rosters. Large sections cap active rows and add Show all; terminal rows move into one Earlier fold; Clear/Restore is a visual per-session filter. Failed and pinned rows remain active, survivors keep source order, and the pane variant owns a single scroller with sticky section headers. The Schedule header keeps the per-chat pause/play action beside Clear. For Codex sessions the goal card stays above plan/subagent progress so the current objective stays visible without crowding the chat header. | | `ChatComputerUsePanel.tsx` | Computer-use backend status. | | `ChatAppControlPanel.tsx` | App Control panel for Electron apps. Two mount points: under the chat composer (chat-scoped, `sessionId` set) and inside the Work right-edge sidebar (lane-scoped, `sessionId={null}`). Two modes: **Control** (live screencast frames + launch/connect form + click/type input + quick `terminal write` / `terminal signal` actions) and **Inspect** (hit-test crosshair on the screenshot; commits selections as `AppControlContextItem`s with screenshot, DOM packet, and source-file candidates). Persists panel state under `sessionStorage["ade.chat.appControlPanel."]`, where the key is `chat:` for the chat mount and `lane::` for the sidebar mount. Connect/launch calls forward `laneId` so the resulting `AppControlSession` records its launching lane. See [App Control](../computer-use/app-control.md). | | `ChatIosSimulatorPanel.tsx` | macOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, `interact` vs `inspect` mode, hit-test overlay, and selection emission as `IosElementContextItem`. Accepts an optional `laneId` prop, forwarded into `iosSimulator.launch` so the resulting `IosSimulatorSession` records its launching lane. Simulator controls are not blocked when another chat session owns the simulator — ownership only affects which session receives context insertions, not whether the user can interact with the device. See [iOS Simulator feature](../ios-simulator/README.md). | @@ -537,9 +537,9 @@ emits `subagent_started`, `subagent_progress`, and `subagent_result` events. `ChatSubagentsPanel` renders running/completed/failed/stopped subagents with usage metrics. The same panel also renders the current Codex goal, plan, `todo_update` task list, and the Schedule section -derived from `scheduled_work_update` events. `ChatSubagentStrip` is the -compact header strip showing running subagent count, while the Work tab -actions badge also counts scheduled work when no subagents are present. +derived from `scheduled_work_update` events. The Work tab actions badge +shows the running subagent count and also counts scheduled work when no +subagents are present. The same lifecycle events also render inline in the transcript as identity-anchored spawn/result cards (background shells collapse to a single finish chip) via `deriveSubagentTimelineRows()` diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 6c01547b2..d791f4863 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1457,7 +1457,11 @@ does not duplicate the full desktop Stats page. mirroring `deriveSubagentTimelineRows` in `chatSubagents.ts` so a subagent never repaints per tick. `WorkChatRichCardViews` renders those rows plus the unified Chat Info sheet, whose ordered Subagents / - Background / Schedule sections mirror the desktop Chat Info pane. + Background / Schedule sections mirror the desktop Chat Info pane — + including the same active caps (12 / 8 / 10), the single `Earlier` + disclosure that folds terminal rows without reordering survivors, and + the per-session Clear/Restore filter (persisted under + `ade.chat.paneCleared.v1:`). - **Long Work chats must keep row work and root polling cheap.** The Work chat detail keeps the full timeline snapshot preview-free, then attaches cached initial assistant-message previews only to the visible diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index b5b1c23eb..6cd335c97 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -620,8 +620,8 @@ Launch commands are built by `apps/desktop/src/shared/cliLaunch.ts`: Right-click menu with branches per session type: -- Chat: Rename (inline text input, sets `manuallyNamed: true`), Delete, - archive/restore, Go to lane, Copy session ID. +- Chat: Rename (inline text input, sets `manuallyNamed: true`), Set tag… + (Claude only), Delete, archive/restore, Go to lane, Copy session ID. - PTY: Stop runtime (dispatches `ptyDispose`), Go to lane, Copy session ID. @@ -629,6 +629,13 @@ The rename input uses a local state and submits via `sessions.updateMeta({ title, manuallyNamed: true })`. Errors bubble up to `renameError` in `TerminalsPage`. +`Set tag…` is a second inline editor that reuses the same input chrome. +It appears only for running `claude-chat` sessions (writing a tag needs a +live Claude SDK runtime — `updateSession` throws for ended sessions), +submits `agentChat.updateSession({ sessionId, tag })` where an empty +value clears the tag, and the resolved `claudeTag` renders as a small +mono pill on the session card. + ## Work view hook: `useWorkSessions.ts` A single hook that owns a lot of state: From 431adbbe6d0cddc48236d62c4805f9f19f70a134 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:48:44 -0400 Subject: [PATCH 9/9] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20address?= =?UTF-8?q?=20CodeRabbit=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact-match-only SDK rebuild in the envelope splice repair (superset SDK text no longer splices unseen content into history; falls back to the lossless local merge), fix the subprocess reaper's SIGKILL escalation (child.killed only means signal-sent, gate on exit/signal codes), gate the TUI roster hint on uncleared snapshots instead of visible rows, precompute the roster key->index map, and extend the tag context-menu tests (ended-session gate + non-empty submission). Co-Authored-By: Claude Fable 5 --- .../src/tuiClient/components/RightPane.tsx | 7 ++++-- .../chat/chatEnvelopeSpliceRepair.test.ts | 13 ++++++++++ .../services/chat/chatEnvelopeSpliceRepair.ts | 11 ++++---- .../chat/claudeSubprocessReaper.test.ts | 25 +++++++++++++++++++ .../services/chat/claudeSubprocessReaper.ts | 7 ++++-- .../terminals/SessionContextMenu.test.tsx | 19 ++++++++++++++ 6 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 953d4016e..cb34104cd 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -843,7 +843,9 @@ function ChatInfoRoster({ const selected = Math.max(-1, Math.min(selectedIndex, totalSelectable - 1)); const mainSelected = selected === 0; const showingMain = !info.inspectedSubagentId; - const hint = snapshotRows.length === 0 + // Gate on the full uncleared snapshot list, not the visible rows — a + // collapsed section empties snapshotRows while agents are still running. + const hint = countedSnapshots.length === 0 ? "0 live" : [ `${runCount} live`, @@ -868,6 +870,7 @@ function ChatInfoRoster({ selected, SUBAGENT_PANE_ROSTER_CAPACITY, ); + const rosterIndexByKey = new Map(snapshotRows.map((row, index) => [row.key, index])); return ( @@ -906,7 +909,7 @@ function ChatInfoRoster({ if (row.kind === "restore-cleared") { return {` restore (${row.count})`}; } - const rosterIndex = snapshotRows.findIndex((candidate) => candidate.key === row.key); + const rosterIndex = rosterIndexByKey.get(row.key) ?? -1; const isSelected = !mainSelected && subagentSelectedIndex === rosterIndex; const kind = subagentAgentKind(row.snapshot.status); // Background rows get a cyan glyph tint so the eye can sort them out diff --git a/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.test.ts b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.test.ts index b0d75091b..0df7c4e96 100644 --- a/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.test.ts +++ b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.test.ts @@ -158,6 +158,19 @@ describe("repairSplicedEnvelopeLines", () => { expect(twice).toEqual({ lines: once.lines, changed: false, repairedTurns: 0 }); }); + it("falls back to a local merge when SDK text extends beyond the run", () => { + // The SDK message is a superset ("Hello from the SDK, and more") of the + // run's text. Rebuilding from it would splice content the ADE transcript + // never showed into history — the repair must keep the exact ADE text. + const input = splicedRun(); + const result = repairSplicedEnvelopeLines(input, [assistantMessage("msg-super", "Hello from the SDK, and more")]); + expect(result.lines).toHaveLength(1); + expect(JSON.parse(result.lines[0]!).event).toMatchObject({ + text: "Hello from the SDK", + messageId: "wire-1", + }); + }); + it("falls back to a local merge when SDK messages cover only a prefix of the run", () => { // The SDK transcript is missing the run's tail (" the SDK"). A rebuild from // the partial SDK match would silently drop that tail — the repair must diff --git a/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts index 51aff43e0..474804747 100644 --- a/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts +++ b/apps/desktop/src/main/services/chat/chatEnvelopeSpliceRepair.ts @@ -88,13 +88,14 @@ function findSdkMatch( let combined = ""; for (let end = start; end < messages.length; end += 1) { combined = normalizeText(`${combined}${messages[end]!.normalizedText}`); - if (combined === normalizedRun || combined.startsWith(normalizedRun)) { + // Only an EXACT match may rebuild. A superset match (SDK text merely + // starting with the run) could splice content the ADE transcript never + // showed into history; a partial match (SDK covering only a prefix of + // the run) would drop the run's uncovered tail. Both fall back to the + // caller's lossless local merge instead. + if (combined === normalizedRun) { return { messages: messages.slice(start, end + 1), nextIndex: end + 1 }; } - // Partial coverage (SDK text is only a prefix of the run) must NOT win: - // rebuilding from it would drop the run's uncovered tail. Keep scanning - // while the SDK side could still complete the run, otherwise move on and - // let the caller fall back to the lossless local merge. if (normalizedRun.startsWith(combined)) continue; break; } diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts index 7b960826f..1fb242940 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts @@ -179,6 +179,31 @@ describe("createClaudeSubprocessReaper", () => { expect(otherChild.killedWith).toEqual([]); }); + it("escalates to SIGKILL for a hung child even though killed=true after SIGTERM", () => { + // Node sets child.killed as soon as ANY signal is delivered — it does NOT + // mean the process exited. A hung child must still get the escalation. + vi.useFakeTimers(); + const logger = createLogger(); + const child = createProcess(9911); + child.kill = vi.fn((signal: NodeJS.Signals) => { + child.killedWith.push(signal); + child.killed = true; // Node-faithful: true after the first delivered signal. + return true; + }); + const reaper = createClaudeSubprocessReaper({ logger, killGraceMs: 25 }); + reaper.register(child, { + sessionId: "chat-hung", + laneId: "lane-1", + cwd: "/tmp/lane-1", + }, "claude", []); + + reaper.reapForSession("chat-hung", "ended_session"); + expect(child.killedWith).toEqual(["SIGTERM"]); + + vi.advanceTimersByTime(25); + expect(child.killedWith).toEqual(["SIGTERM", "SIGKILL"]); + }); + it("reaps subprocesses left behind by a crashed ADE owner", () => { vi.useFakeTimers(); const logger = createLogger(); diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts index 55630b234..3e164a710 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts @@ -237,7 +237,10 @@ export function createClaudeSubprocessReaper(args: { reason: string, ): void => { const child = entry.process; - if (child.killed || child.exitCode !== null || entry.killTimer) return; + // `child.killed` only means "a signal was sent", not "the process exited" — + // gate on exit/signal codes so a hung child still gets the SIGKILL escalation. + const exited = () => child.exitCode !== null || (child as { signalCode?: string | null }).signalCode != null; + if (exited() || entry.killTimer) return; logger.warn("agent_chat.claude_subprocess_terminate", { pid, sessionId: entry.record.sessionId, @@ -249,7 +252,7 @@ export function createClaudeSubprocessReaper(args: { // Best effort; the process may already be gone. } entry.killTimer = setTimer(() => { - if (!child.killed && child.exitCode === null) { + if (!exited()) { logger.warn("agent_chat.claude_subprocess_kill", { pid, sessionId: entry.record.sessionId, diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx index b121ec597..826d2eb7c 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.test.tsx @@ -69,8 +69,27 @@ describe("SessionContextMenu Claude tags", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("submits a non-empty tag for the session", () => { + const session = makeSession(); + const { onClose, onSetChatTag } = renderMenu(session); + + fireEvent.click(screen.getByRole("button", { name: "Set tag…" })); + const input = screen.getByRole("textbox", { name: "Set Claude session tag" }) as HTMLInputElement; + fireEvent.change(input, { target: { value: "review-ready" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(onSetChatTag).toHaveBeenCalledWith(session, "review-ready"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("does not offer SDK tags for non-Claude chat sessions", () => { renderMenu(makeSession({ toolType: "codex-chat" })); expect(screen.queryByRole("button", { name: "Set tag…" })).toBeNull(); }); + + it("does not offer SDK tags for ended Claude sessions", () => { + // Tag writes need a live Claude SDK runtime; the menu gates on running. + renderMenu(makeSession({ status: "disposed", endedAt: "2026-07-10T13:00:00.000Z" })); + expect(screen.queryByRole("button", { name: "Set tag…" })).toBeNull(); + }); });