Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
mergePeerSessions,
deriveSessionBadge,
isRemotePeerSession,
resolveDropdownOpenAction,
type DropdownSession,
} from "./session-fleet-peers"

Expand Down Expand Up @@ -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 <machine>" 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)
}
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { amicode_owner?: SessionOwnerTag }>(
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 {
Expand Down Expand Up @@ -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
* <machine>" 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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
mergePeerSessions,
deriveSessionBadge,
isRemotePeerSession,
resolveDropdownOpenAction,
type DropdownSession,
} from "./session-fleet-peers"

Expand Down Expand Up @@ -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 <machine>, 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)
Expand All @@ -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)
}
}

Expand Down
Loading