mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
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:
committed by
GitHub
Unverified
parent
eab83d63fd
commit
494d70707b
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user