diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 897eca4f..decd97c5 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -641,7 +641,9 @@ "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-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", - "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6", + "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-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", @@ -891,10 +893,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T03:35:03.529Z", + "extracted_at": "2026-09-24T12:56:52.355Z", "per_package": { "packages/app": { - "A": 199, + "A": 201, "M": 156, "D": 0 }, @@ -940,7 +942,7 @@ } }, "counts": { - "overlay_total": 869, + "overlay_total": 871, "deletions": 7, "server_coupled": 0 }, @@ -1578,6 +1580,8 @@ "packages/app/src/components/session/preview-human-write-gate.ts": "A", "packages/app/src/components/session/session-chats-dropdown.test.ts": "A", "packages/app/src/components/session/session-context-tab.tsx": "M", + "packages/app/src/components/session/session-fleet-peers.test.ts": "A", + "packages/app/src/components/session/session-fleet-peers.ts": "A", "packages/app/src/components/session/session-header.tsx": "M", "packages/app/src/components/session/session-new-view.tsx": "M", "packages/app/src/components/session/session-preview-tab.tsx": "A", 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 new file mode 100644 index 00000000..bd98c820 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { + peerSessionsFromProjection, + mergePeerSessions, + deriveSessionBadge, + isRemotePeerSession, + type DropdownSession, +} from "./session-fleet-peers" + +// #1525 B1 (read-only): merge PEER sessions from the fleet projection into the +// titlebar Sessions dropdown. These pure helpers are the data layer the +// component consumes; every reader is tolerant (never throws, [] on garbage). + +const localTag = { owner_machine_id: "jjs-macbook-pro", owner_name: "MacBook Pro", is_local: true } +const studioTag = { owner_machine_id: "jjs-mac-studio", owner_name: "JJ's Mac Studio", device_type: "desktop", is_local: false } + +function projection(sessions: unknown[]): unknown { + return { sessions, sources: {} } +} + +describe("#1525 peerSessionsFromProjection — keep only remote peer sessions", () => { + test("keeps a remote (is_local:false) session, coerced with title/time/owner", () => { + const raw = projection([ + { id: "ses_studio", title: "Free port 4096", directory: "/proj", time: { created: 3, updated: 7 }, amicode_owner: studioTag }, + ]) + const out = peerSessionsFromProjection(raw) + expect(out).toHaveLength(1) + expect(out[0].id).toBe("ses_studio") + expect(out[0].title).toBe("Free port 4096") + expect(out[0].time).toEqual({ created: 3, updated: 7 }) + expect(out[0].amicode_owner).toMatchObject({ owner_machine_id: "jjs-mac-studio", is_local: false }) + }) + + test("drops LOCAL-owned and UNOWNED entries (the local list already has those)", () => { + const raw = projection([ + { id: "ses_local", title: "local", time: { created: 1 }, amicode_owner: localTag }, + { id: "ses_plain", title: "plain", time: { created: 2 } }, + { id: "ses_studio", title: "remote", time: { created: 3 }, amicode_owner: studioTag }, + ]) + const out = peerSessionsFromProjection(raw) + expect(out.map((s) => s.id)).toEqual(["ses_studio"]) + }) + + test("tolerant: undefined / {} / non-array sessions / malformed rows → []", () => { + expect(peerSessionsFromProjection(undefined)).toEqual([]) + expect(peerSessionsFromProjection({})).toEqual([]) + expect(peerSessionsFromProjection({ sessions: "nope" })).toEqual([]) + expect(peerSessionsFromProjection(projection([{ no_id: true, amicode_owner: studioTag }]))).toEqual([]) + expect(peerSessionsFromProjection(projection([{ id: "x", amicode_owner: { owner_name: 1, is_local: false } }]))).toEqual([]) + }) + + test("a remote entry missing time still coerces (created defaults to 0)", () => { + const out = peerSessionsFromProjection(projection([{ id: "ses_studio", amicode_owner: studioTag }])) + expect(out).toHaveLength(1) + expect(out[0].time.created).toBe(0) + }) +}) + +describe("#1525 deriveSessionBadge / isRemotePeerSession", () => { + test("remote session badges with the owner name", () => { + expect(deriveSessionBadge({ amicode_owner: studioTag })).toBe("JJ's Mac Studio") + expect(isRemotePeerSession({ amicode_owner: studioTag })).toBe(true) + }) + test("local and unowned sessions are unbadged / not remote", () => { + expect(deriveSessionBadge({ amicode_owner: localTag })).toBeUndefined() + expect(deriveSessionBadge({})).toBeUndefined() + expect(deriveSessionBadge(undefined)).toBeUndefined() + expect(isRemotePeerSession({ amicode_owner: localTag })).toBe(false) + expect(isRemotePeerSession(undefined)).toBe(false) + }) +}) + +describe("#1525 mergePeerSessions — dedupe (local wins) + sort by last activity", () => { + const mk = (id: string, updated: number, owner?: DropdownSession["amicode_owner"]): DropdownSession => + ({ id, directory: "/d", time: { created: 0, updated }, ...(owner ? { amicode_owner: owner } : {}) }) as DropdownSession + + test("peers append to local, sorted by updated desc", () => { + const local = [mk("a", 5), mk("b", 1)] + const peers = [mk("c", 9, studioTag), mk("d", 3, studioTag)] + expect(mergePeerSessions(local, peers).map((s) => s.id)).toEqual(["c", "a", "d", "b"]) + }) + + test("id collision: the local row wins, the peer duplicate is dropped", () => { + const local = [mk("dup", 5)] + const peers = [mk("dup", 99, studioTag)] + const out = mergePeerSessions(local, peers) + expect(out).toHaveLength(1) + expect(out[0].amicode_owner).toBeUndefined() // kept the LOCAL row + }) + + test("empty peers → local list unchanged (degrade path)", () => { + const local = [mk("a", 5), mk("b", 9)] + expect(mergePeerSessions(local, []).map((s) => s.id)).toEqual(["b", "a"]) + }) +}) 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 new file mode 100644 index 00000000..6bed5b15 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts @@ -0,0 +1,124 @@ +/** + * session-fleet-peers.ts — #1525 B1 (read-only) + * + * Pure logic for merging PEER sessions from the fleet-wide projection + * (GET /amicode/fleet/sessions, W2 #1447) into the titlebar Sessions dropdown. + * + * B1 scope is READ-ONLY: a peer's sessions APPEAR in the dropdown, badged with + * the owner machine name, searchable, and de-duped against the local list. + * Owner-routed OPEN and remote control (prompt / archive / delete) are B2 — + * this module deliberately carries no control surface. `isRemotePeerSession` + * is the predicate the component uses to keep destructive/local-only actions + * off a remote row. + * + * The projection's session entries carry the FULL raw session fields (id, + * title, time, directory) PLUS the `amicode_owner` overlay + * (merged_projection.ts `tagSessionsWithOwner`), so a remote entry is directly + * renderable as a dropdown row. Every reader here is TOLERANT: a malformed / + * absent / errored response yields [] and never throws (the dropdown must + * degrade to the local list, never blank out). + */ + +import type { Session } from "@opencode-ai/sdk/v2/client" + +/** The per-session owner overlay the projection tags each entry with + * (mirrors merged_projection.ts `SessionOwnerTag`). */ +export interface SessionOwnerTag { + owner_machine_id: string + owner_name: string + device_type?: string + directory?: string + is_local: boolean +} + +/** A dropdown session row — the SDK session shape plus the optional owner + * overlay a peer entry carries. */ +export type DropdownSession = Session & { amicode_owner?: SessionOwnerTag } + +/** True when a session is a REMOTE peer session (has an owner overlay whose + * `is_local` is explicitly false). Local / unowned sessions are false — + * absence of an overlay means local (ADR 0031 §D6). B1 uses this to keep + * local-only actions (archive) and the local open OFF a remote row. */ +export function isRemotePeerSession(session: { amicode_owner?: SessionOwnerTag } | undefined): boolean { + return !!session?.amicode_owner && session.amicode_owner.is_local === false +} + +/** The machine badge for a dropdown row: the owner machine name for a remote + * session, `undefined` for a local/unowned one (absence = local, unbadged). */ +export function deriveSessionBadge(session: { amicode_owner?: SessionOwnerTag } | undefined): string | undefined { + if (!isRemotePeerSession(session)) return undefined + return session!.amicode_owner!.owner_name +} + +/** 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 { + if (!raw || typeof raw !== "object") return undefined + const o = raw as Record + if (typeof o.owner_machine_id !== "string") return undefined + if (typeof o.owner_name !== "string") return undefined + if (typeof o.is_local !== "boolean") return undefined + return { + owner_machine_id: o.owner_machine_id, + owner_name: o.owner_name, + ...(typeof o.device_type === "string" ? { device_type: o.device_type } : {}), + ...(typeof o.directory === "string" ? { directory: o.directory } : {}), + is_local: o.is_local, + } +} + +/** Coerce one raw projection entry into a renderable DropdownSession, keeping + * only the fields the dropdown needs. Returns undefined when the entry has no + * usable id (never a fabricated row). */ +function readProjectionSession(raw: unknown): DropdownSession | undefined { + if (!raw || typeof raw !== "object") return undefined + const o = raw as Record + if (typeof o.id !== "string" || o.id === "") return undefined + const owner = readOwnerTag(o.amicode_owner) + const timeRaw = (o.time && typeof o.time === "object" ? o.time : {}) as Record + const created = typeof timeRaw.created === "number" ? timeRaw.created : 0 + const updated = typeof timeRaw.updated === "number" ? timeRaw.updated : created + const time: DropdownSession["time"] = { + created, + updated, + ...(typeof timeRaw.archived === "number" ? { archived: timeRaw.archived } : {}), + } + return { + id: o.id, + ...(typeof o.title === "string" ? { title: o.title } : {}), + directory: typeof o.directory === "string" ? o.directory : "", + ...(typeof o.parentID === "string" ? { parentID: o.parentID } : {}), + time, + ...(owner ? { amicode_owner: owner } : {}), + } as DropdownSession +} + +/** From the raw GET /amicode/fleet/sessions response, extract the REMOTE peer + * sessions (owner overlay present, `is_local === false`) as renderable rows. + * A malformed / error / not-yet-resolved response → []. Local-owned and + * unowned entries are dropped (the local list already carries those). */ +export function peerSessionsFromProjection(raw: unknown): DropdownSession[] { + if (!raw || typeof raw !== "object") return [] + const sessions = (raw as { sessions?: unknown }).sessions + if (!Array.isArray(sessions)) return [] + return sessions.flatMap((s) => { + const row = readProjectionSession(s) + if (!row || !isRemotePeerSession(row)) return [] + return [row] + }) +} + +/** Merge peer sessions into the local active list: local wins on an id + * collision (a session that is somehow in both stays local), and the result + * is sorted by last activity (updated ?? created) descending — the same order + * the dropdown already uses for the local list. */ +export function mergePeerSessions(local: DropdownSession[], peers: DropdownSession[]): DropdownSession[] { + const seen = new Set(local.map((s) => s.id)) + const merged = [...local] + for (const p of peers) { + if (seen.has(p.id)) continue + seen.add(p.id) + merged.push(p) + } + return merged.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) +} 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 e51273f5..491908e6 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 @@ -11,7 +11,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { getFilename } from "@opencode-ai/core/util/path" -import { batch, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { Portal } from "solid-js/web" @@ -41,6 +41,14 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers" import { useNavigate } from "@solidjs/router" import type { Session } from "@opencode-ai/sdk/v2/client" +import { amicodeGet } from "@/utils/amicode-fetch" +import { + peerSessionsFromProjection, + mergePeerSessions, + deriveSessionBadge, + isRemotePeerSession, + type DropdownSession, +} from "./session-fleet-peers" // AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator // noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is @@ -715,6 +723,18 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) const currentSessionID = createMemo(() => props.currentSessionID) + // #1525 B1 (read-only): peer sessions from the fleet-wide projection + // (GET /amicode/fleet/sessions). Fetched only while the flyout is open; + // tolerant — a 404 (route not mounted / no fleet) or any error resolves to + // undefined, so the dropdown degrades to the local list, never blank. The + // merge below adds REMOTE (is_local:false) sessions, badged with the owner + // machine name; owner-routed open + remote control (prompt/archive/delete) + // are B2 (#1525) and deliberately absent here. + const [fleetProjection] = createResource( + () => (open() ? server.current : undefined), + (conn) => amicodeGet(conn, "/amicode/fleet/sessions").catch(() => undefined), + ) + // Active sessions — only computed when the flyout is open to avoid // triggering reactive subscriptions (serverSync().child pins the directory // and can cascade re-renders to the parent Portal). @@ -744,6 +764,15 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) } }) + // #1525 B1: fold the projection's remote peer sessions into the active list + // (deduped against local, sorted by last activity). Empty/errored projection + // → the local list unchanged. + const activeSessionsWithPeers = createMemo(() => { + if (!open()) return [] + const peers = peerSessionsFromProjection(fleetProjection.latest) + return mergePeerSessions(activeSessions() as DropdownSession[], peers) + }) + // Fresh clients have no bootstrapped child stores for the fallback // directories (the dropdown reads with bootstrap: false) — kick the loads // once per open. Converges: re-runs find the stores populated and skip. @@ -762,7 +791,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) // Sort: open-tab sessions first const sortedActiveSessions = createMemo(() => { if (!open()) return [] - const all = activeSessions() + const all = activeSessionsWithPeers() const openTabs: Session[] = [] const rest: Session[] = [] for (const session of all) { @@ -890,6 +919,20 @@ 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 + } + // Close flyout first so its Portal unmounts cleanly. setOpen(false) @@ -1150,6 +1193,10 @@ function SessionDropdownRow(props: { }) { const language = useLanguage() const title = createMemo(() => sessionTitle(props.session.title) || props.session.id) + // #1525 B1: a remote peer session shows the owner machine name as a badge and + // hides local-only actions (archive is B2). Local/unowned rows are unbadged. + const badge = createMemo(() => deriveSessionBadge(props.session as DropdownSession)) + const isRemote = createMemo(() => isRemotePeerSession(props.session as DropdownSession)) const rowServer = useServer() // #1292 hover prewarm: a hovered row is a click away — pull its first // message page the instant the pointer lands, so the open renders from @@ -1196,7 +1243,17 @@ function SessionDropdownRow(props: { {title()} + + + + {badge()} + + +
+
) }