diff --git a/packages/app/e2e/regression/btw-sidebar.spec.ts b/packages/app/e2e/regression/btw-sidebar.spec.ts index 208b1e870e84..43a345a8013d 100644 --- a/packages/app/e2e/regression/btw-sidebar.spec.ts +++ b/packages/app/e2e/regression/btw-sidebar.spec.ts @@ -5,11 +5,149 @@ import { expectSessionTitle } from "../utils/waits" test.use({ viewport: { width: 1440, height: 900 } }) +test("keeps separate one-shot /btw answers and restores them after reload", async ({ page }, testInfo) => { + const prompts: unknown[] = [] + const generations: { sessionID: string; prompt: string }[] = [] + + const { editor } = await openSession(page, { + name: "BtwQuestions", + onPrompt: (input) => prompts.push(input), + generate: (input) => { + generations.push(input) + + return { + text: input.prompt.includes("second question") + ? "Second answer" + : `First answer\n\n${"A detailed explanation of the first question.\n\n".repeat(100)}End of first answer`, + } + }, + }) + + const panel = page.locator('[data-slot="session-btw-panel"]') + await editor.fill("/btw first question") + await editor.press("Enter") + await expect(panel.getByText("First answer", { exact: true })).toBeVisible() + await expect(panel.getByRole("textbox")).toHaveCount(0) + await expect(panel.getByRole("button", { name: "Send", exact: true })).toHaveCount(0) + await panel.getByText("End of first answer", { exact: true }).scrollIntoViewIfNeeded() + await expect(panel.getByText("End of first answer", { exact: true })).toBeInViewport() + await editor.fill("/btw second question") + await editor.press("Enter") + await expect(panel.getByText("Second answer", { exact: true })).toBeVisible() + const first = page.getByRole("tab", { name: "first question", exact: true }) + const second = page.getByRole("tab", { name: "second question", exact: true }) + await expect(first).toBeVisible() + await expect(second).toHaveAttribute("data-selected", "") + await page.screenshot({ path: testInfo.outputPath("separate-tabs.png") }) + expect(generations).toHaveLength(2) + expect(generations[1]?.prompt).not.toContain("first question") + expect(generations[1]?.prompt).not.toContain("First answer") + + await page.reload() + await expect(first).toBeVisible() + await expect(second).toHaveAttribute("data-selected", "") + await expect(panel.getByText("Second answer", { exact: true })).toBeVisible() + await first.click() + await expect(panel.getByText("First answer", { exact: true })).toBeVisible() + await expect(panel.getByText("Second answer", { exact: true })).toHaveCount(0) + await expect(panel.getByRole("textbox")).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath("restored-answer.png") }) + + // Closing a tab forgets its question; the other tab and its answer survive a reload. + await first.click({ button: "middle" }) + await expect(first).toHaveCount(0) + await expect(second).toHaveAttribute("data-selected", "") + await expect(panel.getByText("Second answer", { exact: true })).toBeVisible() + await page.reload() + await expect(second).toHaveAttribute("data-selected", "") + await expect(panel.getByText("Second answer", { exact: true })).toBeVisible() + await expect(first).toHaveCount(0) + expect(generations).toHaveLength(2) + expect(prompts).toEqual([]) +}) + +test("isolates concurrent side questions and makes an interrupted reload retryable", async ({ page }) => { + const held = Promise.withResolvers() + const attempts: string[] = [] + const prompts: unknown[] = [] + + const { editor } = await openSession(page, { + name: "BtwConcurrent", + onPrompt: (input) => prompts.push(input), + generate: async (input) => { + attempts.push(input.prompt) + + if (input.prompt.includes("slow question") && attempts.length === 1) { + await held.promise + + return { text: "Abandoned answer" } + } + + return { text: input.prompt.includes("fast question") ? "Fast answer" : "Retried answer" } + }, + }) + + const panel = page.locator('[data-slot="session-btw-panel"]') + await editor.fill("/btw slow question") + await editor.press("Enter") + await expect(panel.getByRole("status")).toContainText("Working") + await editor.fill("/btw fast question") + await editor.press("Enter") + await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible() + await page.getByRole("tab", { name: "slow question", exact: true }).click() + await expect(panel.getByRole("status")).toContainText("Working") + await expect(panel.getByText("Fast answer", { exact: true })).toHaveCount(0) + + await page.reload() + await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible() + await expect(panel.getByRole("status")).toHaveCount(0) + expect(attempts).toHaveLength(2) + await panel.getByRole("button", { name: "Retry", exact: true }).click() + await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible() + expect(attempts).toHaveLength(3) + expect(attempts[2]).toContain("slow question") + expect(attempts[2]).not.toContain("fast question") + held.resolve() + await page.getByRole("tab", { name: "fast question", exact: true }).click() + await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible() + await page.getByRole("tab", { name: "slow question", exact: true }).click() + await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible() + await expect(panel.getByText("Abandoned answer", { exact: true })).toHaveCount(0) + await expect(panel.getByRole("textbox")).toHaveCount(0) + expect(prompts).toEqual([]) +}) + +test("keeps many long-titled side tabs usable at a narrow desktop width", async ({ page }, testInfo) => { + await page.setViewportSize({ width: 900, height: 700 }) + + const { editor } = await openSession(page, { + name: "BtwOverflow", + generate: () => ({ text: "Saved overflow answer" }), + }) + + const panel = page.locator('[data-slot="session-btw-panel"]') + const questions = Array.from({ length: 12 }, (_, index) => `Side ${index + 1}: ${"long-title-".repeat(30)}`) + + for (const question of questions) { + await editor.fill(`/btw ${question}`) + await editor.press("Enter") + await expect(page.getByRole("tab", { name: question, exact: true })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible() + } + + await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12) + await expect(panel.getByRole("textbox")).toHaveCount(0) + await page.reload() + await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12) + await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible() + await page.screenshot({ path: testInfo.outputPath("narrow-many-tabs.png") }) +}) + test("answers /btw in the side panel without admitting a prompt", async ({ page }) => { const generations: { sessionID: string; prompt: string }[] = [] const prompts: unknown[] = [] const generated = Promise.withResolvers() - const abandoned = Promise.withResolvers() + const leftBehind = Promise.withResolvers() const main = { id: "ses_btw_sidebar", title: "Side question session" } const other = { id: "ses_btw_sidebar_other", title: "Other side question session" } const ownerWarnings: string[] = [] @@ -28,7 +166,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." } if (input.prompt.includes("left behind")) { - await abandoned.promise + await leftBehind.promise return { text: "This answer arrived after the user left." } } @@ -47,7 +185,6 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page await suggestion.click() await expect(editor).toHaveText("/btw ") await editor.press("Enter") - const panel = page.locator('[data-slot="session-btw-panel"]') await expect(panel).toBeHidden() await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible() @@ -59,7 +196,10 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page await expect(panel).toBeVisible() await expect(panel.getByRole("textbox")).toHaveCount(0) await expect(panel.getByRole("status")).toContainText("Working") - await expect(page.getByRole("tab", { name: "/btw" })).toHaveAttribute("data-selected", "") + await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute( + "data-selected", + "", + ) generated.resolve() await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible() await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible() @@ -75,13 +215,11 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page await editor.fill("/btw what belongs here?") await editor.press("Enter") await expect(panel.getByText("other session", { exact: false })).toBeVisible() - await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click() await expectSessionTitle(page, main.title) await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible() await expect(panel.getByText("other session", { exact: false })).toHaveCount(0) - // Leaving a session abandons its in-flight question, so it reads as failed on return. await editor.fill("/btw is this question left behind?") await editor.press("Enter") await expect(panel.getByRole("status")).toContainText("Working") @@ -89,13 +227,35 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page await expectSessionTitle(page, other.title) await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click() await expectSessionTitle(page, main.title) - await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible() - await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible() - abandoned.resolve() + // Leaving the session does not abandon its question: it is still working, and its answer lands once it arrives. + await expect(panel.getByRole("status")).toContainText("Working") + await page.getByRole("button", { name: "Home", exact: true }).click() + await expect(page).toHaveURL(/\/$/) + leftBehind.resolve() + await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click() + await expectSessionTitle(page, main.title) + await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible() + await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toHaveCount(0) + expect(generations.filter((item) => item.prompt.includes("left behind"))).toHaveLength(1) await page.reload() await expectSessionTitle(page, main.title) - await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0) - await expect(panel).toHaveCount(0) + await expect(page.getByRole("tab", { name: "is this question left behind?", exact: true })).toHaveAttribute( + "data-selected", + "", + ) + await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible() + await expect(panel.getByRole("status")).toHaveCount(0) + await page.getByRole("tab", { name: "how does the retry loop work?", exact: true }).click() + await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible() + await page.getByRole("button", { name: "Home", exact: true }).click() + await expect(page).toHaveURL(/\/$/) + await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click() + await expectSessionTitle(page, main.title) + await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute( + "data-selected", + "", + ) + await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible() expect(ownerWarnings).toEqual([]) }) diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 9c98efbc7c42..83b5077c898d 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -145,7 +145,7 @@ function session(input: { } describe("Composer submission", () => { - test("runs a client argument command without admitting it to the session", async () => { + test("applies the selection and runs a client argument command without admitting it to the session", async () => { const state = createMemoryComposerState().capture() const image = { @@ -169,7 +169,8 @@ describe("Composer submission", () => { } }).submit(new Event("submit")) - expect(calls).toEqual(["btw"]) + // Client commands such as /btw generate with the session's model, so the composer's selection commits first. + expect(calls).toEqual(["switch-agent", "switch-model", "btw"]) expect(history).toEqual([]) expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }, image]) expect(state.context.items()).toHaveLength(1) diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 8f7301fd377d..55b279163692 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -66,6 +66,16 @@ export function createComposerSubmit(input: ComposerSubmitInput) { submitting.add(input.adapter.state) try { + // Client commands such as /btw answer with the session's model, so apply the composer's selection first, + // following the same steer rule as server commands. + const selection = currentSelection(input) + + if (input.adapter.kind === "active-session" && selection && (input.delivery?.(false) ?? "steer") === "steer") + await applySelection( + input.adapter.session(), + selection, + input.adapter.controls().model.selection.trackSessionCommit, + ) clearClientCommand(input, prompt) await clientCommand() } catch (error) { @@ -272,25 +282,22 @@ function readSubmission( if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return - const controls = input.adapter.controls() - const model = controls.model.selection.current() - const agent = controls.agents.current + const selection = currentSelection(input) - if (!model || !agent) { + if (!selection) { input.notify.missingSelection() return } - const variant = controls.model.selection.variant.current() const retry = input.adapter.state.retry.current() const retryID = retry && - retry.agent === agent && - retry.providerID === model.provider.id && - retry.modelID === model.id && - (retry.variant ?? "default") === (variant ?? "default") + retry.agent === selection.agent && + retry.providerID === selection.model.providerID && + retry.modelID === selection.model.modelID && + (retry.variant ?? "default") === (selection.variant ?? "default") ? retry.id : undefined @@ -301,15 +308,25 @@ function readSubmission( context, text, images, - selection: { - agent, - model: { modelID: model.id, providerID: model.provider.id }, - variant, - }, + selection, delivery: input.delivery?.(alternate) ?? "steer", } } +function currentSelection(input: ComposerSubmitInput): ComposerSelection | undefined { + const controls = input.adapter.controls() + const model = controls.model.selection.current() + const agent = controls.agents.current + + if (!model || !agent) return + + return { + agent, + model: { modelID: model.id, providerID: model.provider.id }, + variant: controls.model.selection.variant.current(), + } +} + function clearSubmission(input: ComposerSubmitInput, submission: ReturnType) { submission.clear() submission.target().mode.set("normal") diff --git a/packages/gui-extensions/src/btw/index.ts b/packages/gui-extensions/src/btw/index.ts index 2e61fc8502ac..c834901fad42 100644 --- a/packages/gui-extensions/src/btw/index.ts +++ b/packages/gui-extensions/src/btw/index.ts @@ -1,7 +1,18 @@ -import { Extension } from "../sdk" +import { Schema } from "effect" +import { Extension, Store } from "../sdk" import en from "./i18n/en" +const Chats = Schema.Struct({ + chats: Schema.Array( + Schema.Struct({ id: Schema.String, question: Schema.String, answer: Schema.optional(Schema.String) }), + ), +}) + export default Extension.define({ id: "btw", + stores: { + // Each open /btw tab's chat (its question and answer), kept until its tab closes. + chats: Store.session(Chats, { chats: [] }), + }, i18n: { en }, }) diff --git a/packages/gui-extensions/src/btw/model.ts b/packages/gui-extensions/src/btw/model.ts index e13f6d3116c6..86fbd722888a 100644 --- a/packages/gui-extensions/src/btw/model.ts +++ b/packages/gui-extensions/src/btw/model.ts @@ -1,4 +1,4 @@ -import { batch, onCleanup } from "solid-js" +import { batch, createRoot, getOwner, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { showToast } from "@opencode/ui/toast" import { createKeyed, type MountedSession, type SetupContext } from "../sdk" @@ -10,38 +10,58 @@ const instructions = [ "Do not call any tools and do not take any actions.", ].join(" ") -const empty = { - question: "", - answer: "", - error: false, - pending: false, -} - -/** Side questions per session. Window-local: a reload drops them, and with them the tab. */ +/** One-shot side questions per session, one tab each, stored until the tab closes. */ export function createBtw(ctx: SetupContext) { - const sessions = ctx.sessions - const layout = ctx.layout - const [states, setStates] = createStore>({}) - const requests = new Map() + const owner = getOwner() const controllers = new Map() + const [requests, setRequests] = createStore<{ pending: string[] }>({ pending: [] }) + const saved = (session: MountedSession) => ctx.stores.chats(session) - const stop = (key: string) => { - const controller = controllers.get(key) - - if (!controller) return - controller.abort() - controllers.delete(key) - - if (states[key]?.pending) setStates(key, { pending: false, error: true }) + const stop = (id: string) => { + controllers.get(id)?.abort() + controllers.delete(id) + setRequests("pending", (list) => list.filter((item) => item !== id)) } - // Leaving a session abandons its in-flight question; the same session moving to another directory does not. - createKeyed( - () => sessions.current()?.key, - (key) => onCleanup(() => stop(key)), - ) + // In-flight questions keep running when the user leaves their session and answer into its store; only a reload, + // closing the question's tab or disposing the extension abandons one, which leaves it retryable. onCleanup(() => Array.from(controllers.keys()).forEach(stop)) + const entry = (session: MountedSession, id: string) => saved(session).value?.chats.find((item) => item.id === id) + const pending = (id: string) => requests.pending.includes(id) + + const generate = (session: MountedSession, id: string) => { + const item = entry(session, id) + + if (!item || pending(id)) return + + const store = saved(session) + const controller = new AbortController() + controllers.set(id, controller) + setRequests("pending", (list) => [...list, id]) + + return ( + session.server.client.session + .generate( + { sessionID: session.id, prompt: [instructions, item.question].join("\n\n") }, + { signal: controller.signal }, + ) + .then((result) => { + if (ctx.signal.aborted || controller.signal.aborted) return + store.update((draft) => { + const chat = draft.chats.find((item) => item.id === id) + + if (chat) chat.answer = result.text.trim() + }) + }) + // A missing answer is the retry state; no transient error flag is stored. + .catch(() => undefined) + .finally(() => { + if (controllers.get(id) === controller) stop(id) + }) + ) + } + const ask = (value?: string) => { const question = value?.trim() @@ -51,54 +71,69 @@ export function createBtw(ctx: SetupContext) { return } - const session = sessions.current() + const session = ctx.sessions.current() if (!session?.id) return - const key = session.key - const request = (requests.get(key) ?? 0) + 1 - requests.set(key, request) - controllers.get(key)?.abort() - const controller = new AbortController() - controllers.set(key, controller) - // The tab lists only while its session has a question, and the host drops unlisted transient keys, so - // record the question in the same batch that opens the tab. - batch(() => { - setStates(key, { question, answer: "", error: false, pending: true }) - layout.open(`${ctx.id}:main`, session) + const store = saved(session) + const id = crypto.randomUUID() + + // Desktop storage loads asynchronously. Record the question and open its tab once the store has loaded, in one + // batch, because the host drops a transient tab its panel does not list. Leaving the session first cancels it. + return new Promise((resolve) => { + const controller = new AbortController() + const signal = AbortSignal.any([controller.signal, ctx.signal]) + + createRoot((dispose) => { + const done = () => { + signal.removeEventListener("abort", done) + dispose() + resolve() + } + + signal.addEventListener("abort", done, { once: true }) + createKeyed( + () => ctx.sessions.current()?.key === session.key, + () => onCleanup(() => controller.abort()), + ) + createKeyed( + () => store.ready(), + () => { + if (signal.aborted) return + batch(() => { + store.update((draft) => { + draft.chats.push({ id, question }) + }) + ctx.layout.open(`${ctx.id}:${id}`, session, { tab: "select" }) + }) + // Release the composer once the request starts, not when its answer arrives. + void generate(session, id) + done() + }, + ) + }, owner) }) - - return session.server.client.session - .generate( - { - sessionID: session.id, - prompt: [instructions, question].join("\n\n"), - }, - { signal: controller.signal }, - ) - .then((result) => { - if (controller.signal.aborted || requests.get(key) !== request) return - setStates(key, { answer: result.text.trim(), pending: false }) - }) - .catch(() => { - if (controller.signal.aborted || requests.get(key) !== request) return - setStates(key, { error: true, pending: false }) - }) - .finally(() => { - if (controllers.get(key) === controller) controllers.delete(key) - }) } - const state = (session: MountedSession) => states[session.key] - return { ask, - has: (session: MountedSession) => !!state(session), - answer: (session: MountedSession) => (state(session) ?? empty).answer, - error: (session: MountedSession) => (state(session) ?? empty).error, - pending: (session: MountedSession) => (state(session) ?? empty).pending, - question: (session: MountedSession) => (state(session) ?? empty).question, - retry: (session: MountedSession) => ask((state(session) ?? empty).question), + saved, + pending, + question: (session: MountedSession, id: string) => entry(session, id)?.question, + answer: (session: MountedSession, id: string) => entry(session, id)?.answer, + error: (session: MountedSession, id: string) => { + const item = entry(session, id) + + return !!item && item.answer === undefined && !pending(id) + }, + retry: generate, + /** Closing a tab forgets its question and answer. */ + remove: (session: MountedSession, id: string) => { + stop(id) + saved(session).update((draft) => { + draft.chats = draft.chats.filter((item) => item.id !== id) + }) + }, } } diff --git a/packages/gui-extensions/src/btw/panel.tsx b/packages/gui-extensions/src/btw/panel.tsx index 3af7b3f75155..151e13f1fc1c 100644 --- a/packages/gui-extensions/src/btw/panel.tsx +++ b/packages/gui-extensions/src/btw/panel.tsx @@ -10,10 +10,10 @@ import { Markdown } from "@opencode/session-ui/markdown" import { useExtension, type MountedSession } from "../sdk" import type { BtwModel } from "./model" -export default function SessionBtwPanel(props: { btw: BtwModel; session: MountedSession }) { +export default function SessionBtwPanel(props: { btw: BtwModel; session: MountedSession; id: string }) { const ctx = useExtension() const system = ctx.system - const answer = () => props.btw.answer(props.session) + const answer = () => props.btw.answer(props.session, props.id) // A new token for each answer, so the copied mark clears when the answer changes. const shown = createMemo(on(answer, () => ({}))) const [copiedAnswer, setCopiedAnswer] = createSignal() @@ -32,7 +32,7 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted return (
-
{props.btw.question(props.session)}
+
{props.btw.question(props.session, props.id)}
- +
- +
{ctx.t("error")}
-
- -
- -
-
+ {(text) => ( + +
+ +
+
+ )}
diff --git a/packages/gui-extensions/src/btw/renderer.tsx b/packages/gui-extensions/src/btw/renderer.tsx index ac34537bfe83..1a80809362b7 100644 --- a/packages/gui-extensions/src/btw/renderer.tsx +++ b/packages/gui-extensions/src/btw/renderer.tsx @@ -1,6 +1,6 @@ import { createMemo, lazy, onCleanup, Suspense } from "solid-js" import { Icon } from "@opencode/ui/icon" -import { Command, onIdle, Panel, type PanelTab, type Setup } from "../sdk" +import { Command, createKeyed, onIdle, Panel, type PanelTab, type Setup } from "../sdk" import type Btw from "./index" import { createBtw } from "./model" @@ -13,18 +13,19 @@ const setup: Setup = (ctx) => { // Changes when a session mounts or unmounts, not on every switch between sessions. const mounted = createMemo(() => !!sessions.current()) - const tab: PanelTab = { - id: "main", - get title() { - return ctx.t("tab.title") + // Tab objects per session, reused so strip updates and session switches do not rebuild a trigger. + const tabs = new Map>() + + // Drops the tab objects of sessions whose shell tab closed. + createKeyed( + () => [...sessions.list().map((session) => session.key), sessions.current()?.key].join("\u0000"), + () => { + const keep = new Set([...sessions.list().map((session) => session.key), sessions.current()?.key]) + tabs.forEach((_cache, key) => { + if (!keep.has(key)) tabs.delete(key) + }) }, - label: () => ( -
- - {ctx.t("tab.title")} -
- ), - } + ) ctx.add( Command, @@ -48,13 +49,52 @@ const setup: Setup = (ctx) => { transient: true, // Layouts saved before extensions store the tab as "btw"; as a panel key it leaves like any unlisted transient tab. legacy: { btw: "main" }, - // The answer lives only in this window's memory, so the tab lists while its session has one. - list: (input) => (input.open.includes("main") && btw.has(input.session) ? [tab] : []), + // One tab per stored question. Until the session's store loads, its tabs stay listed but hidden so restore keeps + // them; afterwards a tab without a stored question leaves the strip. + list: (input) => { + if (input.open.length === 0) return [] + + const saved = btw.saved(input.session) + const cache = tabs.get(input.session.key) ?? new Map() + tabs.set(input.session.key, cache) + + return input.open.flatMap((id) => { + if (saved.ready() && !saved.value?.chats.some((item) => item.id === id)) return [] + + const existing = cache.get(id) + + if (existing) return [existing] + + const tab: PanelTab = { + id, + get title() { + return saved.value?.chats.find((item) => item.id === id)?.question ?? ctx.t("tab.title") + }, + get hidden() { + return !saved.ready() + }, + label: (state) => ( +
+ + {btw.question(state.session, id) ?? ctx.t("tab.title")} +
+ ), + } + + cache.set(id, tab) + + return [tab] + }) + }, render: (props) => ( - + ), + close: (input) => { + btw.remove(input.session, input.tab.id) + tabs.get(input.session.key)?.delete(input.tab.id) + }, }) }