diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index decd97c5..fad3105a 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -639,11 +639,11 @@ "packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e", "packages/app/src/components/session/preview-file-view.tsx": "8292f2430d41c3b6a587fcbc6547ab6828445542a8ddc6c4554d115c1340f3f3", "packages/app/src/components/session/preview-human-write-gate.ts": "4c4b1cf258a06b0012d722add0125ecb77a0f4e463dd95d193f21e5e73541098", - "packages/app/src/components/session/session-chats-dropdown.test.ts": "cc0f96f935cc5681a1fe62875f4538c303b7c0b75744e066180f8805d5160292", + "packages/app/src/components/session/session-chats-dropdown.test.ts": "b5f178bea1b52788d6f794d09ccf7ff81f745633ad59e2d438cfc80b96acde55", "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", - "packages/app/src/components/session/session-fleet-peers.test.ts": "f32233cfc7854755431ab8ad545859b3d49bee38cb70dd960d32384e09ea7d21", - "packages/app/src/components/session/session-fleet-peers.ts": "e8cd9598e7416e4f1d882f40c846e9d5e26914c41e84189b14bb477ee7995e66", - "packages/app/src/components/session/session-header.tsx": "22dbba2406023bca0e72553aa468b06507e5ccad2f521e6ce1e9d1c1c297d238", + "packages/app/src/components/session/session-fleet-peers.test.ts": "67e03d6f0be79f5d08cb293af2c8d7e1100e5c49d7fd86ce5c7ced1eea93f1d7", + "packages/app/src/components/session/session-fleet-peers.ts": "1c9e846668adcff0852a66a6961e393fb9e0cc91dc4103017d70cbad269f791c", + "packages/app/src/components/session/session-header.tsx": "c8207d714f01d4fe9f8731b410ff0359e4f9b9f7815967cc654060944af8933e", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", "packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe", "packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653", @@ -893,7 +893,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T12:56:52.355Z", + "extracted_at": "2026-09-24T13:51:39.015Z", "per_package": { "packages/app": { "A": 201, diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts index 4b8dab27..00d84c30 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts @@ -1,4 +1,9 @@ import { describe, expect, test } from "bun:test" +// #1537 B2a (AC6): the machine helpers are no longer defined here — they live in +// the ONE real module the component also imports (`session-fleet-peers.ts`). +// `deriveBadge` was a stale duplicate of the real `deriveSessionBadge`; the +// per-machine narrowing `filterSessionsByMachine` is promoted alongside it. +import { deriveSessionBadge, filterSessionsByMachine } from "./session-fleet-peers" /** * Tests for the Session Chats Dropdown logic (amicode#274). @@ -7,6 +12,9 @@ import { describe, expect, test } from "bun:test" * flyout (amicode#273). */ +// Local fixture shape for this suite (a superset-tolerant subset of the SDK +// session — `time.updated` optional, as the dropdown fixtures use it). The +// promoted helpers are structural, so they accept this shape directly. type SessionOwnerTag = { owner_machine_id: string owner_name: string @@ -55,27 +63,6 @@ function filterSessionsByQuery( return sessions.filter((session) => getTitle(session).toLowerCase().includes(q)) } -/** Derive the machine badge label for a session (#1439). - * Local sessions are unbadged (absence = local, ADR 0031 §D6); - * remote sessions show the owner's name as the badge. */ -function deriveBadge(session: Session): string | undefined { - if (!session.amicode_owner) return undefined - if (session.amicode_owner.is_local) return undefined - return session.amicode_owner.owner_name -} - -/** Filter sessions to a specific machine (#1439). - * null/undefined = all machines (the "clear" state). */ -function filterSessionsByMachine( - sessions: Session[], - machineId: string | null | undefined, -): Session[] { - if (machineId == null) return sessions - return sessions.filter( - (s) => s.amicode_owner?.owner_machine_id === machineId, - ) -} - /** * Simulates the flyout open/close state machine — the same pattern used * in the component. Verifies the toggle works and the dismiss handler @@ -266,7 +253,7 @@ describe("Session Chats Dropdown", () => { // ── fleet-wide machine badges (#1439, AC2) ───────────────────────────────── - describe("deriveBadge — machine badge for fleet sessions", () => { + describe("deriveSessionBadge — machine badge for fleet sessions", () => { const localSession: Session = { id: "ses_local", title: "Local work", @@ -302,15 +289,15 @@ describe("Session Chats Dropdown", () => { } test("local session is unbadged (absence = local)", () => { - expect(deriveBadge(localSession)).toBeUndefined() + expect(deriveSessionBadge(localSession)).toBeUndefined() }) test("remote session shows the owner machine name as badge", () => { - expect(deriveBadge(remoteSession)).toBe("Mac Studio") + expect(deriveSessionBadge(remoteSession)).toBe("Mac Studio") }) test("pre-fleet session (no owner tag) is unbadged", () => { - expect(deriveBadge(preFleetsession)).toBeUndefined() + expect(deriveSessionBadge(preFleetsession)).toBeUndefined() }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts index bd98c820..7563f8bd 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts @@ -4,6 +4,7 @@ import { mergePeerSessions, deriveSessionBadge, isRemotePeerSession, + resolveDropdownOpenAction, type DropdownSession, } from "./session-fleet-peers" @@ -93,3 +94,54 @@ describe("#1525 mergePeerSessions — dedupe (local wins) + sort by last activit expect(mergePeerSessions(local, []).map((s) => s.id)).toEqual(["b", "a"]) }) }) + +// #1537 B2a (AC4): owner-routed OPEN. `resolveDropdownOpenAction` is the pure +// decision the dropdown's openSession() dispatches on. A REMOTE peer row must +// resolve to the owner-routed navigate (the multiplex routes reads by owner) — +// NOT the B1 "lives on " guard toast. A LOCAL row is byte-unchanged +// from B1: existing tab → select-tab; no tab → navigate. No branch produces a +// mutation/remote-write action — open is a read. +describe("#1537 resolveDropdownOpenAction — owner-routed open of a peer row", () => { + const encodePath = (dir: string, id: string) => `/${btoa(dir)}/session/${id}` + const studioTag = { owner_machine_id: "jjs-mac-studio", owner_name: "JJ's Mac Studio", is_local: false } + + const localRow: DropdownSession = { id: "ses_local", directory: "/proj", time: { created: 1 } } as DropdownSession + const remoteRow: DropdownSession = { + id: "ses_studio", + directory: "/studio-proj", + time: { created: 2 }, + amicode_owner: studioTag, + } as DropdownSession + + test("REMOTE row → owner-routed navigate to its raw directory/id (never a toast)", () => { + const action = resolveDropdownOpenAction(remoteRow, false, encodePath) + expect(action.type).toBe("navigate") + expect((action as { type: "navigate"; path: string }).path).toBe(`/${btoa("/studio-proj")}/session/ses_studio`) + }) + + test("REMOTE row with an already-open tab → select that tab (no re-navigate)", () => { + const action = resolveDropdownOpenAction(remoteRow, true, encodePath) + expect(action.type).toBe("select-tab") + expect((action as { type: "select-tab"; sessionId: string }).sessionId).toBe("ses_studio") + }) + + test("LOCAL row without a tab → navigate (byte-unchanged from B1)", () => { + const action = resolveDropdownOpenAction(localRow, false, encodePath) + expect(action.type).toBe("navigate") + expect((action as { type: "navigate"; path: string }).path).toBe(`/${btoa("/proj")}/session/ses_local`) + }) + + test("LOCAL row with an existing tab → select-tab (byte-unchanged from B1)", () => { + const action = resolveDropdownOpenAction(localRow, true, encodePath) + expect(action.type).toBe("select-tab") + expect((action as { type: "select-tab"; sessionId: string }).sessionId).toBe("ses_local") + }) + + test("no branch yields a remote-write action — the union is only navigate | select-tab", () => { + for (const row of [localRow, remoteRow]) { + for (const hasTab of [true, false]) { + expect(["navigate", "select-tab"]).toContain(resolveDropdownOpenAction(row, hasTab, encodePath).type) + } + } + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts index 6bed5b15..61fa83b3 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts @@ -50,6 +50,17 @@ export function deriveSessionBadge(session: { amicode_owner?: SessionOwnerTag } return session!.amicode_owner!.owner_name } +/** Filter dropdown rows to a specific owner machine (#1439, promoted here in + * #1537 B2a AC6 as the single real home for the dropdown's machine helpers). + * `null`/`undefined` machineId = all machines (the "clear filter" state). */ +export function filterSessionsByMachine( + sessions: T[], + machineId: string | null | undefined, +): T[] { + if (machineId == null) return sessions + return sessions.filter((s) => s.amicode_owner?.owner_machine_id === machineId) +} + /** Tolerant reader for one raw owner overlay — returns the tag only when its * load-bearing fields are well-typed, else undefined. */ function readOwnerTag(raw: unknown): SessionOwnerTag | undefined { @@ -122,3 +133,34 @@ export function mergePeerSessions(local: DropdownSession[], peers: DropdownSessi } return merged.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) } + +/** The action the dropdown's openSession() dispatches on when a row is clicked. + * A READ-ONLY union — there is no delete/archive/prompt variant, so a reviewer + * can confirm this open path carries no remote-write surface (#1537 B2a). */ +export type DropdownOpenAction = + | { type: "select-tab"; sessionId: string } + | { type: "navigate"; path: string } + +/** Resolve how to open a dropdown row — the seam #1537 B2a wires for BOTH local + * and REMOTE peer rows. + * + * Before B2a, a remote (peer-owned) row short-circuited to a "lives on + * " guard toast because opening a remote session was never wired. + * B2a removes that dead end: a remote row resolves to the SAME owner-routed + * navigate as a local one — the session store lives on the owner, and the + * multiplex routes reads by owner at the API boundary, so navigating to the + * row's raw `directory`/`id` IS the owner-routed open. This is a read: no + * branch mutates a remote session. + * + * - an already-open tab → select it (no re-navigate, no transition); + * - otherwise → navigate to the encoded `directory`/`id` path. + * + * Local-row behavior is byte-unchanged from B1 (same two outcomes). */ +export function resolveDropdownOpenAction( + session: DropdownSession, + hasExistingTab: boolean, + encodePath: (directory: string, id: string) => string, +): DropdownOpenAction { + if (hasExistingTab) return { type: "select-tab", sessionId: session.id } + return { type: "navigate", path: encodePath(session.directory, session.id) } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx index 491908e6..3591b9db 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx @@ -47,6 +47,7 @@ import { mergePeerSessions, deriveSessionBadge, isRemotePeerSession, + resolveDropdownOpenAction, type DropdownSession, } from "./session-fleet-peers" @@ -919,25 +920,23 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } async function openSession(session: Session) { - // #1525 B1: a REMOTE peer session cannot be opened by a local navigate — - // its store lives on the owner machine, so a local open would 404. Owner- - // routed open is B2 (#1525); until then, surface where it lives rather than - // break. (The row also hides its local archive action for the same reason.) - if (isRemotePeerSession(session as DropdownSession)) { - const owner = (session as DropdownSession).amicode_owner! - showToast({ - title: `Session on ${owner.owner_name}`, - description: `This session lives on ${owner.owner_name}. Opening remote sessions from here is coming soon.`, - }) - setOpen(false) - return - } + // #1537 B2a: OPEN a peer (remote) session by routing through the owner. + // Open is a READ — the session store lives on the owner machine and the + // multiplex routes reads by owner at the API boundary, so navigating to the + // row's raw directory/id IS the owner-routed open. B1's guard toast ("lives + // on , coming soon") was the dead end this replaces; a remote row + // now lands in the normal session view, byte-for-byte the same navigate a + // local row takes. No remote-write surface is added here (prompt / archive / + // delete of a peer row remain absent — that is B2b, design-gated). The row + // still hides its LOCAL-only archive action via `isRemotePeerSession`. // Close flyout first so its Portal unmounts cleanly. setOpen(false) // Mirror the dashboard's project setup: ensure the directory is registered and - // touched so the workspace context is warm when the session page mounts. + // touched so the workspace context is warm when the session page mounts. For a + // remote row this registers the owner's directory string locally — harmless + // bookkeeping; the actual session reads route to the owner via the multiplex. const conn = server.current if (conn) { const ctx = globalCtx.ensureServerCtx(conn) @@ -962,11 +961,15 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) const existingTab = tabs.store.find( (t) => t.type === "session" && t.sessionId === session.id, ) - if (existingTab) { - tabs.select(existingTab) + const action = resolveDropdownOpenAction( + session as DropdownSession, + !!existingTab, + (dir, id) => `/${base64Encode(dir)}/session/${id}`, + ) + if (action.type === "select-tab") { + tabs.select(existingTab!) } else { - const path = `/${base64Encode(session.directory)}/session/${session.id}` - navigate(path) + navigate(action.path) } }