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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "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",
Expand All @@ -157,16 +158,16 @@
"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",
"packages/app/src/components/titlebar-tab-nav.tsx": "0d5e11f63006e87fc42a4682c481203f50622677b07a8ff87a3348613cdf3674",
"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",
Expand All @@ -193,11 +194,13 @@
"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",
"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",
Expand Down Expand Up @@ -252,9 +255,9 @@
"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": "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",
Expand Down Expand Up @@ -762,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": "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",
"packages/app/src/pages/layout/session-tab-status.tsx": "0960d24e581e5cdd38d1bea5e745039542e5ab5268f5540cf8fa87d84717922b",
Expand Down Expand Up @@ -984,10 +990,10 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
"extracted_at": "2026-10-13T15:58:01.063Z",
"extracted_at": "2026-10-13T20:52:48.628Z",
"per_package": {
"packages/app": {
"A": 244,
"A": 250,
"M": 159,
"D": 0
},
Expand Down Expand Up @@ -1033,7 +1039,7 @@
}
},
"counts": {
"overlay_total": 962,
"overlay_total": 968,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -1164,6 +1170,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",
Expand Down Expand Up @@ -1223,6 +1230,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",
Expand Down Expand Up @@ -1793,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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<span class="flex shrink-0" data-tour-target="inbox">
<TooltipV2 placement="bottom" value={count() > 0 ? `Question inbox — ${count()} pending` : "Question inbox"} class="shrink-0">
<span class="relative flex shrink-0">
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<IconV2 name="help" />}
state={inbox.opened() ? "pressed" : undefined}
onClick={inbox.toggle}
aria-label="Question inbox"
/>
<Show when={count() > 0}>
{/* the count chip is black-on-yellow (the accent's only light-mode legibility) */}
<span class="pointer-events-none absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--accent)] px-1 text-10-regular font-[600] text-[var(--accent-ink)] tabular-nums">
{count() > 99 ? "99+" : count()}
</span>
</Show>
</span>
</TooltipV2>
</span>
)
}

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 (
<div
data-component="question-inbox-entry"
data-entry-id={props.entry.id}
data-stale={props.entry.stale ? "true" : undefined}
class="flex flex-col gap-2 rounded-[var(--radius-lg)] border border-border-weak-base bg-[var(--v2-background-bg-layer-01)] p-3"
>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 text-12-medium text-text-base truncate">{sessionLabel()}</div>
<div class="flex shrink-0 items-center gap-1.5">
<Show when={props.entry.stale}>
<span class="text-10-regular text-text-faint">stale · {ageText(props.entry.ageMs)}</span>
</Show>
<span class="text-10-regular text-text-faint tabular-nums">waiting {ageText(Date.now() - props.entry.askedAt)}</span>
</div>
</div>
<For each={props.entry.request.questions}>
{(question, index) => (
<div class="flex flex-col gap-1.5">
<div class="text-12-regular text-text-base">{question.question}</div>
<div class="flex flex-wrap gap-1.5">
<For each={question.options}>
{(option) => (
<button
type="button"
data-slot="question-inbox-option"
data-picked={draft.answers[index()]?.includes(option.label) ? "true" : undefined}
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,
}}
disabled={props.disabled}
onClick={() => pick(index(), option.label, question.multiple === true)}
>
{option.label}
</button>
)}
</For>
<Show when={question.options.length === 0 || question.custom !== false}>
<input
data-slot="question-inbox-custom"
class="min-w-0 flex-1 rounded-[var(--radius-md)] border border-border-weak-base bg-[var(--v2-background-bg-layer-02)] px-2 py-1 text-12-regular text-text-base outline-none focus-visible:border-border-focus-base"
placeholder={question.default || "Type your answer"}
value={draft.answers[index()]?.[0] ?? ""}
disabled={props.disabled}
onInput={(event) => {
const text = event.currentTarget.value.trim()
setDraft("answers", index(), text ? [text] : undefined)
}}
/>
</Show>
</div>
</div>
)}
</For>
<div class="flex justify-end">
<button
type="button"
data-slot="question-inbox-submit"
class="cursor-pointer rounded-[var(--radius-md)] bg-[var(--accent)] px-3 py-1 text-12-medium text-[var(--accent-ink)] transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-1 focus-visible:outline-border-focus-base disabled:cursor-default disabled:opacity-60"
disabled={props.disabled || !ready()}
onClick={submit}
>
Answer
</button>
</div>
</div>
)
}

/** 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 (
<Show when={inbox.opened()}>
<aside
data-component="amico-question-inbox-panel"
aria-label="Question inbox"
class="fixed bottom-2 left-18 top-12 z-40 flex w-[360px] max-w-[85vw] flex-col overflow-hidden rounded-xl border border-border-weaker-base bg-background-base shadow-[var(--v2-elevation-raised)]"
>
<div class="flex items-center justify-between gap-2 border-b border-border-weaker-base px-3 py-2">
<div class="flex items-center gap-1.5 text-12-medium text-text-base">
{/* the rail's one accent rule: solid yellow chips carry near-black ink */}
<span class="text-11-regular text-text-faint">AMICO</span>
<span>·</span>
<span>Question inbox</span>
</div>
<IconButtonV2
type="button"
variant="ghost-muted"
size="normal"
icon={<IconV2 name="close" />}
onClick={inbox.close}
aria-label="Close question inbox"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto p-2">
<Show
when={inbox.entries().length > 0}
fallback={<div class="px-2 py-8 text-center text-12-regular text-text-weak">No pending questions.</div>}
>
<div class="flex flex-col gap-2">
<For each={inbox.entries()}>
{(entry) => <InboxEntryCard entry={entry} disabled={inbox.pending(entry.id)} />}
</For>
</div>
</Show>
</div>
</aside>
</Show>
)
}
Loading
Loading