feat(app): keep each /btw answer in its own tab until closed (#53270)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
opencode-agent[bot]
2026-10-05 08:13:34 +00:00
committed by GitHub
Unverified
parent eab83d63fd
commit 494d70707b
7 changed files with 386 additions and 120 deletions
+171 -11
View File
@@ -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<void>()
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<void>()
const abandoned = Promise.withResolvers<void>()
const leftBehind = Promise.withResolvers<void>()
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([])
})
+3 -2
View File
@@ -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)
+31 -14
View File
@@ -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<typeof createComposerSubmission>) {
submission.clear()
submission.target().mode.set("normal")
+12 -1
View File
@@ -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 },
})
+101 -66
View File
@@ -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<typeof Btw>) {
const sessions = ctx.sessions
const layout = ctx.layout
const [states, setStates] = createStore<Record<string, typeof empty>>({})
const requests = new Map<string, number>()
const owner = getOwner()
const controllers = new Map<string, AbortController>()
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<typeof Btw>) {
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<void>((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)
})
},
}
}
+13 -11
View File
@@ -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<object>()
@@ -32,7 +32,7 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
return (
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question(props.session)}</div>
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question(props.session, props.id)}</div>
<Show when={answer()}>
<Tooltip value={copied() ? ctx.t("common.copied") : ctx.t("copy")}>
<IconButton
@@ -48,7 +48,7 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
<div class="relative min-h-0 flex-1">
<Switch>
<Match when={props.btw.pending(props.session)}>
<Match when={props.btw.pending(props.id)}>
<div
data-component="session-working"
role="status"
@@ -57,20 +57,22 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
<TextShimmer text={ctx.t("session.timeline.working")} active />
</div>
</Match>
<Match when={props.btw.error(props.session)}>
<Match when={props.btw.error(props.session, props.id)}>
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
<div class="text-13-regular text-text-weak">{ctx.t("error")}</div>
<Button size="small" variant="outline" onClick={() => props.btw.retry(props.session)}>
<Button size="small" variant="outline" onClick={() => props.btw.retry(props.session, props.id)}>
{ctx.t("retry")}
</Button>
</div>
</Match>
<Match when={answer()}>
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={answer()} class="text-14-regular" />
</div>
</ScrollView>
{(text) => (
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={text()} class="text-14-regular" />
</div>
</ScrollView>
)}
</Match>
</Switch>
</div>
+55 -15
View File
@@ -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<typeof Btw> = (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<string, Map<string, PanelTab>>()
// 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: () => (
<div class="flex items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<span>{ctx.t("tab.title")}</span>
</div>
),
}
)
ctx.add(
Command,
@@ -48,13 +49,52 @@ const setup: Setup<typeof Btw> = (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<string, PanelTab>()
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) => (
<div class="flex min-w-0 items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<span class="truncate">{btw.question(state.session, id) ?? ctx.t("tab.title")}</span>
</div>
),
}
cache.set(id, tab)
return [tab]
})
},
render: (props) => (
<Suspense>
<SessionBtwPanel btw={btw} session={props.session} />
<SessionBtwPanel btw={btw} session={props.session} id={props.tab.id} />
</Suspense>
),
close: (input) => {
btw.remove(input.session, input.tab.id)
tabs.get(input.session.key)?.delete(input.tab.id)
},
})
}