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
12 changes: 8 additions & 4 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -940,7 +942,7 @@
}
},
"counts": {
"overlay_total": 869,
"overlay_total": 871,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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"])
})
})
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<DropdownSession[]>(() => {
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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1196,7 +1243,17 @@ function SessionDropdownRow(props: {
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap">
{title()}
</span>
<Show when={badge()}>
<span
class="shrink-0 ml-1 inline-flex max-w-[40%] items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-sm px-1 py-0.5 text-[10px] leading-none text-v2-text-text-faint bg-v2-background-bg-layer-02"
title={badge()!}
>
<IconV2 name="monitor" class="shrink-0 opacity-70" />
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{badge()}</span>
</span>
</Show>
</button>
<Show when={!isRemote()}>
<div class="absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center opacity-0 group-hover/session:opacity-100 focus-within:opacity-100 transition-opacity">
<TooltipV2 placement="top" value={language.t("common.archive")}>
<IconButtonV2
Expand All @@ -1213,6 +1270,7 @@ function SessionDropdownRow(props: {
/>
</TooltipV2>
</div>
</Show>
</div>
)
}
Expand Down
Loading