diff --git a/packages/app/e2e/browser/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts index 990c922c30..4875cc31c1 100644 --- a/packages/app/e2e/browser/agent-message-rewind.spec.ts +++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts @@ -23,7 +23,7 @@ async function completeSubmittedTurn( const finish = await agent.client.waitForFinish(agent.agentId, 30_000); expect(finish.status).toBe("idle"); - await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); await expect(userMessage).toHaveAttribute("aria-busy", "false"); return userMessage; } @@ -40,7 +40,7 @@ async function expectTurnCompletesNormally( ): Promise { const finish = await agent.client.waitForFinish(agent.agentId, 30_000); expect(finish.status).toBe("idle"); - await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); await expectAgentIdle(page); } diff --git a/packages/app/e2e/browser/archive-tab.spec.ts b/packages/app/e2e/browser/archive-tab.spec.ts index e250489a9b..6ee80ddf1e 100644 --- a/packages/app/e2e/browser/archive-tab.spec.ts +++ b/packages/app/e2e/browser/archive-tab.spec.ts @@ -10,6 +10,7 @@ import { archiveAgentFromSessions, clickSessionRow, createIdleAgent, + expectArchivedAgentFocused, expectSessionRowArchived, expectSessionRowVisible, expectWorkspaceArchiveOutcome, @@ -21,6 +22,7 @@ import { resetSeededPageState, reloadWorkspace, } from "../support/helpers/archive-tab"; +import { expectAgentTabActive } from "../support/helpers/launcher"; test.describe("Archive tab reconciliation", () => { let client: Awaited>; @@ -144,6 +146,8 @@ test.describe("Archive tab reconciliation", () => { await clickSessionRow(page, archived.title); + await expectArchivedAgentFocused(page, archived.id); + await expectAgentTabActive(page, archived.id); expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt); await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), { diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 252788bc2b..752ccf36f3 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -508,6 +508,21 @@ describe("ReplicaCache", () => { ]); }); + it("retries a timeline read invalidated by a concurrent directory commit", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Persisted timeline")); + await cache.flush(); + storage.onRead = () => { + storage.onRead = null; + cache.commitDirectory(SERVER_ID, directory()); + }; + + expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ + timelineItem("Persisted timeline"), + ]); + }); + it("rebuilds every directory row before restoring its checkpoint after eviction", async () => { const storage = new MemoryStorage(); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 3a678186d8..40cdc4c691 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -898,13 +898,15 @@ export class ReplicaCache { ids?: readonly string[], ): Promise { if (!this.activeServerIds.has(serverId)) return []; - const revision = this.hostRevisions.get(serverId) ?? 0; try { await this.prepareStore(); - await this.flush(); - if (!this.canReadHostRevision(serverId, revision)) return []; - const rows = await this.rowStore.read(serverId, kinds, ids); - return this.canReadHostRevision(serverId, revision) ? rows : []; + while (this.activeServerIds.has(serverId)) { + await this.flush(); + const revision = this.hostRevisions.get(serverId) ?? 0; + const rows = await this.rowStore.read(serverId, kinds, ids); + if (this.canReadHostRevision(serverId, revision)) return rows; + } + return []; } catch { return []; } diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 7c28c5b748..be001c5f2d 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -37,9 +37,8 @@ function navigateDeps(): NavigateToWorkspaceDeps { getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionAgents: (serverId) => useSessionStore.getState().sessions[serverId]?.agents.values() ?? [], + isWorkspaceLayoutHydrated: () => useWorkspaceLayoutStore.persist.hasHydrated(), openTab: (input) => useWorkspaceLayoutStore.getState().openTab(input), - pinAgent: (workspaceKey, agentId) => - useWorkspaceLayoutStore.getState().pinAgent(workspaceKey, agentId), rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection), navigateToRoute: (route) => { navigateToHostWorkspaceRoute(route); diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts index 8db0e52246..3902ba116c 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.test.ts @@ -14,6 +14,7 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; interface RecordedTab { workspaceKey: string; target: WorkspaceTabTarget; + pin: boolean; } function createFakeDeps(overrides: Partial = {}) { @@ -23,11 +24,11 @@ function createFakeDeps(overrides: Partial = {}) { const deps: NavigateToWorkspaceDeps = { getSessionWorkspaces: () => null, getSessionAgents: () => [] as Agent[], - openTab: ({ workspaceKey, target }) => { - openedTabs.push({ workspaceKey, target }); + isWorkspaceLayoutHydrated: () => true, + openTab: ({ workspaceKey, target, pin = false }) => { + openedTabs.push({ workspaceKey, target, pin }); return target.kind === "agent" ? target.agentId : null; }, - pinAgent: () => undefined, rememberLastWorkspace: (selection) => remembered.push(selection), navigateToRoute: (route) => navigations.push(route), ...overrides, @@ -97,6 +98,7 @@ describe("workspace navigation", () => { { workspaceKey: "server-1:workspace-a", target: { kind: "agent", agentId: "agent-1" }, + pin: false, }, ]); }); @@ -131,6 +133,7 @@ describe("workspace navigation", () => { { workspaceKey: "server-1:workspace-a", target: { kind: "draft", draftId: "draft-1" }, + pin: false, }, ]); }); @@ -153,6 +156,30 @@ describe("workspace navigation", () => { expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]); }); + it("defers an agent tab until persisted workspace layout has hydrated", () => { + const workspace = { + id: "workspace-a", + workspaceDirectory: "/repo/workspace-a", + } as WorkspaceDescriptor; + const { deps, navigations, openedTabs } = createFakeDeps({ + getSessionWorkspaces: () => new Map([[workspace.id, workspace]]), + isWorkspaceLayoutHydrated: () => false, + }); + + navigateToWorkspace( + { + serverId: "server-1", + workspaceId: "workspace-a", + target: { kind: "agent", agentId: "agent-1" }, + pin: true, + }, + deps, + ); + + expect(openedTabs).toEqual([]); + expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]); + }); + it("reads the active workspace from the current route", () => { const selection = parseActiveWorkspaceSelection({ pathname: "/h/server-1/workspace/workspace-a", diff --git a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts index aea1cd69e5..f8bb8ccf9f 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/navigation.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/navigation.ts @@ -34,6 +34,7 @@ export interface NavigateToWorkspaceInput { export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps { getSessionWorkspaces: (serverId: string) => Map | null | undefined; getSessionAgents: (serverId: string) => Iterable; + isWorkspaceLayoutHydrated: () => boolean; rememberLastWorkspace: (selection: ActiveWorkspaceSelection) => void; navigateToRoute: (route: string) => void; } @@ -90,8 +91,11 @@ export function navigateToWorkspace( workspaces, workspaceId: input.workspaceId, }); + const shouldDeferAgentOpen = Boolean( + input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()), + ); if (input.target) { - if (resolvedWorkspaceId || input.target.kind !== "agent") { + if (!shouldDeferAgentOpen) { prepareWorkspaceTab({ ...input, target: input.target }, deps); } } else { @@ -111,7 +115,7 @@ export function navigateToWorkspace( } const route = - input.target?.kind === "agent" && !resolvedWorkspaceId + input.target?.kind === "agent" && shouldDeferAgentOpen ? buildHostWorkspaceOpenRoute( input.serverId, input.workspaceId, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 12421cf7f5..e53f9c1911 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -3092,7 +3092,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); - it("keeps pinned archived agents in memory per workspace without persisting them", () => { + it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, @@ -3102,9 +3102,18 @@ describe("workspace-layout-store actions", () => { expect(otherWorkspaceKey).toBeTruthy(); const store = workspaceLayoutStore.getState(); - store.pinAgent(workspaceKey, "agent-1"); - store.pinAgent(workspaceKey, "agent-1"); - store.pinAgent(otherWorkspaceKey as string, "agent-2"); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); + store.openTab({ + workspaceKey: otherWorkspaceKey as string, + target: { kind: "agent", agentId: "agent-2" }, + intent: "reveal", + pin: true, + }); let state = workspaceLayoutStore.getState(); expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); @@ -3122,13 +3131,7 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); - expect(partialize?.(state)).toEqual({ - layoutByWorkspace: {}, - splitSizesByWorkspace: {}, - explorerSidebarWidthByWorkspace: {}, - explorerPaneIdByWorkspace: {}, - sidePaneIdByWorkspace: {}, - }); + expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace"); }); it("keeps hidden agent intents in memory per workspace without persisting them", () => { @@ -3653,14 +3656,19 @@ describe("workspace-layout-store actions", () => { ]); }); - it("pinning an agent clears hidden intent", () => { + it("opening a pinned agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); store.hideAgent(workspaceKey, "agent-1"); expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined(); - store.pinAgent(workspaceKey, "agent-1"); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); const state = workspaceLayoutStore.getState(); expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); @@ -3675,8 +3683,8 @@ describe("workspace-layout-store actions", () => { workspaceKey: workspaceKey, target: { kind: "agent", agentId: "archived-agent" }, intent: "reveal", + pin: true, }); - store.pinAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, @@ -3730,8 +3738,8 @@ describe("workspace-layout-store actions", () => { workspaceKey: workspaceKey, target: { kind: "agent", agentId: "archived-agent" }, intent: "reveal", + pin: true, }); - store.pinAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, @@ -3749,6 +3757,48 @@ describe("workspace-layout-store actions", () => { ).toEqual(["agent_archived-agent"]); }); + it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "first-agent" }, + intent: "reveal", + }); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "second-agent" }, + intent: "reveal", + }); + store.hideAgent(workspaceKey, "archived-agent"); + + const archivedTabId = store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + pin: true, + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["first-agent", "second-agent"], + autoOpenAgentIds: ["first-agent", "second-agent"], + knownAgentIds: ["first-agent", "second-agent"], + standaloneTerminalIds: [], + }); + + const state = workspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toContain( + archivedTabId, + ); + expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(archivedTabId); + expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toContain( + "archived-agent", + ); + expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); + }); + it("retargeting a tab to an agent clears hidden intent", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 6f72ea0850..974b8c6964 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -95,6 +95,8 @@ export interface OpenWorkspaceTabInput { workspaceKey: string; target: WorkspaceTabTarget; intent: WorkspaceTabOpenIntent; + /** Keeps an explicitly opened agent visible even when it is archived. */ + pin?: boolean; placement?: WorkspaceTabPlacement; parentTabId?: string; state?: JsonValue; @@ -159,7 +161,6 @@ interface WorkspaceLayoutStore { resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void; resizeExplorerSidebar: (workspaceKey: string, width: number) => void; reorderTabsInPane: (workspaceKey: string, paneId: string, tabIds: string[]) => void; - pinAgent: (workspaceKey: string, agentId: string) => void; unpinAgent: (workspaceKey: string, agentId: string) => void; hideAgent: (workspaceKey: string, agentId: string) => void; unhideAgent: (workspaceKey: string, agentId: string) => void; @@ -811,6 +812,7 @@ export function createWorkspaceLayoutStore( placement.explorerSidebarPaneId, placement.layout.focusedPaneId, ); + const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent"; set((state) => ({ ...withoutFocusRestoration(state, normalizedWorkspaceKey), hiddenAgentIdsByWorkspace: @@ -821,6 +823,20 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey, normalizedTarget.agentId, ), + pinnedAgentIdsByWorkspace: shouldPinAgent + ? addAgentIdToWorkspaceSet( + state.pinnedAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ) + : state.pinnedAgentIdsByWorkspace, + pendingAgentIdsByWorkspace: shouldPinAgent + ? addAgentIdToWorkspaceSet( + state.pendingAgentIdsByWorkspace, + normalizedWorkspaceKey, + normalizedTarget.agentId, + ) + : state.pendingAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: input.parentTabId @@ -1639,46 +1655,6 @@ export function createWorkspaceLayoutStore( }; }); }, - pinAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } - - set((state) => { - const currentPinnedAgentIds = - state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - const currentPendingAgentIds = - state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null; - if ( - currentPinnedAgentIds?.has(normalizedAgentId) && - currentPendingAgentIds?.has(normalizedAgentId) - ) { - return state; - } - - const nextPinnedAgentIds = new Set(currentPinnedAgentIds ?? []); - nextPinnedAgentIds.add(normalizedAgentId); - - return { - hiddenAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.hiddenAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), - pinnedAgentIdsByWorkspace: { - ...state.pinnedAgentIdsByWorkspace, - [normalizedWorkspaceKey]: nextPinnedAgentIds, - }, - pendingAgentIdsByWorkspace: addAgentIdToWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), - }; - }); - }, unpinAgent: (workspaceKey, agentId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedAgentId = trimNonEmpty(agentId); diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index 63f52f5668..72cd01772e 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -138,6 +138,103 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); + it("paints cached rows without replacing a live head that arrives during preparation", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + readTimeline: () => read, + commitTimeline: () => undefined, + }, + prepareAgent: async () => undefined, + }); + + const preparation = replica.prepare(AGENT_ID); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("live", "live", 5)], + }); + release(cachedTimeline()); + await preparation; + + expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); + expect( + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ).toEqual({ status: "painted", items: cachedTimeline().items }); + expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamHead.get(AGENT_ID)).toEqual([ + item("live", "live", 5), + ]); + }); + + it("reconciles a live head that overlaps the cached canonical timeline", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + readTimeline: () => read, + commitTimeline: () => undefined, + }, + prepareAgent: async () => undefined, + }); + + const preparation = replica.prepare(AGENT_ID); + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("cached", "cached", 4), item("live", "live", 5)], + }); + release(cachedTimeline()); + await preparation; + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect([ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); + }); + + it("reconciles cached rows with a non-authoritative timeline painted during preparation", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + readTimeline: () => read, + commitTimeline: () => undefined, + }, + prepareAgent: async () => undefined, + }); + + const preparation = replica.prepare(AGENT_ID); + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, AGENT_ID, { + items: [item("live", "live", 5)], + head: [], + range: null, + older: "none", + newer: false, + synchronized: false, + acknowledgedClientMessageIds: [], + }); + release(cachedTimeline()); + await preparation; + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect([ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual([item("cached", "cached", 4), item("live", "live", 5)]); + expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); + }); + it("persists accepted live stream commits through the owner", () => { useSessionStore.getState().initializeSession(SERVER_ID, null); applySynced(AGENT_ID, 8); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 386572f12b..e1167c524a 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -27,6 +27,7 @@ import { } from "./session-stream-reducers"; import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; import { createInstalledTimelineTransform, type TimelineItemTransform } from "@/plugins/timeline"; +import { replaceWithCanonicalStream } from "@/types/stream"; const PLUGIN_TIMELINE_REPROJECTION_DELAY_MS = 50; @@ -50,21 +51,41 @@ async function prepareCachedTimeline(input: { if (!stored) return undefined; const session = useSessionStore.getState().sessions[input.serverId]; const currentTimeline = selectAgentTimelineState(session, input.agentId); - if (session?.agentStreamHead.get(input.agentId) !== beforeHead) return undefined; - if (beforeTimeline.status === "painted") { - return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items - ? stored - : undefined; + const currentHead = session?.agentStreamHead.get(input.agentId); + if (currentTimeline.status === "synced") return undefined; + if (!stored.range) { + if (currentHead !== beforeHead) return undefined; + if (beforeTimeline.status === "painted") { + return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items + ? stored + : undefined; + } + if (currentTimeline.status !== "cold") return undefined; } - if (currentTimeline.status !== "cold") return undefined; + const liveItems = + currentTimeline.status === "painted" + ? [...currentTimeline.items, ...(currentHead ?? [])] + : (currentHead ?? []); + const replacement = stored.range + ? replaceWithCanonicalStream({ + canonical: stored.items, + previousTail: [], + previousHead: liveItems, + sendingClientMessageIds: getSendingClientMessageIds( + session?.messageSubmissions.get(input.agentId), + ), + preserveContinuity: true, + canonicalCoverage: stored.range, + }) + : { tail: stored.items, head: liveItems, acknowledgedClientMessageIds: [] }; useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, { - items: stored.items, - head: [], + items: replacement.tail, + head: replacement.head, range: stored.range, older: stored.hasOlder ? "available" : "none", newer: false, synchronized: false, - acknowledgedClientMessageIds: [], + acknowledgedClientMessageIds: replacement.acknowledgedClientMessageIds, }); return stored; } diff --git a/packages/app/src/utils/prepare-workspace-tab.ts b/packages/app/src/utils/prepare-workspace-tab.ts index 57a7448f33..5d05bff60c 100644 --- a/packages/app/src/utils/prepare-workspace-tab.ts +++ b/packages/app/src/utils/prepare-workspace-tab.ts @@ -15,9 +15,9 @@ export interface PrepareWorkspaceTabDeps { workspaceKey: string; target: WorkspaceTabTarget; intent: "reveal"; + pin?: boolean; placement?: WorkspaceTabPlacement; }) => string | null; - pinAgent: (workspaceKey: string, agentId: string) => void; } function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { @@ -38,9 +38,11 @@ export function prepareWorkspaceTab( workspaceId: input.workspaceId, }) ?? ""; - deps.openTab({ workspaceKey: key, target, intent: "reveal", placement: input.placement }); - - if (input.pin && target.kind === "agent") { - deps.pinAgent(key, target.agentId); - } + deps.openTab({ + workspaceKey: key, + target, + intent: "reveal", + pin: input.pin === true && target.kind === "agent", + placement: input.placement, + }); } diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index cbad3e56f5..efb1f496b2 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -9,32 +9,25 @@ const AGENT_ID = "agent-1"; interface RecordedOpenedTab { key: string; target: WorkspaceTabTarget; -} - -interface RecordedPin { - key: string; - agentId: string; + pin: boolean; } function createFakeLayout() { const openedTabs: RecordedOpenedTab[] = []; - const pinnedAgents: RecordedPin[] = []; return { openedTabs, - pinnedAgents, openTab: ({ workspaceKey: key, target, + pin = false, }: { workspaceKey: string; target: WorkspaceTabTarget; + pin?: boolean; }) => { - openedTabs.push({ key, target }); + openedTabs.push({ key, target, pin }); return target.kind === "agent" ? target.agentId : null; }, - pinAgent: (key: string, agentId: string) => { - pinnedAgents.push({ key, agentId }); - }, }; } @@ -52,8 +45,33 @@ describe("prepareWorkspaceTab", () => { ); expect(layout.openedTabs).toEqual([ - { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } }, + { + key: "server-1:/repo/worktree", + target: { kind: "agent", agentId: AGENT_ID }, + pin: false, + }, + ]); + }); + + it("requests pinned visibility in the same command that reveals an agent", () => { + const layout = createFakeLayout(); + + prepareWorkspaceTab( + { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: AGENT_ID }, + pin: true, + }, + layout, + ); + + expect(layout.openedTabs).toEqual([ + { + key: "server-1:/repo/worktree", + target: { kind: "agent", agentId: AGENT_ID }, + pin: true, + }, ]); - expect(layout.pinnedAgents).toEqual([]); }); }); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index 1b1cc91657..727ce24bb3 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -14,9 +14,9 @@ function layoutStoreDeps() { workspaceKey: string; target: WorkspaceTabTarget; intent: "reveal"; + pin?: boolean; placement?: import("@/stores/workspace-layout-actions").WorkspaceTabPlacement; }) => store.openTab(input), - pinAgent: store.pinAgent, }; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 154dcdb39d..687ae8a5d6 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -460,7 +460,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { { sessionID: "session-1", directory: cwd, - time: { archived: null }, + time: { archived: 0 }, }, ]); expect(runtime.acquisitions.every((acquisition) => acquisition.releaseCount === 1)).toBe(true); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index a3fd30f7f0..574ca43f62 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1636,12 +1636,13 @@ export class OpenCodeAgentClient implements AgentClient { } async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { - await this.setNativeSessionArchived(handle, null); + // OpenCode's numeric archive field uses zero as the active-session sentinel. + await this.setNativeSessionArchived(handle, 0); } private async setNativeSessionArchived( handle: AgentPersistenceHandle, - archivedAt: number | null, + archivedAt: number, ): Promise { const metadata = (handle.metadata ?? {}) as Partial; if (!metadata.cwd) { @@ -1657,15 +1658,8 @@ export class OpenCodeAgentClient implements AgentClient { directory: metadata.cwd, }); try { - // OpenCode accepts null to clear the archive timestamp, but this SDK - // release's generated request type still exposes only number. - const updateSession = client.session.update.bind(client.session) as (parameters: { - sessionID: string; - directory?: string; - time?: { archived?: number | null }; - }) => ReturnType; const response = readOpenCodeRecord( - await updateSession({ + await client.session.update({ sessionID: handle.sessionId, directory: metadata.cwd, time: { archived: archivedAt }, @@ -1673,7 +1667,7 @@ export class OpenCodeAgentClient implements AgentClient { ); if (response?.error) { throw new Error( - `Failed to ${archivedAt === null ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`, + `Failed to ${archivedAt === 0 ? "unarchive" : "archive"} OpenCode session: ${toDiagnosticErrorMessage(response.error)}`, ); } } finally {