From c34f47faff4624ceaeef3b93d6a12ae6dc42f665 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:17:56 -0400 Subject: [PATCH 01/11] =?UTF-8?q?feat(app):=20the=20question=20inbox=20mod?= =?UTF-8?q?el=20=E2=80=94=20derive,=20reconcile=20read,=20answer=20action?= =?UTF-8?q?=20(#1786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/src/context/question-inbox.test.ts | 572 ++++++++++++++++++ .../app/src/context/question-inbox.ts | 290 +++++++++ 2 files changed, 862 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts diff --git a/packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts new file mode 100644 index 00000000..bc041877 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts @@ -0,0 +1,572 @@ +import { describe, expect, test } from "bun:test" +import type { OpencodeClient, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { createServerSession } from "./server-session" +import { + INBOX_STALE_MS, + INBOX_TTL_MS, + applyInboxClears, + collectQuestionInbox, + createInboxAnswerAction, + createInboxState, + runInboxRead, + type InboxClear, +} from "./question-inbox" + +// amicode#1786 (S2) — the question inbox model. The seams it rides are pinned +// elsewhere (the #1753 live dispatch seam, the store's question lifecycle in +// server-session.test.ts); these tests pin the INBOX's own behavior: deriving +// every pending question from the shared store (no session opened), the +// session identity + staleness read, TTL clearing, the reconcile read's +// recency-guarded freshness, and the reply action's single-flight + +// idempotency + telemetry floor. + +const sessionInfo = (id: string, directory?: string, title?: string) => + ({ id, directory, title, time: { created: 1, updated: 1 } }) as Session + +const questionRequest = (id: string, sessionID: string, header = id) => + ({ + id, + sessionID, + questions: [{ header, question: `${header}?`, options: [{ label: "a", description: "d" }] }], + }) as QuestionRequest + +function createSession() { + return createServerSession({ session: { get: async () => ({ data: undefined }) } } as unknown as OpencodeClient) +} + +// ── AC1: a question asked by a session the client has NOT opened ──────────── + +describe("#1786 AC1 — an unopened session's question lists in the inbox", () => { + test("a live question.asked event lands in the inbox with the badge count rising to 1", () => { + const session = createSession() + const state = createInboxState() + // The session was never created, never opened, no info in the store — the + // question arrives cold over the directory's event stream. + session.apply({ type: "question.asked", properties: questionRequest("que_live", "ses_unopened") }) + + const entries = collectQuestionInbox({ question: session.data.question, info: session.data.info, state, now: 1_000 }) + expect(entries.length).toBe(1) + expect(entries[0]?.id).toBe("que_live") + expect(entries[0]?.sessionID).toBe("ses_unopened") + // The badge count is the entry count. + expect(entries.length).toBe(1) + }) + + test("settle on question.replied: the entry leaves the inbox and its tracking state is dropped", () => { + const session = createSession() + const state = createInboxState() + session.apply({ type: "question.asked", properties: questionRequest("que_gone", "ses_unopened") }) + collectQuestionInbox({ question: session.data.question, info: session.data.info, state, now: 1_000 }) + + session.apply({ + type: "question.replied", + properties: { sessionID: "ses_unopened", requestID: "que_gone" }, + }) + + const entries = collectQuestionInbox({ question: session.data.question, info: session.data.info, state, now: 2_000 }) + expect(entries.length).toBe(0) + expect(state.evidence.has("que_gone")).toBe(false) + }) +}) + +// ── AC2: session identity + staleness read; answerable from the inbox ─────── + +describe("#1786 AC2 — the entry's session identity and staleness read", () => { + test("an entry carries the session's title and directory from the shared info store", () => { + const state = createInboxState() + const question = { ses_a: [questionRequest("que_id", "ses_a")] } + const info = { ses_a: sessionInfo("ses_a", "/work/proj", "CZ gate sweep") } + const entries = collectQuestionInbox({ question, info, state, now: 5_000 }) + expect(entries[0]?.title).toBe("CZ gate sweep") + expect(entries[0]?.directory).toBe("/work/proj") + expect(entries[0]?.askedAt).toBe(5_000) + }) + + test("a fresh entry does not read as stale", () => { + const state = createInboxState() + const entries = collectQuestionInbox({ + question: { ses_a: [questionRequest("que_f", "ses_a")] }, + info: {}, + state, + now: 10_000, + }) + expect(entries[0]?.stale).toBe(false) + }) +}) + +// ── AC4: stale rows read honestly, never block, clear by TTL ──────────────── + +describe("#1786 AC4 — stale reads, non-blocking, TTL clearing", () => { + test("an entry with no liveness evidence past the stale window reads honestly as stale", () => { + const state = createInboxState() + const question = { ses_a: [questionRequest("que_old", "ses_a")] } + collectQuestionInbox({ question, info: {}, state, now: 1_000 }) + + const entries = collectQuestionInbox({ question, info: {}, state, now: 1_000 + INBOX_STALE_MS + 1 }) + expect(entries.length).toBe(1) + expect(entries[0]?.stale).toBe(true) + }) + + test("a stale entry never blocks the rest of the inbox: fresh entries stay fresh and listed", () => { + const state = createInboxState() + // The older question is seen first; the fresher one is asked later. + const older = { ses_a: [questionRequest("que_old", "ses_a")] } + const both = { + ses_a: [questionRequest("que_old", "ses_a")], + ses_fresh: [questionRequest("que_fresh", "ses_fresh")], + } + collectQuestionInbox({ question: older, info: {}, state, now: 1_000 }) + collectQuestionInbox({ question: both, info: {}, state, now: 1_000 + INBOX_STALE_MS }) + + const entries = collectQuestionInbox({ question: both, info: {}, state, now: 1_000 + INBOX_STALE_MS + 1 }) + const stale = entries.find((entry) => entry.id === "que_old") + const fresh = entries.find((entry) => entry.id === "que_fresh") + expect(stale?.stale).toBe(true) + expect(fresh?.stale).toBe(false) + expect(entries.length).toBe(2) + }) + + test("clears by TTL: an entry with no evidence past the TTL drops from the inbox, others remain", () => { + const state = createInboxState() + const older = { ses_a: [questionRequest("que_dead", "ses_a")] } + const both = { + ses_a: [questionRequest("que_dead", "ses_a")], + ses_fresh: [questionRequest("que_fresh", "ses_fresh")], + } + collectQuestionInbox({ question: older, info: {}, state, now: 1_000 }) + collectQuestionInbox({ question: both, info: {}, state, now: 1_000 + INBOX_STALE_MS }) + + const after = collectQuestionInbox({ question: both, info: {}, state, now: 1_000 + INBOX_TTL_MS + 1 }) + expect(after.map((entry) => entry.id)).toEqual(["que_fresh"]) + // The TTL clear is stable on re-collect while the store row lingers + // (the in-session dock keeps its own view of the same store). + const again = collectQuestionInbox({ question: both, info: {}, state, now: 1_000 + INBOX_TTL_MS + 2 }) + expect(again.map((entry) => entry.id)).toEqual(["que_fresh"]) + }) + + test("a TTL-cleared entry whose session later settles is forgotten entirely", () => { + const state = createInboxState() + const question: Record = { ses_a: [questionRequest("que_dead", "ses_a")] } + collectQuestionInbox({ question, info: {}, state, now: 1_000 }) + collectQuestionInbox({ question, info: {}, state, now: 1_000 + INBOX_TTL_MS + 1 }) + expect(state.tombstones.has("que_dead")).toBe(true) + + // The store row leaves (settled / evicted) — the tombstone must not leak. + const settled: Record = {} + const entries = collectQuestionInbox({ question: settled, info: {}, state, now: 1_000 + INBOX_TTL_MS + 2 }) + expect(entries.length).toBe(0) + expect(state.tombstones.has("que_dead")).toBe(false) + }) +}) + +// ── the reconcile read: freshness rides the event flow plus row TTLs ──────── +// The engine's /question list is the shared pending rows (live-heartbeat rows +// only, any owner) — the read is the inbox's liveness probe; the #1768 +// reconcile semantics apply (recency-guarded, honest absence keeps current +// value). + +describe("#1786 — the inbox reconcile read", () => { + const storeFor = (rows: Record) => rows + const infoFor = (directory: string) => ({ ses_a: sessionInfo("ses_a", directory), ses_b: sessionInfo("ses_b", directory) }) + + test("presence refreshes liveness: a stale entry goes live again after the read includes it", async () => { + const state = createInboxState() + const question = storeFor({ ses_a: [questionRequest("que_1", "ses_a")] }) + const info = infoFor("/work/proj") + collectQuestionInbox({ question, info, state, now: 1_000 }) + collectQuestionInbox({ question, info, state, now: 1_000 + INBOX_STALE_MS + 1 }) + const staleBefore = collectQuestionInbox({ question, info, state, now: 1_000 + INBOX_STALE_MS + 1 }) + expect(staleBefore[0]?.stale).toBe(true) + + const out = await runInboxRead({ + directory: "/work/proj", + read: async () => [questionRequest("que_1", "ses_a")], + question, + info, + state, + now: () => 1_000 + INBOX_STALE_MS + 2, + }) + + expect(out.kind).toBe("ok") + expect(out.refreshed).toEqual(["que_1"]) + expect(out.cleared).toEqual([]) + const after = collectQuestionInbox({ question, info, state, now: 1_000 + INBOX_STALE_MS + 3 }) + expect(after[0]?.stale).toBe(false) + }) + + test("absence on a read that covers the entry clears it — an unreachable owner never wedges the inbox", async () => { + const state = createInboxState() + const question = storeFor({ + ses_a: [questionRequest("que_gone", "ses_a")], + ses_other: [questionRequest("que_kept", "ses_other")], + }) + const info = { ...infoFor("/work/proj"), ses_other: sessionInfo("ses_other", "/work/other") } + collectQuestionInbox({ question, info, state, now: 1_000 }) + + const out = await runInboxRead({ + directory: "/work/proj", + // The owner died: its row expired engine-side and the fresh list omits it. + read: async () => [], + question, + info, + state, + now: () => 2_000, + }) + + expect(out.kind).toBe("ok") + expect(out.cleared).toEqual([{ sessionID: "ses_a", id: "que_gone" }]) + expect(out.refreshed).toEqual([]) + // The cleared pair removes the dead store row; the other directory's + // entry is untouched. + applyInboxClears(question, out.cleared) + const entries = collectQuestionInbox({ question, info, state, now: 2_001 }) + expect(entries.map((entry) => entry.id)).toEqual(["que_kept"]) + }) + + test("honest absence: a read that does not cover the entry's directory changes nothing", async () => { + const state = createInboxState() + const question = storeFor({ ses_other: [questionRequest("que_kept", "ses_other")] }) + const info = { ses_other: sessionInfo("ses_other", "/work/other") } + collectQuestionInbox({ question, info, state, now: 1_000 }) + + const out = await runInboxRead({ + directory: "/work/proj", + read: async () => [], + question, + info, + state, + now: () => 2_000, + }) + + expect(out.cleared).toEqual([]) + expect(state.evidence.has("que_kept")).toBe(true) + }) + + test("recency guard: a read issued BEFORE the ask does not clear the entry it predates", async () => { + const state = createInboxState() + const question = storeFor({ ses_a: [questionRequest("que_new", "ses_a")] }) + const info = infoFor("/work/proj") + collectQuestionInbox({ question, info, state, now: 5_000 }) + + const out = await runInboxRead({ + directory: "/work/proj", + read: async () => [], + question, + info, + state, + now: () => 2_000, // the read was issued before the ask arrived locally + }) + + expect(out.cleared).toEqual([]) + expect(state.evidence.has("que_new")).toBe(true) + }) + + test("a failed read is honest absence: nothing changes, the inbox stays as-is", async () => { + const state = createInboxState() + const question = storeFor({ ses_a: [questionRequest("que_keep", "ses_a")] }) + const info = infoFor("/work/proj") + collectQuestionInbox({ question, info, state, now: 1_000 }) + + const out = await runInboxRead({ + directory: "/work/proj", + read: async () => { + throw new Error("connection refused") + }, + question, + info, + state, + now: () => 2_000, + }) + + expect(out.kind).toBe("failed") + expect(out.cleared).toEqual([]) + expect(out.refreshed).toEqual([]) + expect(state.evidence.has("que_keep")).toBe(true) + }) + + test("an unreachable owner never blocks the other directories: one failed read, one successful read", async () => { + const state = createInboxState() + const question = storeFor({ + ses_dead: [questionRequest("que_dead", "ses_dead")], + ses_live: [questionRequest("que_live", "ses_live")], + }) + const info = { + ses_dead: sessionInfo("ses_dead", "/work/dead"), + ses_live: sessionInfo("ses_live", "/work/live"), + } + collectQuestionInbox({ question, info, state, now: 1_000 }) + + const dead = await runInboxRead({ + directory: "/work/dead", + read: async () => { + throw new Error("unreachable") + }, + question, + info, + state, + now: () => 2_000, + }) + const live = await runInboxRead({ + directory: "/work/live", + read: async () => [questionRequest("que_live", "ses_live")], + question, + info, + state, + now: () => 2_000, + }) + + expect(dead.kind).toBe("failed") + expect(live.kind).toBe("ok") + expect(live.refreshed).toEqual(["que_live"]) + const entries = collectQuestionInbox({ question, info, state, now: 2_001 }) + expect(entries.map((entry) => entry.id).sort()).toEqual(["que_dead", "que_live"]) + }) + + test("a TTL-tombstoned row that the read proves alive revives; one it proves dead clears fully", async () => { + const state = createInboxState() + const question = storeFor({ + ses_a: [questionRequest("que_back", "ses_a")], + ses_b: [questionRequest("que_out", "ses_b")], + }) + const info = infoFor("/work/proj") + collectQuestionInbox({ question, info, state, now: 1_000 }) + collectQuestionInbox({ question, info, state, now: 1_000 + INBOX_TTL_MS + 1 }) + expect(state.tombstones.has("que_back")).toBe(true) + expect(state.tombstones.has("que_out")).toBe(true) + + const out = await runInboxRead({ + directory: "/work/proj", + read: async () => [questionRequest("que_back", "ses_a")], + question, + info, + state, + now: () => 1_000 + INBOX_TTL_MS + 2, + }) + + expect(out.cleared).toEqual([{ sessionID: "ses_b", id: "que_out" }]) + expect(state.tombstones.has("que_back")).toBe(false) + applyInboxClears(question, out.cleared) + const entries = collectQuestionInbox({ question, info, state, now: 1_000 + INBOX_TTL_MS + 3 }) + expect(entries.map((entry) => entry.id)).toEqual(["que_back"]) + }) +}) + +// ── the answer action: single-flight, idempotent settle, telemetry floor ──── + +describe("#1786 AC2/AC3 — answering from the inbox", () => { + test("answering submits the reply with the session's directory and settles the local entry on success", async () => { + const replies: { sessionID: string; requestID: string; directory: string | undefined; answers: string[][] }[] = [] + const settled: string[] = [] + let live = true + const action = createInboxAnswerAction({ + reply: async (input) => { + replies.push({ + sessionID: input.sessionID, + requestID: input.requestID, + directory: input.directory, + answers: input.answers.map((a) => [...a]), + }) + }, + hasQuestion: () => live, + settle: (input) => settled.push(input.requestID), + }) + + await action.submit({ + sessionID: "ses_a", + requestID: "que_1", + directory: "/work/proj", + answers: [["a"]], + }) + + expect(replies.length).toBe(1) + expect(replies[0]).toEqual({ sessionID: "ses_a", requestID: "que_1", directory: "/work/proj", answers: [["a"]] }) + expect(settled).toEqual(["que_1"]) + }) + + test("never double-submit: a retry while the reply is in flight rides the same promise", async () => { + let calls = 0 + let release: () => void = () => {} + const gate = new Promise((resolve) => (release = resolve)) + const action = createInboxAnswerAction({ + reply: () => { + calls++ + return gate + }, + hasQuestion: () => true, + settle: () => {}, + }) + + const first = action.submit({ sessionID: "ses_a", requestID: "que_1", directory: undefined, answers: [["a"]] }) + const second = action.submit({ sessionID: "ses_a", requestID: "que_1", directory: undefined, answers: [["a"]] }) + expect(second).toBe(first) + expect(calls).toBe(1) + + release() + await first + expect(calls).toBe(1) + }) + + test("a late 'already settled' reads as success: the question left the store while the reply was in flight", async () => { + const settled: string[] = [] + let live = false + const action = createInboxAnswerAction({ + reply: async () => { + throw new Error("question request not found") + }, + hasQuestion: () => live, + settle: (input) => settled.push(input.requestID), + }) + + // The question was answered elsewhere; the settle event removed the store + // row before this reply's failure came back. The goal state is reached. + await action.submit({ sessionID: "ses_a", requestID: "que_1", directory: undefined, answers: [["a"]] }) + expect(settled).toEqual(["que_1"]) + }) + + test("a genuine failure (the row is still live locally) rejects and does not settle", async () => { + const settled: string[] = [] + let live = true + const action = createInboxAnswerAction({ + reply: async () => { + throw new Error("boom") + }, + hasQuestion: () => live, + settle: (input) => settled.push(input.requestID), + }) + + await expect( + action.submit({ sessionID: "ses_a", requestID: "que_1", directory: undefined, answers: [["a"]] }), + ).rejects.toThrow("boom") + expect(settled).toEqual([]) + }) +}) + +// ── AC5: the diagnostic reply captures, absorbed as the inbox's floor ──────── + +describe("#1786 AC5 — the absorbed reply telemetry floor (question-reply-attempt/ok/failed)", () => { + test("a successful submit emits the attempt and ok captures", async () => { + const bodies: string[] = [] + const action = createInboxAnswerAction({ + reply: async () => {}, + hasQuestion: () => true, + settle: () => {}, + telemetry: (body) => bodies.push(body), + }) + await action.submit({ sessionID: "ses_a", requestID: "que_1", directory: "/w", answers: [["a"]] }) + + expect(bodies.length).toBe(2) + const attempt = JSON.parse(bodies[0]!) + const ok = JSON.parse(bodies[1]!) + expect(attempt.kind).toBe("question-reply-attempt") + expect(attempt.requestID).toBe("que_1") + expect(attempt.sessionID).toBe("ses_a") + expect(attempt.directory).toBe("/w") + expect(ok.kind).toBe("question-reply-ok") + expect(ok.requestID).toBe("que_1") + }) + + test("a failed submit emits the attempt and failed captures with the error text", async () => { + const bodies: string[] = [] + const action = createInboxAnswerAction({ + reply: async () => { + throw new Error("not found") + }, + hasQuestion: () => true, + settle: () => {}, + telemetry: (body) => bodies.push(body), + }) + await action.submit({ sessionID: "ses_a", requestID: "que_2", directory: undefined, answers: [] }).catch(() => {}) + + expect(bodies.length).toBe(2) + const attempt = JSON.parse(bodies[0]!) + const failed = JSON.parse(bodies[1]!) + expect(attempt.kind).toBe("question-reply-attempt") + expect(failed.kind).toBe("question-reply-failed") + expect(failed.requestID).toBe("que_2") + expect(String(failed.error)).toContain("not found") + }) + + test("an already-settled late reply emits ok (the goal state) with the settled marker", async () => { + const bodies: string[] = [] + const action = createInboxAnswerAction({ + reply: async () => { + throw new Error("question request not found") + }, + hasQuestion: () => false, + settle: () => {}, + telemetry: (body) => bodies.push(body), + }) + await action.submit({ sessionID: "ses_a", requestID: "que_3", directory: undefined, answers: [] }) + + expect(bodies.length).toBe(2) + expect(JSON.parse(bodies[0]!).kind).toBe("question-reply-attempt") + const ok = JSON.parse(bodies[1]!) + expect(ok.kind).toBe("question-reply-ok") + expect(ok.requestID).toBe("que_3") + expect(ok.alreadySettled).toBe(true) + }) +}) + +// ── AC6 (app half): the end-to-end inbox path over the real store seam ────── +// The macbook headless probe owns the live end of this path; this pins the app +// half: unopened session → inbox → answered from the inbox → settled +// everywhere through the shared store. + +describe("#1786 AC6 (app half) — question from an unopened session, answered from the inbox", () => { + test("the full path: asked (unopened) → listed → answered → settled + duplicate submit is a safe no-op", async () => { + const session = createSession() + const state = createInboxState() + let replyCalls = 0 + let live = true + const hasQuestion = (sessionID: string, requestID: string) => + (session.data.question[sessionID] ?? []).some((row) => row.id === requestID) + const action = createInboxAnswerAction({ + reply: async () => { + replyCalls++ + live = false // the engine settles the row before the response lands + }, + hasQuestion: () => live, + settle: (input) => { + session.set( + "question", + input.sessionID, + session.data.question[input.sessionID]?.filter((row) => row.id !== input.requestID), + ) + }, + }) + + session.apply({ type: "question.asked", properties: questionRequest("que_e2e", "ses_elsewhere") }) + const entries = collectQuestionInbox({ question: session.data.question, info: session.data.info, state, now: 1 }) + expect(entries.length).toBe(1) + + await action.submit({ sessionID: "ses_elsewhere", requestID: "que_e2e", directory: undefined, answers: [["a"]] }) + expect(replyCalls).toBe(1) + + // The engine's question.replied event arrives next (the settle the whole + // fleet sees) — and the store is already settled by the local settle hook. + session.apply({ + type: "question.replied", + properties: { sessionID: "ses_elsewhere", requestID: "que_e2e" }, + }) + const after = collectQuestionInbox({ question: session.data.question, info: session.data.info, state, now: 2 }) + expect(after.length).toBe(0) + }) +}) + +// ── the store clear helper ─────────────────────────────────────────────────── + +describe("applyInboxClears", () => { + test("removes exactly the cleared rows from the store map", () => { + const question: Record = { + ses_a: [questionRequest("que_1", "ses_a"), questionRequest("que_2", "ses_a")], + ses_b: [questionRequest("que_3", "ses_b")], + } + const clears: InboxClear[] = [ + { sessionID: "ses_a", id: "que_1" }, + { sessionID: "ses_b", id: "que_3" }, + ] + applyInboxClears(question, clears) + expect(question.ses_a?.map((row) => row.id)).toEqual(["que_2"]) + expect(question.ses_b).toBeUndefined() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts new file mode 100644 index 00000000..4cd2cf69 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts @@ -0,0 +1,290 @@ +// amicode#1786 (S2) — the question inbox: every pending question, answerable +// without opening the session. A client-side consumer of the shared pending +// rows (#1760 P2): entries DERIVE from the ServerSession store's question map +// — the same map the in-session dock renders — so a question asked by a +// session this client has never opened lists here the moment its live +// question.asked event (or a bootstrap /question seed) lands in the store. +// No engine change, no new state ownership; the store's question lifecycle +// (asked/replied/rejected) settles entries everywhere by itself. +// +// Liveness beyond the events: the engine heartbeats live rows every 5s and +// its TTL (15s) prunes a dead owner's rows silently — no terminal event ever +// fires for a crashed owner. The inbox therefore keeps per-request evidence +// (first seen, last seen alive) refreshed by periodic reads of the engine's +// /question list (the shared rows seam — live-heartbeat rows only, any +// owner): presence refreshes, a read that covers the entry's directory and +// omits it proves the row dead and clears it (#1768/#1766 reconcile +// semantics: recency-guarded, honest absence keeps current value). Entries +// the reads cannot cover (unknown directory, unreachable owner) read +// honestly as stale after the stale window and clear by TTL — an +// unreachable owner never wedges the inbox. +import type { QuestionRequest } from "@opencode-ai/sdk/v2/client" + +/** No liveness evidence for this long → the entry reads honestly as stale + * (the engine row TTL is 15s; the reconcile cadence is 10s, so a live row's + * evidence is at most one missed cadence old — see INBOX_RECONCILE_MS). */ +export const INBOX_STALE_MS = 30_000 +/** No liveness evidence for this long → the entry clears by TTL. Only bites + * when the reconcile cannot cover the entry (unknown directory, a failing + * read); a covered live row refreshes every cadence and never ages out. */ +export const INBOX_TTL_MS = 10 * 60_000 +/** The reconcile cadence: one /question read per involved directory while + * the inbox holds entries. Above the engine's 15s row TTL so a dead owner's + * row is gone before the next read observes its absence. */ +export const INBOX_RECONCILE_MS = 10_000 + +/** Per-request liveness evidence, keyed by request id. */ +export type InboxEvidence = { firstSeenAt: number; lastLiveAt: number } +export type InboxState = { + evidence: Map + /** ids cleared by TTL while their store row still lingers (the dock keeps + * its own view of the same store; the inbox just stops listing them). */ + tombstones: Set +} + +export function createInboxState(): InboxState { + return { evidence: new Map(), tombstones: new Set() } +} + +export type InboxEntry = { + readonly id: string + readonly sessionID: string + readonly request: QuestionRequest + /** The session's title from the shared info store, when warmed. */ + readonly title: string | undefined + /** The session's directory — the reply's location scope and the read's + * coverage key. Undefined until the info store learns the session. */ + readonly directory: string | undefined + /** When this client first saw the request. */ + readonly askedAt: number + /** How long since the last liveness evidence. */ + readonly ageMs: number + readonly stale: boolean +} + +type InboxInfo = Record + +/** The store's question map, generalized so tests and plain records fit. */ +export type InboxQuestionMap = Record + +const cmpID = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +/** + * Derive the inbox from the store. Idempotent-ish: first-seen evidence is + * stamped here (at `now`), TTL clears tombstone entries, and tracking state + * for ids no longer in the store is swept — so calling this on every store + * change is the whole wiring story. + */ +export function collectQuestionInbox(input: { + question: InboxQuestionMap + info?: InboxInfo + state: InboxState + now: number +}): InboxEntry[] { + const present = new Set() + for (const rows of Object.values(input.question)) for (const row of rows ?? []) present.add(row.id) + // Sweep tracking state for settled/evicted rows — keeps the maps bounded + // and lets a TTL tombstone die once its row finally leaves the store. + for (const id of input.state.evidence.keys()) if (!present.has(id)) input.state.evidence.delete(id) + for (const id of input.state.tombstones) if (!present.has(id)) input.state.tombstones.delete(id) + + const entries: InboxEntry[] = [] + for (const [sessionID, rows] of Object.entries(input.question).sort(([a], [b]) => cmpID(a, b))) { + for (const request of rows ?? []) { + if (input.state.tombstones.has(request.id)) continue + const seen = input.state.evidence.get(request.id) + const firstSeenAt = seen?.firstSeenAt ?? input.now + if (!seen) input.state.evidence.set(request.id, { firstSeenAt, lastLiveAt: firstSeenAt }) + const lastLiveAt = seen?.lastLiveAt ?? firstSeenAt + // Clears by TTL: an unreachable owner never wedges the inbox. (The read + // timeout bounds a read's readAt within ~30s of now, and the tombstone + // needs 10 min without evidence, so a read issued before the ask cannot + // arrive after the tombstone — no recency guard is needed here.) + if (input.now - lastLiveAt > INBOX_TTL_MS) { + input.state.evidence.delete(request.id) + input.state.tombstones.add(request.id) + continue + } + const info = input.info?.[sessionID] + entries.push({ + id: request.id, + sessionID, + request, + title: info?.title, + directory: info?.directory, + askedAt: firstSeenAt, + ageMs: input.now - lastLiveAt, + stale: input.now - lastLiveAt > INBOX_STALE_MS, + }) + } + } + return entries.sort((a, b) => a.askedAt - b.askedAt || cmpID(a.id, b.id)) +} + +export type InboxClear = { sessionID: string; id: string } + +export type InboxReadOutcome = { + kind: "ok" | "failed" + refreshed: string[] + cleared: InboxClear[] +} + +/** + * One reconcile read: the engine's /question list for one directory is the + * shared pending rows (live-heartbeat only, any owner) — presence is the + * freshest liveness truth, absence proves the row dead. The #1768 semantics: + * a read issued BEFORE the last local evidence is stale for that entry and + * cannot clear it (recency guard); a read that does not cover the entry's + * directory changes nothing (honest absence keeps current value); a failed + * read changes nothing. The read is stamped BEFORE it is issued, like the + * fleet session-status read. + */ +export async function runInboxRead(deps: { + directory: string + read: () => Promise + question: InboxQuestionMap + info?: InboxInfo + state: InboxState + now: () => number +}): Promise { + const readAt = deps.now() + let rows: readonly QuestionRequest[] + try { + rows = await deps.read() + } catch { + return { kind: "failed", refreshed: [], cleared: [] } + } + const present = new Set(rows.map((row) => row.id)) + const refreshed: string[] = [] + const cleared: InboxClear[] = [] + for (const [sessionID, rows] of Object.entries(deps.question)) { + // Coverage: only sessions whose directory is this read's directory. The + // engine's per-directory instance list is authoritative for them. + if (deps.info?.[sessionID]?.directory !== deps.directory) continue + for (const row of rows ?? []) { + const evidence = deps.state.evidence.get(row.id) + const tombstoned = deps.state.tombstones.has(row.id) + if (!evidence && !tombstoned) continue + if (present.has(row.id)) { + // Alive per the freshest truth — even a TTL tombstone revives. + deps.state.tombstones.delete(row.id) + if (evidence) evidence.lastLiveAt = Math.max(evidence.lastLiveAt, readAt) + refreshed.push(row.id) + continue + } + // Recency guard: a read issued before the ask (or before newer local + // evidence) cannot clear what it predates. + if (evidence && (evidence.firstSeenAt > readAt || evidence.lastLiveAt > readAt)) continue + deps.state.evidence.delete(row.id) + deps.state.tombstones.delete(row.id) + cleared.push({ sessionID, id: row.id }) + } + } + return { kind: "ok", refreshed, cleared } +} + +/** Remove cleared rows from the store's question map (safe inside a solid + * `produce` draft; a session left with no rows is dropped entirely). */ +export function applyInboxClears(question: InboxQuestionMap & Record, clears: readonly InboxClear[]) { + for (const clear of clears) { + const rows = question[clear.sessionID] + if (!rows?.some((row) => row.id === clear.id)) continue + const next = rows.filter((row) => row.id !== clear.id) + if (next.length === 0) delete question[clear.sessionID] + else question[clear.sessionID] = next + } +} + +// ── the answer action ──────────────────────────────────────────────────────── + +export type InboxAnswerInput = { + sessionID: string + requestID: string + directory: string | undefined + answers: ReadonlyArray> +} + +export type InboxAnswerDeps = { + /** The reply request, scoped to the REQUESTING session's directory + * (amicode#117/#249: the registry is instance-per-directory). */ + reply: (input: InboxAnswerInput) => Promise + /** Is the row still in the shared store? (Answers a failed reply: a + * question that left the store was settled elsewhere — the goal state.) */ + hasQuestion: (sessionID: string, requestID: string) => boolean + /** Remove the entry locally once the reply is accepted (the engine's + * question.replied event also settles the store; both are idempotent). */ + settle: (input: { sessionID: string; requestID: string }) => void + /** The telemetry sink (postClientLog). */ + telemetry?: (body: string) => void +} + +/** The live dist's diagnostic reply captures (question-reply-attempt / ok / + * failed) were built from an unbranched tree — #1786 absorbs them here as + * the inbox's own telemetry floor, so the markers keep the same capture + * shape in source, owned by the reply path this slice ships. */ +function emitReplyTelemetry(post: ((body: string) => void) | undefined, capture: Record) { + if (!post) return + try { + post(JSON.stringify(capture)) + } catch { + // Telemetry must never break the reply. + } +} + +function replyErrorText(error: unknown) { + return error instanceof Error ? `${error.message} | ${String(error.stack).slice(0, 400)}` : String(error) +} + +/** + * The inbox's answer action. Single-flight per request (a retry while the + * reply is in flight rides the same promise — never double-submit), tolerant + * of a late "already settled" (the question left the store while the reply + * was in flight → answered elsewhere → the goal state, reads as success), + * and the settle is idempotent against the engine's own idempotent + * settlePending. + */ +export function createInboxAnswerAction(deps: InboxAnswerDeps) { + const inflight = new Map>() + return { + pending: (requestID: string) => inflight.has(requestID), + submit(input: InboxAnswerInput): Promise { + const existing = inflight.get(input.requestID) + if (existing) return existing + const task = (async () => { + emitReplyTelemetry(deps.telemetry, { + kind: "question-reply-attempt", + requestID: input.requestID, + directory: input.directory, + sessionID: input.sessionID, + }) + try { + await deps.reply(input) + emitReplyTelemetry(deps.telemetry, { kind: "question-reply-ok", requestID: input.requestID }) + } catch (error) { + // A late "already settled" is the goal state: the settle event (or + // the engine's own row expiry) removed the store row before this + // failure landed. The question was answered — never wedge on it. + if (!deps.hasQuestion(input.sessionID, input.requestID)) { + emitReplyTelemetry(deps.telemetry, { + kind: "question-reply-ok", + requestID: input.requestID, + alreadySettled: true, + }) + } else { + emitReplyTelemetry(deps.telemetry, { + kind: "question-reply-failed", + requestID: input.requestID, + error: replyErrorText(error), + }) + throw error + } + } finally { + if (inflight.get(input.requestID) === task) inflight.delete(input.requestID) + } + deps.settle({ sessionID: input.sessionID, requestID: input.requestID }) + })() + inflight.set(input.requestID, task) + return task + }, + } +} From 957656ab90273d646a4bc4ec32a1b27946c44de0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:23:10 -0400 Subject: [PATCH 02/11] feat(app): the per-window question inbox controller (#1786) --- .../app/src/context/question-inbox.test.ts | 86 +++++++++++ .../app/src/context/question-inbox.ts | 133 ++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts index bc041877..ba0e4fe1 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { OpencodeClient, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { createStore, produce } from "solid-js/store" import { createServerSession } from "./server-session" import { INBOX_STALE_MS, @@ -8,6 +9,8 @@ import { collectQuestionInbox, createInboxAnswerAction, createInboxState, + initQuestionInbox, + questionInbox, runInboxRead, type InboxClear, } from "./question-inbox" @@ -570,3 +573,86 @@ describe("applyInboxClears", () => { expect(question.ses_b).toBeUndefined() }) }) + +// ── the per-window controller (the badge + panel's live state) ────────────── + +describe("#1786 — the inbox controller", () => { + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + + test("uninitialized reads degrade to an empty inbox", () => { + expect(questionInbox().entries()).toEqual([]) + expect(questionInbox().opened()).toBe(false) + expect(questionInbox().pending("que_x")).toBe(false) + }) + + test("derives entries from the live store and settles when the store does", async () => { + const [question, setQuestion] = createStore>({}) + const inbox = initQuestionInbox({ + question: () => question, + info: () => undefined, + removeQuestion: (clears) => setQuestion(produce((draft) => applyInboxClears(draft, clears))), + isLive: (sessionID, requestID) => (question[sessionID] ?? []).some((row) => row.id === requestID), + listQuestions: async () => [], + reply: async () => {}, + reconcileMs: 5, + }) + + setQuestion("ses_a", [questionRequest("que_c", "ses_a")]) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(inbox.entries().map((entry) => entry.id)).toEqual(["que_c"]) + + // Settled anywhere (the replied event, a reconcile clear) — the store row + // leaves and the inbox entry follows within one derivation pass. + setQuestion("ses_a", []) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(inbox.entries().length).toBe(0) + inbox.dispose() + }) + + test("the reconcile cadence reads each involved directory and applies the clears it proves", async () => { + const [question, setQuestion] = createStore>({}) + const readDirectories: string[] = [] + let reads = 0 + const inbox = initQuestionInbox({ + question: () => question, + info: () => ({ ses_a: sessionInfo("ses_a", "/work/proj") }), + removeQuestion: (clears) => setQuestion(produce((draft) => applyInboxClears(draft, clears))), + isLive: (sessionID, requestID) => (question[sessionID] ?? []).some((row) => row.id === requestID), + listQuestions: async (directory) => { + readDirectories.push(directory) + reads++ + // The first read proves the row alive; the owner dies before the next. + return reads === 1 ? [questionRequest("que_dead", "ses_a")] : [] + }, + reply: async () => {}, + connected: () => true, + reconcileMs: 10, + }) + + setQuestion("ses_a", [questionRequest("que_dead", "ses_a")]) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(inbox.entries().length).toBe(1) + + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(readDirectories).toContain("/work/proj") + expect(question.ses_a).toBeUndefined() + expect(inbox.entries().length).toBe(0) + inbox.dispose() + }) + + test("disposal resets the singleton to the empty inbox", async () => { + const [question] = createStore>({}) + const inbox = initQuestionInbox({ + question: () => question, + info: () => undefined, + removeQuestion: () => {}, + isLive: () => false, + listQuestions: async () => [], + reply: async () => {}, + reconcileMs: 60_000, + }) + inbox.dispose() + expect(questionInbox().entries()).toEqual([]) + expect(questionInbox().opened()).toBe(false) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts index 4cd2cf69..894a4eda 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/question-inbox.ts @@ -19,6 +19,7 @@ // honestly as stale after the stale window and clear by TTL — an // unreachable owner never wedges the inbox. import type { QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { createEffect, createRoot, createSignal } from "solid-js" /** No liveness evidence for this long → the entry reads honestly as stale * (the engine row TTL is 15s; the reconcile cadence is 10s, so a live row's @@ -288,3 +289,135 @@ export function createInboxAnswerAction(deps: InboxAnswerDeps) { }, } } + +// ── the per-window inbox controller (module singleton) ────────────────────── +// One inbox per window (the vault-panel pattern): the layout that owns the +// active server's provider scope initializes it with narrow deps over the +// shared store + SDK; the titlebar badge and the panel both read +// `questionInbox()`. Uninitialized reads degrade to an empty inbox so chrome +// rendered outside an init scope (a titlebar before the layout mounts) stays +// silent instead of throwing. + +export type QuestionInboxDeps = { + /** The shared store's question map (a reactive read — the effect tracks it). */ + question: () => InboxQuestionMap + /** The shared store's session-info map (reactive; may be undefined). */ + info: () => InboxInfo | undefined + /** Remove cleared rows from the shared store (idempotent). */ + removeQuestion: (clears: readonly InboxClear[]) => void + /** Is the row still in the shared store? */ + isLive: (sessionID: string, requestID: string) => boolean + /** The engine's /question list for one directory (the shared rows seam). */ + listQuestions: (directory: string) => Promise + /** The reply request, scoped to the requesting session's directory. */ + reply: (input: InboxAnswerInput) => Promise + telemetry?: (body: string) => void + /** Only read while this says true (the live event stream is connected). */ + connected?: () => boolean + /** Test seam for the reconcile cadence. */ + reconcileMs?: number +} + +export type QuestionInboxHandle = { + entries: () => readonly InboxEntry[] + opened: () => boolean + open: () => void + close: () => void + toggle: () => void + submit: ReturnType["submit"] + pending: (requestID: string) => boolean + dispose: () => void +} + +let handle: QuestionInboxHandle | undefined + +export function questionInbox(): QuestionInboxHandle { + return ( + handle ?? { + entries: () => [], + opened: () => false, + open: () => {}, + close: () => {}, + toggle: () => {}, + // Unreachable in practice: the panel only renders submit controls for + // listed entries, and an uninitialized inbox lists none. + submit: () => Promise.resolve(), + pending: () => false, + dispose: () => {}, + } + ) +} + +export function initQuestionInbox(deps: QuestionInboxDeps): QuestionInboxHandle { + handle?.dispose() + const state = createInboxState() + const [entries, setEntries] = createSignal([]) + const [opened, setOpened] = createSignal(false) + const answer = createInboxAnswerAction({ + reply: deps.reply, + hasQuestion: deps.isLive, + settle: (input) => deps.removeQuestion([{ sessionID: input.sessionID, id: input.requestID }]), + telemetry: deps.telemetry, + }) + const collect = () => + setEntries(collectQuestionInbox({ question: deps.question(), info: deps.info(), state, now: Date.now() })) + // Freshness: one read per involved directory per cadence. A read only runs + // while the event stream is connected (offline reads are pointless — their + // failure would be honest absence, but the retry storm buys nothing). + const reconcile = async () => { + if (deps.connected?.() === false) return + const directories = new Set(entries().flatMap((entry) => (entry.directory ? [entry.directory] : []))) + for (const directory of directories) { + const out = await runInboxRead({ + directory, + read: () => deps.listQuestions(directory), + question: deps.question(), + info: deps.info(), + state, + now: () => Date.now(), + }) + // An absence the read proves dead removes the dead store row — the + // same settle the engine's replied/rejected events drive. + if (out.cleared.length > 0) deps.removeQuestion(out.cleared) + collect() + } + } + const cleanup = createRoot((disposeRoot) => { + // In the browser this effect tracks the shared store's question + info + // maps — every ask, settle, and session-info warm lands there and + // re-derives the inbox instantly. Under the app test runner solid + // resolves to the non-reactive server build, where this is inert and + // the cadence collect below is what drives the same derivation. + createEffect(() => { + deps.question() + deps.info() + collect() + }) + const timer = setInterval( + () => { + collect() // the staleness read re-evaluates on the clock + void reconcile() + }, + deps.reconcileMs ?? INBOX_RECONCILE_MS, + ) + return () => { + clearInterval(timer) + disposeRoot() + } + }) + const inbox: QuestionInboxHandle = { + entries: () => entries(), + opened: () => opened(), + open: () => setOpened(true), + close: () => setOpened(false), + toggle: () => setOpened((x) => !x), + submit: answer.submit, + pending: answer.pending, + dispose() { + if (handle === inbox) handle = undefined + cleanup() + }, + } + handle = inbox + return inbox +} From 0a3a3b20478e7db7dfd2a6f636fe06c1a06a361f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:31:17 -0400 Subject: [PATCH 03/11] fix(app): close the dropped test/describe braces at the P4 splice seam in server-sdk.test.ts (#1786 note) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1784 (P4) overlay edit spliced the postEventStreamUrlAssert describe into the middle of the dead-man wiring describe's last test, dropping its closing }) and the describe's }). The file failed to parse: the whole suite was dead (0 of its 35 tests ran) and tsgo -b died on TS1005 before any type-check could run. Repair only — all 35 tests pass once parseable; the known baseline failure set is unchanged by the repair (measured, see the slice return). --- .../overlay/packages/app/src/context/server-sdk.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts index eb3adf62..6e88fdc6 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts @@ -311,6 +311,9 @@ describe("dead-man wiring in the reader loop (#1751)", () => { const body = source.slice(onDead, onDead + 200) expect(body).toContain('setStreamStatus("disconnected")') expect(body).toContain("attempt?.abort()") + }) +}) + // #1760 P4 (4b) — the telemetry-visible assert: the global SDK event stream's // URL must be PROVABLE in production (the headless probe greps the hub's // client-errors.log for this line and asserts it equals the hub URL when the From 7420e4ff44ecdf9efcc70ba17ff3ef5cd1563d4b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:31:23 -0400 Subject: [PATCH 04/11] =?UTF-8?q?feat(app):=20the=20question=20inbox=20sur?= =?UTF-8?q?face=20=E2=80=94=20titlebar=20badge,=20panel=20drawer,=20layout?= =?UTF-8?q?=20wiring=20(#1786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge is a canonical titlebar control (persisted pre-#1786 layouts fall back to the new default and gain it); the panel is the VaultPanel-shaped global drawer; NewLayout wires the per-window controller to the shared ServerSession store + the hub SDK and tears it down on server switches. --- .../app/src/components/question-inbox.tsx | 210 ++++++++++++++++++ .../src/components/titlebar-layout.test.ts | 34 +-- .../app/src/components/titlebar-layout.ts | 4 +- .../packages/app/src/components/titlebar.tsx | 9 + .../packages/app/src/pages/layout-new.tsx | 49 +++- 5 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx diff --git a/packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx b/packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx new file mode 100644 index 00000000..ea3112dc --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx @@ -0,0 +1,210 @@ +// amicode#1786 (S2) — the question inbox surface: a titlebar badge (the count +// of every pending question reachable from this client's shared store) plus +// the panel drawer listing each with its session identity and a staleness +// read, answerable in place — no session view required. The state lives in +// the per-window controller (context/question-inbox, the vault-panel +// singleton pattern); this file is render composition over it, exactly like +// the in-session dock renders the same shared store. +import { createMemo, For, Show, type Component } from "solid-js" +import { createStore } from "solid-js/store" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import type { QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { questionInbox, type InboxEntry } from "@/context/question-inbox" + +/** #1786: does every question of the request have at least one answer? */ +function answerableQuestions(request: QuestionRequest, answers: (string[] | undefined)[]) { + return request.questions.every((question, index) => (answers[index]?.length ?? 0) > 0) +} + +function answersFor(request: QuestionRequest, answers: (string[] | undefined)[]) { + // Order matches the request's questions; text-kind questions fall back to + // their pre-filled default (the dock's submit gate treats empty text as + // unanswered — the submit button reflects the same rule). + return request.questions.map((_, index) => answers[index] ?? []) +} + +function ageText(ageMs: number) { + if (ageMs < 60_000) return `${Math.max(1, Math.round(ageMs / 1000))}s` + if (ageMs < 3_600_000) return `${Math.round(ageMs / 60_000)}m` + return `${Math.round(ageMs / 3_600_000)}h` +} + +/** The titlebar control: the inbox bell + pending count. Always rendered — + * the sortable layout needs the control present — with the count chip and + * an attention tint only while questions wait. */ +export const QuestionInboxBadge: Component = () => { + const inbox = questionInbox() + const count = createMemo(() => inbox.entries().length) + return ( + + 0 ? `Question inbox — ${count()} pending` : "Question inbox"} class="shrink-0"> + + } + state={inbox.opened() ? "pressed" : undefined} + onClick={inbox.toggle} + aria-label="Question inbox" + /> + 0}> + {/* the count chip is black-on-yellow (the accent's only light-mode legibility) */} + + {count() > 99 ? "99+" : count()} + + + + + + ) +} + +function InboxEntryCard(props: { entry: InboxEntry; disabled: boolean }) { + const inbox = questionInbox() + // Draft answers per question index for this entry; cleared with the entry. + const [draft, setDraft] = createStore<{ answers: (string[] | undefined)[] }>({ + answers: props.entry.request.questions.map((question) => + question.options.length === 0 && question.default ? [question.default] : undefined, + ), + }) + const ready = createMemo(() => answerableQuestions(props.entry.request, draft.answers)) + const sessionLabel = () => props.entry.title ?? props.entry.sessionID + + const pick = (index: number, label: string, multiple: boolean) => { + setDraft( + "answers", + index, + multiple ? (current) => (current?.includes(label) ? current.filter((item) => item !== label) : [...(current ?? []), label]) : [label], + ) + } + + const submit = () => { + if (props.disabled || !ready() || inbox.pending(props.entry.id)) return + void inbox + .submit({ + sessionID: props.entry.sessionID, + requestID: props.entry.id, + directory: props.entry.directory, + answers: answersFor(props.entry.request, draft.answers), + }) + .catch(() => {}) + } + + return ( +
+
+
{sessionLabel()}
+
+ + stale · {ageText(props.entry.ageMs)} + + waiting {ageText(Date.now() - props.entry.askedAt)} +
+
+ + {(question, index) => ( +
+
{question.question}
+
+ + {(option) => ( + + )} + + + { + const text = event.currentTarget.value.trim() + setDraft("answers", index(), text ? [text] : undefined) + }} + /> + +
+
+ )} +
+
+ +
+
+ ) +} + +/** The panel drawer — the VaultPanel's host shape: one global host on every + * route (mounted by the layout that owns the server provider scope). */ +export function QuestionInboxPanel() { + const inbox = questionInbox() + return ( + + + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts b/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts index 9d69b76b..b8bdb0ac 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.test.ts @@ -18,10 +18,10 @@ import { } from "./titlebar-layout" describe("titlebar layout", () => { - test("the default layout places all five controls on the right in canonical order", () => { + test("the default layout places all six controls on the right in canonical order", () => { expect(defaultTitlebarLayout).toEqual({ left: [], - right: ["sessions", "status", "side-panel", "profile", "settings"], + right: ["sessions", "status", "side-panel", "inbox", "profile", "settings"], } satisfies TitlebarLayout) expect([...defaultTitlebarLayout.left, ...defaultTitlebarLayout.right].sort()).toEqual( [...TITLEBAR_CONTROL_IDS].sort(), @@ -31,7 +31,7 @@ describe("titlebar layout", () => { test("a valid layout with all controls on the right passes through", () => { const input = { left: [], - right: ["sessions", "status", "side-panel", "profile", "settings"], + right: ["sessions", "status", "side-panel", "inbox", "profile", "settings"], } satisfies TitlebarLayout expect(validateTitlebarLayout(input)).toEqual(input) }) @@ -39,7 +39,7 @@ describe("titlebar layout", () => { test("a valid layout split across left and right passes through", () => { const input = { left: ["profile", "settings"], - right: ["sessions", "status", "side-panel"], + right: ["sessions", "status", "side-panel", "inbox"], } satisfies TitlebarLayout expect(validateTitlebarLayout(input)).toEqual(input) }) @@ -61,16 +61,18 @@ describe("titlebar layout", () => { expect( validateTitlebarLayout({ left: ["sessions"], - right: ["sessions", "status", "side-panel", "profile", "settings"], + right: ["sessions", "status", "side-panel", "inbox", "profile", "settings"], }), ).toEqual(defaultTitlebarLayout) }) test("missing IDs fall back to the default layout", () => { expect( + // also the migration: a pre-#1786 persisted layout (five controls, no + // inbox) falls back to the new default and gains the inbox badge. validateTitlebarLayout({ left: [], - right: ["sessions", "status", "side-panel", "profile"], + right: ["sessions", "status", "side-panel", "profile", "settings"], }), ).toEqual(defaultTitlebarLayout) }) @@ -79,14 +81,14 @@ describe("titlebar layout", () => { expect( validateTitlebarLayout({ left: ["unknown-button"], - right: ["sessions", "status", "side-panel", "profile", "settings"], + right: ["sessions", "status", "side-panel", "inbox", "profile", "settings"], }), ).toEqual(defaultTitlebarLayout) }) test("a valid layout with all controls on the left passes through", () => { const input = { - left: ["sessions", "status", "side-panel", "profile", "settings"], + left: ["sessions", "status", "side-panel", "inbox", "profile", "settings"], right: [], } satisfies TitlebarLayout expect(validateTitlebarLayout(input)).toEqual(input) @@ -96,6 +98,7 @@ describe("titlebar layout", () => { expect(mountPointId("sessions")).toBe("opencode-titlebar-sessions") expect(mountPointId("status")).toBe("opencode-titlebar-status") expect(mountPointId("side-panel")).toBe("opencode-titlebar-side-panel") + expect(mountPointId("inbox")).toBe("opencode-titlebar-inbox") expect(mountPointId("profile")).toBe("opencode-titlebar-profile") expect(mountPointId("settings")).toBe("opencode-titlebar-settings") }) @@ -104,6 +107,7 @@ describe("titlebar layout", () => { expect(isSessionScoped("sessions")).toBe(true) expect(isSessionScoped("status")).toBe(true) expect(isSessionScoped("side-panel")).toBe(true) + expect(isSessionScoped("inbox")).toBe(false) expect(isSessionScoped("profile")).toBe(false) expect(isSessionScoped("settings")).toBe(false) }) @@ -112,21 +116,21 @@ describe("titlebar layout", () => { // The fleet studio slice (session-header provenance, machine picker, Preview // remote edit) adds NO controls to the titlebar. The session-header provenance // icon + caret live in the data-session-title sticky header, not the titlebar. - test("fleet studio adds no titlebar controls — TITLEBAR_CONTROL_IDS is exactly the canonical five (#1442 AC3)", () => { - // The canonical set is exactly these five, in this order - expect(TITLEBAR_CONTROL_IDS).toEqual(["sessions", "status", "side-panel", "profile", "settings"]) - // No fleet/machine/provenance control was added + test("the canonical control set is exactly the six — still no fleet/machine/provenance controls (#1442 AC3, #1786)", () => { + // #1786 adds the question-inbox badge as a canonical control; the #1442 + // fleet-studio contract holds: no machine/fleet/provenance control. + expect(TITLEBAR_CONTROL_IDS).toEqual(["sessions", "status", "side-panel", "inbox", "profile", "settings"]) const ids = [...TITLEBAR_CONTROL_IDS] expect(ids).not.toContain("machine") expect(ids).not.toContain("fleet") expect(ids).not.toContain("provenance") - expect(ids.length).toBe(5) + expect(ids.length).toBe(6) }) - test("fleet studio does not change the default layout (#1442 AC3)", () => { + test("the default layout carries every canonical control including the inbox (#1442 AC3 union, #1786)", () => { expect(defaultTitlebarLayout).toEqual({ left: [], - right: ["sessions", "status", "side-panel", "profile", "settings"], + right: ["sessions", "status", "side-panel", "inbox", "profile", "settings"], }) // The union of left + right is exactly TITLEBAR_CONTROL_IDS (no new entries) const all = [...defaultTitlebarLayout.left, ...defaultTitlebarLayout.right] diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.ts b/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.ts index 266a022f..9908d7df 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar-layout.ts @@ -1,4 +1,4 @@ -export const TITLEBAR_CONTROL_IDS = ["sessions", "status", "side-panel", "profile", "settings"] as const +export const TITLEBAR_CONTROL_IDS = ["sessions", "status", "side-panel", "inbox", "profile", "settings"] as const export type TitlebarControlId = (typeof TITLEBAR_CONTROL_IDS)[number] @@ -9,7 +9,7 @@ export interface TitlebarLayout { export const defaultTitlebarLayout: TitlebarLayout = { left: [], - right: ["sessions", "status", "side-panel", "profile", "settings"], + right: ["sessions", "status", "side-panel", "inbox", "profile", "settings"], } export function validateTitlebarLayout(value: unknown): TitlebarLayout { diff --git a/packages/app-bundle/overlay/packages/app/src/components/titlebar.tsx b/packages/app-bundle/overlay/packages/app/src/components/titlebar.tsx index 29170d90..96864830 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/titlebar.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/titlebar.tsx @@ -36,6 +36,7 @@ import { tabHref, useTabs, type Tab } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import { normalizeSessionInfo } from "@/utils/session" import { channelBadgeText } from "./titlebar-channel" +import { QuestionInboxBadge } from "@/components/question-inbox" import { type TitlebarControlId, type TitlebarLayout, mountPointId, isSessionScoped, defaultTitlebarLayout, reorderWithinSlot, moveToSlot, controlSlotLabel, reconcileDropOnEmptySlot, reconcileDragEnd } from "./titlebar-layout" import "./titlebar.css" @@ -768,6 +769,14 @@ function TitlebarControlSlot(props: { + + {/* #1786 (S2): the question inbox badge — every pending question + reachable from this client's shared store, session-less. The + controller is initialized by the layout that owns the server + provider scope; outside it, the badge degrades to an inert empty + inbox. */} + + diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx index e3956632..83e15f06 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout-new.tsx @@ -1,12 +1,17 @@ -import { createEffect, Suspense, type ParentProps } from "solid-js" -import { createStore } from "solid-js/store" +import { createEffect, onCleanup, Suspense, type ParentProps } from "solid-js" +import { createStore, produce } from "solid-js/store" import { TabsInfoPopup } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { SolverSwitchBanner } from "@/components/solver-switch-banner" import { HarnessSwitchBanner } from "@/components/harness-switch-banner" import { installEngineStateListener } from "@/components/engine-state-signal" import { VaultPanel } from "@/components/vault-panel" +import { QuestionInboxPanel } from "@/components/question-inbox" import { usePlatform } from "@/context/platform" +import { postClientLog } from "@/amicode/client-telemetry" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { applyInboxClears, initQuestionInbox } from "@/context/question-inbox" import { setV2Toast, ToastRegion } from "@/utils/toast" import { useNotificationToasts } from "./layout/notification-toasts" import { useWorktreeAutoRename } from "./layout/worktree-auto-rename" @@ -26,6 +31,43 @@ export default function NewLayout(props: ParentProps) { // own dot/lock is the whole story now. installEngineStateListener() + // amicode#1786 (S2): the question inbox's per-window controller — this layout + // owns the active server's provider scope, so it wires the controller to the + // shared ServerSession store + the hub SDK and tears it down on server + // switches. The titlebar badge (the same tree) and the panel below both read + // the module singleton. + const serverSync = useServerSync() + const serverSDK = useServerSDK() + const inbox = initQuestionInbox({ + question: () => serverSync().session.data.question, + info: () => serverSync().session.data.info, + // The same settle the replied/rejected events drive: a row the reconcile + // read proved dead leaves the shared store (idempotent against events). + removeQuestion: (clears) => serverSync().session.set(produce((draft) => applyInboxClears(draft.question, clears))), + isLive: (sessionID, requestID) => + (serverSync().session.data.question[sessionID] ?? []).some((row) => row.id === requestID), + // The engine's /question list — the shared pending rows seam, read the + // same dual-protocol way bootstrapDirectory reads it. + listQuestions: async (directory) => { + const sdk = serverSDK() + if ((await sdk.protocol) === "v1") + return (await sdk.createClient({ directory, throwOnError: true }).question.list()).data ?? [] + return (await sdk.api.question.request.list({ location: { directory } })).data + }, + // The probe-verified reply path, scoped to the REQUESTING session's + // directory exactly like the dock's reply (amicode#117/#249). + reply: (input) => + serverSDK().api.question.reply({ + sessionID: input.sessionID, + requestID: input.requestID, + location: { directory: input.directory }, + answers: input.answers, + }), + telemetry: postClientLog, + connected: () => serverSDK().event.status() === "connected", + }) + onCleanup(() => inbox.dispose()) + const update: TitlebarUpdate = { version: () => { const state = platform.updater?.state() @@ -60,6 +102,9 @@ export default function NewLayout(props: ParentProps) { into layout.tsx's LegacyLayout branch, which newLayoutDesigns never reaches (VaultPanel was invisible here). */} + {/* amicode#1786 (S2): the question inbox's global host — the same + one-host-per-window pattern as the vault drawer. */} + {/* opencode#78 follow-up: a solver switch restarts the opencode server under the webview. Speaks only for switches the app requested — unlike the removed ConnectionBanner, silence is still the default. */} From 7b32b5711cc44947637698a757e968c16d6f0a9c Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:31:42 -0400 Subject: [PATCH 05/11] chore(app-bundle): refresh the manifest for the #1786 inbox files (3 added, 5 re-hashed) --- packages/app-bundle/manifest.json | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 5d9f8f16..8184639f 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -134,6 +134,7 @@ "packages/app/src/components/prompt-input-v2.tsx": "a37d61cd44e630100758c3de3dbf8cf7e0d4d0e85dc54e6bb7bf38290ece4468", "packages/app/src/components/prompt-input.tsx": "c127f81e2c0de4bdbc21a349473df39c623d8d7e2df8df0c03ea60bbef101a25", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", + "packages/app/src/components/question-inbox.tsx": "9fca0c7474de56198ba6bfb12aeddc30e1526fdde1911839189062df12e1cd22", "packages/app/src/components/remote-create-arm.test.ts": "388132209e16b6f00d79ac7c700f8c2bfb5f80343eee34cee73451b32842d3f2", "packages/app/src/components/remote-create-arm.ts": "49cb5f89f041ee5d555a348ec5bbbd9709d448752987c491f63376b02522db36", "packages/app/src/components/remote-create-header.test.ts": "bfe81618f1ecf13a5e55e19242ce286ef40ab9eb95dae9a64fed03e46d7b1954", @@ -157,8 +158,8 @@ "packages/app/src/components/terminal.tsx": "2ce242034ae129c1f475c7a24fdec3facdd9bb1f49ba8c6b503643b07ae241e5", "packages/app/src/components/titlebar-channel.test.ts": "184238871c46f586dcc21081f710e37a77914c983fccf250665c7317212e41d8", "packages/app/src/components/titlebar-channel.ts": "385ace91d3c69c92f5b442bf15d2040e35df855f1d4ece47312c1ff8e1b01780", - "packages/app/src/components/titlebar-layout.test.ts": "00708b4dd63a357f76bec035ce0fad017e5b24c18129f38df039f587e9e71f71", - "packages/app/src/components/titlebar-layout.ts": "a375c238ab632421a6505dce3a41fd3894865729e921e68eb32249478c18a286", + "packages/app/src/components/titlebar-layout.test.ts": "65991ee08341c22abfa9b77d06f6163711a97e3f17da9d0cfd4e692c8dbbbd41", + "packages/app/src/components/titlebar-layout.ts": "f8e1950c75ae0530b96208a8146ad9efe20f8b8d8c2ae8dd3025b9b9809b4831", "packages/app/src/components/titlebar-portal-dedup.test.ts": "99034b76bf7896247a7eb8d492e7801ac2d67290cce226e70487c008eaff2a1f", "packages/app/src/components/titlebar-portal-registry.ts": "e6a159c44490125046ddbb249fbda5cef6cef383a604a168cb4018cf49025521", "packages/app/src/components/titlebar-tab-nav.css": "90930c2f9476fb0eace8692424a029b3510781c88d1ee61f7f2edba559526832", @@ -166,7 +167,7 @@ "packages/app/src/components/titlebar-tab-popover.css": "d731db2d7d49838004089a3d82ff68c22214b0ca636bf3b8e02498c14dadbf8d", "packages/app/src/components/titlebar-tab-strip.tsx": "a3c8a8df186a6164494230bec7f302cfe3a1cf658e3ab10f9ef0af4f5d63c038", "packages/app/src/components/titlebar.css": "50c262603ba1140db9e4fefe8f03e9c6ae3bf64dc7a917e55586bfd67a5e3011", - "packages/app/src/components/titlebar.tsx": "c53f438701e97cbe3fbf2eaa67fb584a0b8b3a91f2d4f8016542156e17cd598c", + "packages/app/src/components/titlebar.tsx": "51b5fae599e040cacd3f9c0d978d896eb72351f4f592f15dd6b94c759bccf260", "packages/app/src/components/vault-browser-model.test.ts": "7ca11eae3574033d58081bc4126b1ec772a5b496573c70159f05497a1365f4cd", "packages/app/src/components/vault-browser-model.ts": "d9ef6ff588a96ae79b13c3ae0c2a442a42b59021dd501b3a17bd810dda6f7b20", "packages/app/src/components/vault-browser.tsx": "509efa874d69bcd07ef2495a025bc8de2b2fa582736003ff709b4ca76096274b", @@ -193,7 +194,9 @@ "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", "packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da", - "packages/app/src/context/server-sdk.test.ts": "875c6fd0efb76178afb11554c7fb35329a9c70dfdff831b4aca848021637d0d9", + "packages/app/src/context/question-inbox.test.ts": "d4e2c666194fe6463d1a1da1901f2aef3b6a17eec00394a2b020d7ce698d4c92", + "packages/app/src/context/question-inbox.ts": "f42a2490670e44578f88d626bc0db0a0205fa2b5ec7cd719ba0544915f3a6179", + "packages/app/src/context/server-sdk.test.ts": "aa3815ee62cb933fd30895be924ed2074aef7fb90bcbf6cb39ad1dce687e04d8", "packages/app/src/context/server-sdk.tsx": "bd22cc233ba34c42f0ff7a73047b536d1a7882a6783fbaf98fd3163fde12010c", "packages/app/src/context/server-session-child-active.test.ts": "de8c467f67f88b6cce351c1805ec90728cf238eb1ef32f39ffdbbf3de4dcec22", "packages/app/src/context/server-session-orphan-part.test.ts": "1d635374c0bfff25a01825061aabac7334e5cdaedca2dab9f3d9467ff543caea", @@ -254,7 +257,7 @@ "packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5", "packages/app/src/pages/home.tsx": "3e33dca2d17a371daa2f02509eb2730ad94068cf60584c428e22d69056582c05", "packages/app/src/pages/layout-archive.test.ts": "eed98374a2bea6c692d7d13116db9287ee61ff526b90952895bfb0f61afbfb2d", - "packages/app/src/pages/layout-new.tsx": "3763a9458fd34642976177ff8d9bda2bb4b19cbb6c22d4d180bf9b8b3ba16972", + "packages/app/src/pages/layout-new.tsx": "ead0e4a46f5ca4e9515760ce40d21d0114243c490795d7794570d6e6977f592e", "packages/app/src/pages/layout.tsx": "90306f1691cf22b35627e063ce6401ea0ed2f98e12db022861f403de2d0f2d08", "packages/app/src/pages/new-session-landing.test.ts": "95e2780456a653a71e6f66b4302ae546e308a29e6b8ad9e84442938a3a226905", "packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268", @@ -984,10 +987,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-13T15:58:01.063Z", + "extracted_at": "2026-10-13T20:31:28.693Z", "per_package": { "packages/app": { - "A": 244, + "A": 247, "M": 159, "D": 0 }, @@ -1033,7 +1036,7 @@ } }, "counts": { - "overlay_total": 962, + "overlay_total": 965, "deletions": 7, "server_coupled": 0 }, @@ -1164,6 +1167,7 @@ "packages/app/src/components/prompt-input-v2.tsx": "M", "packages/app/src/components/prompt-input.tsx": "M", "packages/app/src/components/prompt-project-selector.tsx": "M", + "packages/app/src/components/question-inbox.tsx": "A", "packages/app/src/components/remote-create-arm.test.ts": "A", "packages/app/src/components/remote-create-arm.ts": "A", "packages/app/src/components/remote-create-header.test.ts": "A", @@ -1223,6 +1227,8 @@ "packages/app/src/context/local.tsx": "M", "packages/app/src/context/models.tsx": "M", "packages/app/src/context/platform.tsx": "M", + "packages/app/src/context/question-inbox.test.ts": "A", + "packages/app/src/context/question-inbox.ts": "A", "packages/app/src/context/server-sdk.test.ts": "M", "packages/app/src/context/server-sdk.tsx": "M", "packages/app/src/context/server-session-child-active.test.ts": "A", From f663bd8cdb27080857d042e135fe764404cad39f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:33:10 -0400 Subject: [PATCH 06/11] =?UTF-8?q?fix(app):=20the=20inbox=20panel's=20inter?= =?UTF-8?q?action=20states=20=E2=80=94=20focus=20rings,=20cursor,=20submit?= =?UTF-8?q?=20weight=20(#1786)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app-bundle/manifest.json | 4 ++-- .../overlay/packages/app/src/components/question-inbox.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 8184639f..b64421e3 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -134,7 +134,7 @@ "packages/app/src/components/prompt-input-v2.tsx": "a37d61cd44e630100758c3de3dbf8cf7e0d4d0e85dc54e6bb7bf38290ece4468", "packages/app/src/components/prompt-input.tsx": "c127f81e2c0de4bdbc21a349473df39c623d8d7e2df8df0c03ea60bbef101a25", "packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294", - "packages/app/src/components/question-inbox.tsx": "9fca0c7474de56198ba6bfb12aeddc30e1526fdde1911839189062df12e1cd22", + "packages/app/src/components/question-inbox.tsx": "5ce03a30b7110092ddffccf3bf38e6c1b1a79624f378c819be7019725c96478a", "packages/app/src/components/remote-create-arm.test.ts": "388132209e16b6f00d79ac7c700f8c2bfb5f80343eee34cee73451b32842d3f2", "packages/app/src/components/remote-create-arm.ts": "49cb5f89f041ee5d555a348ec5bbbd9709d448752987c491f63376b02522db36", "packages/app/src/components/remote-create-header.test.ts": "bfe81618f1ecf13a5e55e19242ce286ef40ab9eb95dae9a64fed03e46d7b1954", @@ -987,7 +987,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-13T20:31:28.693Z", + "extracted_at": "2026-10-13T20:33:10.488Z", "per_package": { "packages/app": { "A": 247, diff --git a/packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx b/packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx index ea3112dc..7d159a54 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/question-inbox.tsx @@ -121,7 +121,7 @@ function InboxEntryCard(props: { entry: InboxEntry; disabled: boolean }) { type="button" data-slot="question-inbox-option" data-picked={draft.answers[index()]?.includes(option.label) ? "true" : undefined} - class="rounded-[var(--radius-md)] border border-border-weak-base px-2 py-1 text-left text-12-regular text-text-base transition-colors hover:border-border-strong-base" + class="cursor-pointer rounded-[var(--radius-md)] border border-border-weak-base px-2 py-1 text-left text-12-regular text-text-base transition-colors hover:border-border-strong-base focus-visible:border-border-focus-base focus-visible:outline-none" classList={{ "border-border-strong-base bg-[var(--accent-fill-soft)]": draft.answers[index()]?.includes(option.label) === true, @@ -154,7 +154,7 @@ function InboxEntryCard(props: { entry: InboxEntry; disabled: boolean }) {
, @@ -54,3 +55,33 @@ export function useSessionTabAvatarState( // they were both just `unread`. return { unread, loading, needsAttention, hasError } } + +// amicode#1787 (S1): the session list row's status chip. READ-ONLY derivation +// over the per-server SHARED ServerSession store — the same `data` leaves the +// S2 question inbox derives from (question), the #1617/#1637 floors already +// guard (session_working / session_status), and the existing re-seed seams +// (connected edge, sync-gap frame, visibility-gated self-heal) keep current. +// No new state, no polling; a session with no evidence derives undefined and +// the row renders no chip (#1768 honest absence). +export function useSessionListChipState( + server: Accessor, + sessionId: Accessor, + lastActivityAt: Accessor, +) { + const global = useGlobal() + const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) + const sync = createMemo(() => { + const conn = connection() + if (conn) return global.ensureServerCtx(conn).sync + }) + return createMemo(() => { + const serverSync = sync() + if (!serverSync) return undefined + return deriveSessionListChip({ + source: serverSync.session.data, + sessionID: sessionId(), + lastActivityAt: lastActivityAt(), + now: Date.now(), + }) + }) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.test.ts new file mode 100644 index 00000000..62ef28a3 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import type { QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { deriveSessionListChip, sessionListChipLabel } from "./session-list-chip" + +const questionRow = (id: string): QuestionRequest => ({ id, sessionID: "s2", questions: [] }) + +// amicode#1787 (S1) — status chips on the session list: the tri-state +// derivation over the shared rows (#1768's busy/idle/omitted semantics on the +// ServerSession store's leaves — the same question rows the S2 inbox reads, so +// "waiting on question" means the same thing on both surfaces). +describe("amicode#1787 (S1) — session list chip derivation", () => { + test("a busy shared row reads as a running chip", () => { + const chip = deriveSessionListChip({ + source: { + session_status: { s1: { type: "busy" } }, + question: {}, + }, + sessionID: "s1", + now: 1_000_000, + }) + expect(chip).toEqual({ kind: "running", retry: false }) + }) + + test("a pending question row reads as waiting on question — the same rows the S2 inbox lists", () => { + const chip = deriveSessionListChip({ + source: { session_status: { s2: { type: "busy" } }, question: { s2: [questionRow("q1")] } }, + sessionID: "s2", + now: 1_000_000, + }) + // A busy turn blocked on a question is WAITING ON THE QUESTION — the + // actionable state wins over running. + expect(chip).toEqual({ kind: "waiting-on-question" }) + }) + + test("an explicit idle row reads as idle with age (label formats the age)", () => { + const now = 1_000_000_000 + const idleChip = (ageMs: number | undefined) => + deriveSessionListChip({ + source: { session_status: { s3: { type: "idle" } }, question: {} }, + sessionID: "s3", + lastActivityAt: ageMs === undefined ? undefined : now - ageMs, + now, + }) + expect(idleChip(40 * 60_000)).toEqual({ kind: "idle", ageMs: 40 * 60_000 }) + expect(sessionListChipLabel(idleChip(40 * 60_000)!)).toBe("idle 40m") + expect(sessionListChipLabel(idleChip(2 * 3_600_000)!)).toBe("idle 2h") + expect(sessionListChipLabel(idleChip(3 * 24 * 3_600_000)!)).toBe("idle 3d") + expect(sessionListChipLabel(idleChip(30_000)!)).toBe("idle") + expect(sessionListChipLabel(idleChip(undefined)!)).toBe("idle") + }) + + test("a retry status reads as a running chip with the retrying hint", () => { + const chip = deriveSessionListChip({ + source: { + session_status: { s4: { type: "retry", attempt: 1, message: "provider 429", next: 1_100_000 } }, + question: {}, + }, + sessionID: "s4", + now: 1_000_000, + }) + expect(chip).toEqual({ kind: "running", retry: true }) + expect(sessionListChipLabel(chip!)).toBe("retrying") + }) + + test("a session with NO status evidence is OMITTED: no chip, never a lying idle", () => { + const chip = deriveSessionListChip({ + source: { session_status: {}, question: {} }, + sessionID: "s5", + lastActivityAt: 1_000_000 - 3_600_000, + now: 1_000_000, + }) + // #1768 rhyme: absence keeps the current value — at the chip level, no + // evidence renders nothing (an idle chip would assert idle as fact). + expect(chip).toBeUndefined() + }) + + test("the working floor alone reads as running (the #1617/#1637 floors feed the same chip)", () => { + const chip = deriveSessionListChip({ + source: { session_status: {}, question: {}, session_working: (id) => id === "s6" }, + sessionID: "s6", + now: 1_000_000, + }) + expect(chip).toEqual({ kind: "running", retry: false }) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.tsx new file mode 100644 index 00000000..ebf9802a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.tsx @@ -0,0 +1,102 @@ +// amicode#1787 (S1) — status chips on the session list. Render-only: the chip +// DERIVES from the ServerSession store's shared-row leaves (busy/heartbeat +// status + the question rows), the same leaves the #1617/#1637 floors already +// guard and the S2 question inbox already derives from — so "waiting on +// question" means the same thing on the list and in the inbox. +// +// The #1768 rhyme (peer-fleet session-status, one data plane over): tri-state +// busy/idle/OMITTED — a session with NO status evidence renders NO chip +// (absence never reads as idle), an unreachable owner's omission keeps the +// current chip (the store's recency-guarded reconcile owns that floor), and +// the row self-corrects when an explicit frame lands (owner returns / the +// shared row TTLs out and a reconcile read reports idle). No polling: the chip +// re-derives reactively off the store, which the event flow and the existing +// re-seed seams (connected edge, sync-gap frame, visibility-gated self-heal) +// already keep current. +import type { QuestionRequest, SessionStatus } from "@opencode-ai/sdk/v2/client" +import { Show } from "solid-js" + +/** The chip a session list row shows. */ +export type SessionListChip = + | { kind: "running"; retry: boolean } + | { kind: "waiting-on-question" } + | { kind: "idle"; ageMs: number | undefined } + +/** The shared-row source: the ServerSession store's own leaves. `session_working` + * is optional so a plain record (a status map without the working floor) still + * derives — the store passes its method, tests pass the store itself. */ +export type SessionListChipSource = { + session_status: Record + question: Record + session_working?: (sessionID: string) => boolean +} + +/** Derive one row's chip. NO status row + no question + no working floor is + * the OMITTED state: it returns undefined and the row renders no chip — + * absence never reads as idle. */ +export function deriveSessionListChip(input: { + source: SessionListChipSource + sessionID: string + /** The session record's last-activity time (session.time.updated). */ + lastActivityAt?: number + now: number +}): SessionListChip | undefined { + const { source, sessionID } = input + // The question rows are the same rows the S2 inbox lists — one definition + // of "waiting on question" across both surfaces. + if ((source.question[sessionID]?.length ?? 0) > 0) return { kind: "waiting-on-question" } + const status = source.session_status[sessionID] + const working = source.session_working?.(sessionID) ?? false + if (working || (status?.type ?? "idle") !== "idle") return { kind: "running", retry: status?.type === "retry" } + if (status === undefined) return undefined // omitted — honest absence + return { + kind: "idle", + ageMs: input.lastActivityAt === undefined ? undefined : Math.max(0, input.now - input.lastActivityAt), + } +} + +/** The chip's user-facing text. English-only by design, matching the tab + * status dot's labels (session-tab-status.tsx). */ +export function sessionListChipLabel(chip: SessionListChip): string { + if (chip.kind === "waiting-on-question") return "waiting on question" + if (chip.kind === "running") return chip.retry ? "retrying" : "running" + if (chip.ageMs === undefined) return "idle" + if (chip.ageMs < 60_000) return "idle" + if (chip.ageMs < 3_600_000) return `idle ${Math.floor(chip.ageMs / 60_000)}m` + if (chip.ageMs < 24 * 3_600_000) return `idle ${Math.floor(chip.ageMs / 3_600_000)}h` + return `idle ${Math.floor(chip.ageMs / (24 * 3_600_000))}d` +} + +// ── the chip mark ──────────────────────────────────────────────────────────── +// Reads like the neighboring session-control-chip / status dot: a small +// muted chip with a colored dot — the dot's tone reuses the SAME status +// palette as SessionTabStatusDot (running yellow, needs-you green, idle grey), +// so one session reads identically on the tab, in the dock, and on the list. + +const CHIP_TONE: Record = { + running: "var(--status-running)", + "waiting-on-question": "var(--status-done)", + idle: "var(--status-idle)", +} + +export function SessionListStatusChip(props: { chip: SessionListChip | undefined }) { + return ( + + {(chip) => { + const label = sessionListChipLabel(chip) + return ( + + + ) + }} + + ) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.wiring.test.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.wiring.test.tsx new file mode 100644 index 00000000..cff1168f --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.wiring.test.tsx @@ -0,0 +1,64 @@ +// amicode#1787 (S1) — the session list chip's WIRING, source-asserted per the +// repo's component-source-assertion pattern (session-header.test.tsx, +// home-archive.test.ts): the DECISION logic is pure + unit-tested in +// session-list-chip.tsx and the store semantics in server-session.test.ts; +// here we assert the SolidJS surface binds them — the chip renders on the home +// session row, rides the shared ServerSession store (the same question rows +// the S2 inbox reads), and is render-only (no timers, no fetches, no writes). +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" + +const homeSource = readFileSync(resolve(__dirname, "../home.tsx"), "utf8") +const chipSource = readFileSync(resolve(__dirname, "session-list-chip.tsx"), "utf8") +const avatarSource = readFileSync(resolve(__dirname, "project-avatar-state.ts"), "utf8") + +function homeSessionRowBody(src: string): string { + const start = src.indexOf("function HomeSessionRow(props: {") + expect(start).toBeGreaterThan(-1) + const next = src.indexOf("function HomeSessionSkeleton", start) + expect(next).toBeGreaterThan(start) + return src.slice(start, next) +} + +describe("amicode#1787 — the session list chip renders on the home session row", () => { + test("HomeSessionRow derives and renders the chip off the shared store", () => { + const body = homeSessionRowBody(homeSource) + expect(body).toContain("useSessionListChipState") + expect(body).toContain(" { + // The hook hands the derivation the per-server shared store's own `data` + // (session_working / session_status / question) — not a per-directory + // child-store view — so "waiting on question" is the SAME question map the + // inbox's collectQuestionInbox walks (layout-new.tsx wires the inbox to + // `serverSync().session.data.question`). + expect(avatarSource).toContain("source: serverSync.session.data") + }) + + test("the chip component renders the tri-state with an honest-absence fallback (no chip when omitted)", () => { + expect(chipSource).toContain('data-slot="session-list-chip"') + expect(chipSource).toContain("data-kind={chip.kind}") + // The omitted state renders NOTHING — absence never reads as idle. + expect(chipSource).toContain("props.chip === undefined") + }) + + test("render-only: the chip adds no polling, no routes, no state writes (AC3/AC4)", () => { + for (const forbidden of [ + "setInterval", + "setTimeout", + "requestAnimationFrame", + "fetch(", + "amicodeGet", + "reconcileStatuses", + ".set(", + "produce(", + ]) + expect(chipSource).not.toContain(forbidden) + // The hook reads only — no store mutation from the chip lane. + const hook = avatarSource.slice(avatarSource.indexOf("export function useSessionListChipState")) + for (const forbidden of ["set(", "produce(", "reconcileStatuses", "setInterval"]) + expect(hook).not.toContain(forbidden) + }) +}) From b1e30e33706d1e4158e7cb7c055897e0ce60c9e9 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:51:15 -0400 Subject: [PATCH 09/11] chore(app-bundle): refresh the manifest for the #1787 status-chip files (3 added, 3 re-hashed) --- packages/app-bundle/manifest.json | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index b64421e3..1908f302 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -200,7 +200,7 @@ "packages/app/src/context/server-sdk.tsx": "bd22cc233ba34c42f0ff7a73047b536d1a7882a6783fbaf98fd3163fde12010c", "packages/app/src/context/server-session-child-active.test.ts": "de8c467f67f88b6cce351c1805ec90728cf238eb1ef32f39ffdbbf3de4dcec22", "packages/app/src/context/server-session-orphan-part.test.ts": "1d635374c0bfff25a01825061aabac7334e5cdaedca2dab9f3d9467ff543caea", - "packages/app/src/context/server-session.test.ts": "b0bd5ef87b81154b938657ffbc335709799b75b0e146178e6e912088eb42d690", + "packages/app/src/context/server-session.test.ts": "31b14c85a843eb172899f456566ad7ab7d07a22eaf8b31d29a22e255e95cb96b", "packages/app/src/context/server-session.ts": "d309c8caad0b444d911a2e63be012d9e269b1791ffbfbb55218b0d631fd684fe", "packages/app/src/context/server-sync.tsx": "cdc7057c2188d79c5e586f1be05431cb97880ed3fd6b8f3980268db0fa011e34", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", @@ -255,7 +255,7 @@ "packages/app/src/pages/home-projects.ts": "d55124578dc839b98e2084b488d818e30e4a1abf1ae264499b83e403c2aabca7", "packages/app/src/pages/home-session-archive.test.ts": "e8f1126287a3e9daa455efcc378e5d72aebdd092158bf8526f7779669b72e850", "packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5", - "packages/app/src/pages/home.tsx": "3e33dca2d17a371daa2f02509eb2730ad94068cf60584c428e22d69056582c05", + "packages/app/src/pages/home.tsx": "88ff338bb202dbdbb50b3948d859fc4240547e7a7b059a6f7c1b5b97bfb093c6", "packages/app/src/pages/layout-archive.test.ts": "eed98374a2bea6c692d7d13116db9287ee61ff526b90952895bfb0f61afbfb2d", "packages/app/src/pages/layout-new.tsx": "ead0e4a46f5ca4e9515760ce40d21d0114243c490795d7794570d6e6977f592e", "packages/app/src/pages/layout.tsx": "90306f1691cf22b35627e063ce6401ea0ed2f98e12db022861f403de2d0f2d08", @@ -765,7 +765,10 @@ "packages/app/src/pages/layout/helpers.ts": "b9b9bd551833390d9dcb7e2bc56de77abdef770d7d786f732930ed2bb6b8f4d6", "packages/app/src/pages/layout/notification-toasts.test.ts": "6a95bd3dccb03be894b5d2adb4bc1f7531a9ec704e4d0eae1eba4ec89bbc9846", "packages/app/src/pages/layout/notification-toasts.ts": "11256e25f2bcc20d3520e81aa50b53db2a7f8dea8047b17e99deb1f9f061e43d", - "packages/app/src/pages/layout/project-avatar-state.ts": "7aadb235d27dc45799411c724f0fd1282af82d091cbcefb0344deb7c5d0659d4", + "packages/app/src/pages/layout/project-avatar-state.ts": "45ca696114049e23f5c5f95745b3f7303a35d847bd186c426618cf88f355865d", + "packages/app/src/pages/layout/session-list-chip.test.ts": "ebda802582ebd274e8c7c4c20ca962e8f699fce482202d3af885bd704104bf46", + "packages/app/src/pages/layout/session-list-chip.tsx": "38bd9f8950fb71d71c6c49ac191fd68a42d29b891887b87915f0a53f4d339dbb", + "packages/app/src/pages/layout/session-list-chip.wiring.test.tsx": "3f19399ea11aabbd6c11624ebc0597301e05cfc033da543c8231a21839880997", "packages/app/src/pages/layout/session-tab-avatar.tsx": "89e83e239d513f91b31f16473be531eaf82768cefe637ea5f10d752cea4ef887", "packages/app/src/pages/layout/session-tab-status.test.ts": "d6e62365948450c0acda628bc5d03233e5e6ead69649c2d2fb7e20c906007e74", "packages/app/src/pages/layout/session-tab-status.tsx": "0960d24e581e5cdd38d1bea5e745039542e5ab5268f5540cf8fa87d84717922b", @@ -987,10 +990,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-13T20:33:10.488Z", + "extracted_at": "2026-10-13T20:51:10.374Z", "per_package": { "packages/app": { - "A": 247, + "A": 250, "M": 159, "D": 0 }, @@ -1036,7 +1039,7 @@ } }, "counts": { - "overlay_total": 965, + "overlay_total": 968, "deletions": 7, "server_coupled": 0 }, @@ -1799,6 +1802,9 @@ "packages/app/src/pages/layout/notification-toasts.test.ts": "A", "packages/app/src/pages/layout/notification-toasts.ts": "A", "packages/app/src/pages/layout/project-avatar-state.ts": "M", + "packages/app/src/pages/layout/session-list-chip.test.ts": "A", + "packages/app/src/pages/layout/session-list-chip.tsx": "A", + "packages/app/src/pages/layout/session-list-chip.wiring.test.tsx": "A", "packages/app/src/pages/layout/session-tab-avatar.tsx": "M", "packages/app/src/pages/layout/session-tab-status.test.ts": "A", "packages/app/src/pages/layout/session-tab-status.tsx": "A", From 7fdf98615c33acb7fba20086c30b7590d5344aa1 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 16:52:55 -0400 Subject: [PATCH 10/11] =?UTF-8?q?fix(app):=20drop=20the=20chip's=20aria=20?= =?UTF-8?q?live=20role=20=E2=80=94=20one=20status=20chip=20per=20row,=20no?= =?UTF-8?q?t=20a=20live=20region=20(#1787)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app-bundle/manifest.json | 4 ++-- .../packages/app/src/pages/layout/session-list-chip.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 1908f302..8ce62b4a 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -767,7 +767,7 @@ "packages/app/src/pages/layout/notification-toasts.ts": "11256e25f2bcc20d3520e81aa50b53db2a7f8dea8047b17e99deb1f9f061e43d", "packages/app/src/pages/layout/project-avatar-state.ts": "45ca696114049e23f5c5f95745b3f7303a35d847bd186c426618cf88f355865d", "packages/app/src/pages/layout/session-list-chip.test.ts": "ebda802582ebd274e8c7c4c20ca962e8f699fce482202d3af885bd704104bf46", - "packages/app/src/pages/layout/session-list-chip.tsx": "38bd9f8950fb71d71c6c49ac191fd68a42d29b891887b87915f0a53f4d339dbb", + "packages/app/src/pages/layout/session-list-chip.tsx": "62373670362fd78e944753b2839820411b5c67dea369dec6a39c5d03df827b16", "packages/app/src/pages/layout/session-list-chip.wiring.test.tsx": "3f19399ea11aabbd6c11624ebc0597301e05cfc033da543c8231a21839880997", "packages/app/src/pages/layout/session-tab-avatar.tsx": "89e83e239d513f91b31f16473be531eaf82768cefe637ea5f10d752cea4ef887", "packages/app/src/pages/layout/session-tab-status.test.ts": "d6e62365948450c0acda628bc5d03233e5e6ead69649c2d2fb7e20c906007e74", @@ -990,7 +990,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-13T20:51:10.374Z", + "extracted_at": "2026-10-13T20:52:48.628Z", "per_package": { "packages/app": { "A": 250, diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.tsx index ebf9802a..7a0e9b87 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout/session-list-chip.tsx @@ -89,7 +89,6 @@ export function SessionListStatusChip(props: { chip: SessionListChip | undefined data-slot="session-list-chip" data-kind={chip.kind} class="shrink-0 inline-flex items-center gap-1 rounded-sm px-1 py-0.5 text-[10px] leading-none text-v2-text-text-faint bg-v2-background-bg-layer-02" - role="status" title={label} >