feat(desktop): refine /btw tab icon and panel layout (#55708)

This commit is contained in:
usrnk1
2026-10-22 10:38:19 +02:00
committed by GitHub
Unverified
parent ece6e3bfbb
commit ab6ac0099f
6 changed files with 246 additions and 54 deletions
@@ -144,6 +144,7 @@ test("keeps many long-titled side tabs usable at a narrow desktop width", async
})
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"])
const generations: { sessionID: string; prompt: string }[] = []
const prompts: unknown[] = []
const generated = Promise.withResolvers<void>()
@@ -204,6 +205,16 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Copy message", exact: true })).toHaveCount(1)
await expect(panel.getByRole("button", { name: "Revert message", exact: true })).toHaveCount(0)
await expect(panel.getByRole("button", { name: "Copy answer", exact: true })).toHaveCount(1)
await expect(panel.locator('[data-slot="text-part-meta"]')).toContainText(/\d+s/)
await panel.locator('[data-component="user-message"]').hover()
await panel.getByRole("button", { name: "Copy message", exact: true }).click()
await expect(panel.getByRole("button", { name: "Copied", exact: true })).toHaveCount(1)
await panel.locator('[data-component="text-part"]').hover()
await panel.getByRole("button", { name: "Copy answer", exact: true }).click()
await expect(panel.getByRole("button", { name: "Copied", exact: true })).toHaveCount(2)
expect(generations).toHaveLength(1)
expect(generations[0]?.sessionID).toBe(main.id)
expect(generations[0]?.prompt).toContain("how does the retry loop work?")
+15 -1
View File
@@ -2,9 +2,23 @@ import { Schema } from "effect"
import { Extension, Store } from "../sdk"
import en from "./i18n/en"
const ChatModel = Schema.Struct({
providerID: Schema.String,
id: Schema.String,
variant: Schema.optional(Schema.String),
})
const Chats = Schema.Struct({
chats: Schema.Array(
Schema.Struct({ id: Schema.String, question: Schema.String, answer: Schema.optional(Schema.String) }),
Schema.Struct({
id: Schema.String,
question: Schema.String,
answer: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(ChatModel),
created: Schema.optional(Schema.Number),
duration: Schema.optional(Schema.Number),
}),
),
})
+42 -2
View File
@@ -29,12 +29,35 @@ export function createBtw(ctx: SetupContext<typeof Btw>) {
const entry = (session: MountedSession, id: string) => saved(session).value?.chats.find((item) => item.id === id)
const pending = (id: string) => requests.pending.includes(id)
const selection = (session: MountedSession) => {
const info = session.id ? session.server.data.session.get(session.id) : undefined
const messages = session.id ? session.server.data.session.message.list(session.id) : []
const lastAgent = messages.findLast(
(item) => item.type === "assistant" || item.type === "agent-switched",
)
const lastModel = messages.findLast(
(item) => item.type === "assistant" || item.type === "model-switched",
)
const agent = info?.agent ?? (lastAgent?.type === "assistant" || lastAgent?.type === "agent-switched" ? lastAgent.agent : undefined)
const model = info?.model ?? (lastModel?.type === "assistant" || lastModel?.type === "model-switched" ? lastModel.model : undefined)
return {
agent,
model: model ? { providerID: model.providerID, id: model.id, variant: model.variant } : undefined,
}
}
// Takes the question rather than reading it back, because a new chat's write may still wait for the store to load.
const generate = (session: MountedSession, id: string, question: string) => {
if (pending(id)) return
const store = saved(session)
const controller = new AbortController()
const started = Date.now()
const current = selection(session)
controllers.set(id, controller)
setRequests("pending", (list) => [...list, id])
@@ -46,10 +69,18 @@ export function createBtw(ctx: SetupContext<typeof Btw>) {
)
.then((result) => {
if (ctx.signal.aborted || controller.signal.aborted) return
const duration = Math.max(0, Date.now() - started)
store.update((draft) => {
const chat = draft.chats.find((item) => item.id === id)
if (chat) chat.answer = result.text.trim()
if (!chat) return
chat.answer = result.text.trim()
chat.duration = duration
if (current.agent) chat.agent = current.agent
if (current.model) chat.model = current.model
})
})
// A missing answer is the retry state; no transient error flag is stored.
@@ -74,13 +105,21 @@ export function createBtw(ctx: SetupContext<typeof Btw>) {
if (!session?.id) return
const id = crypto.randomUUID()
const created = Date.now()
const current = selection(session)
// Desktop storage loads asynchronously. A write before load queues and lands in the same batch that marks the
// store ready, and the panel keeps a tab hidden until then, so the chat and its tab need not wait. The session's
// store lives while its tab stays open, so leaving the session first still records the chat.
batch(() => {
saved(session).update((draft) => {
draft.chats.push({ id, question })
draft.chats.push({
id,
question,
created,
agent: current.agent,
model: current.model,
})
})
ctx.layout.open(`${ctx.id}:${id}`, session, { tab: "select" })
})
@@ -91,6 +130,7 @@ export function createBtw(ctx: SetupContext<typeof Btw>) {
ask,
saved,
pending,
entry,
question: (session: MountedSession, id: string) => entry(session, id)?.question,
answer: (session: MountedSession, id: string) => entry(session, id)?.answer,
error: (session: MountedSession, id: string) => {
+173 -50
View File
@@ -1,5 +1,7 @@
import { createMemo, createSignal, Match, on, Show, Switch } from "solid-js"
import { createMemo, Match, on, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useI18n } from "@opencode/ui/context/i18n"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
@@ -12,70 +14,191 @@ import type { BtwModel } from "./model"
export default function SessionBtwPanel(props: { btw: BtwModel; session: MountedSession; id: string }) {
const ctx = useExtension()
const i18n = useI18n()
const system = ctx.system
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>()
const copied = () => copiedAnswer() === shown()
const chat = () => props.btw.entry(props.session, props.id)
const question = () => chat()?.question
const answer = () => chat()?.answer
// New primitive tokens clear the copied mark when text changes without being proxied by the store.
const shownQuestion = createMemo(on(question, () => Symbol()))
const shownAnswer = createMemo(on(answer, () => Symbol()))
const [copied, setCopied] = createStore<{ question?: symbol; answer?: symbol }>({})
const isQuestionCopied = () => copied.question === shownQuestion()
const isAnswerCopied = () => copied.answer === shownAnswer()
const timefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { timeStyle: "short" }))
const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale()))
const agentLabel = createMemo(() => {
const agent = chat()?.agent
return agent ? agent[0]?.toUpperCase() + agent.slice(1) : ""
})
const modelLabel = createMemo(() => {
const model = chat()?.model
if (!model) return ""
const ref = props.session.directory ? { directory: props.session.directory } : undefined
const models = props.session.server.data.location.model.list(ref)
const found = models?.findLast(
(item) => item.providerID === model.providerID && item.id === model.id && item.status !== "deprecated",
)
const name = found?.name ?? model.id
if (!model.variant || model.variant === "default") return name
return i18n.t("ui.message.modelVariant", {
model: name,
variant: model.variant[0]?.toUpperCase() + model.variant.slice(1),
})
})
const userMeta = createMemo(() => [agentLabel(), modelLabel()].filter(Boolean).join("\u00A0\u00B7\u00A0"))
const stamp = createMemo(() => {
const created = chat()?.created
return created !== undefined ? timefmt().format(created) : ""
})
const durationLabel = createMemo(() => {
const ms = chat()?.duration
if (ms === undefined || !(ms >= 0)) return ""
const total = Math.round(ms / 1000)
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
return i18n.t("ui.message.duration.minutesSeconds", {
minutes: numfmt().format(Math.floor(total / 60)),
seconds: numfmt().format(total % 60),
})
})
const answerMeta = createMemo(() => [agentLabel(), modelLabel(), durationLabel()].filter(Boolean).join(" \u00B7 "))
const copyQuestion = () => {
const value = question()
if (!value) return
void system.copy(value).then(
() => setCopied("question", shownQuestion()),
() => showToast({ title: ctx.t("common.requestFailed") }),
)
}
const copy = () => {
const value = answer()
if (!value) return
void system.copy(value).then(
() => setCopiedAnswer(shown()),
() => setCopied("answer", shownAnswer()),
() => showToast({ title: ctx.t("common.requestFailed") }),
)
}
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, props.id)}</div>
<Show when={answer()}>
<Tooltip value={copied() ? ctx.t("common.copied") : ctx.t("copy")}>
<IconButton
size="small"
variant="ghost-muted"
icon={<Icon name={copied() ? "check" : "outline-copy"} />}
aria-label={copied() ? ctx.t("common.copied") : ctx.t("copy")}
onClick={copy}
/>
</Tooltip>
</Show>
</div>
<ScrollView class="min-h-0 flex-1">
<div class="min-w-0 w-full max-w-full px-4 pt-6 pb-8 md:max-w-session md:mx-auto md:px-6">
<div data-component="user-message">
<div data-slot="user-message-body">
<div data-slot="user-message-text" dir="auto">
{question()}
</div>
</div>
<Show when={question()}>
<div data-slot="user-message-copy-wrapper">
<span data-slot="user-message-meta-wrap">
<Show when={userMeta()}>
<span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default">
{userMeta()}
</span>
</Show>
<Show when={userMeta() && stamp()}>
<span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default">
{"\u00A0\u00B7\u00A0"}
</span>
</Show>
<Show when={stamp()}>
<span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default">
{stamp()}
</span>
</Show>
</span>
<Tooltip
appearance="compact"
value={isQuestionCopied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
placement="top"
gutter={4}
>
<IconButton
size="normal"
variant="ghost-muted"
icon={<Icon name={isQuestionCopied() ? "check" : "outline-copy"} size="small" />}
aria-label={isQuestionCopied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
onMouseDown={(event) => event.preventDefault()}
onClick={copyQuestion}
/>
</Tooltip>
</div>
</Show>
</div>
<div class="relative min-h-0 flex-1">
<Switch>
<Match when={props.btw.pending(props.id)}>
<div
data-component="session-working"
role="status"
class="flex h-9 items-center px-5 pt-3 text-[13px] font-[530] leading-text-compact"
>
<TextShimmer text={ctx.t("session.timeline.working")} active />
</div>
</Match>
<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, props.id)}>
{ctx.t("retry")}
</Button>
</div>
</Match>
<Match when={answer()}>
{(text) => (
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={text()} class="text-14-regular" />
<Switch>
<Match when={props.btw.pending(props.id)}>
<div
data-component="session-working"
role="status"
class="flex h-9 items-center pt-3 text-[13px] font-[530] leading-text-compact"
>
<TextShimmer text={ctx.t("session.timeline.working")} active />
</div>
</Match>
<Match when={props.btw.error(props.session, props.id)}>
<div class="flex flex-col items-start gap-3 pt-4">
<div class="text-13-regular text-text-weak">{ctx.t("error")}</div>
<Button size="small" variant="outline" onClick={() => props.btw.retry(props.session, props.id)}>
{ctx.t("retry")}
</Button>
</div>
</Match>
<Match when={answer()}>
{(text) => (
<div data-component="text-part">
<div data-slot="text-part-body">
<Markdown text={text()} class="text-14-regular" />
</div>
<div data-slot="text-part-copy-wrapper">
<Tooltip
appearance="compact"
value={isAnswerCopied() ? ctx.t("common.copied") : ctx.t("copy")}
placement="top"
gutter={4}
>
<IconButton
size="normal"
variant="ghost-muted"
icon={<Icon name={isAnswerCopied() ? "check" : "outline-copy"} size="small" />}
aria-label={isAnswerCopied() ? ctx.t("common.copied") : ctx.t("copy")}
onMouseDown={(event) => event.preventDefault()}
onClick={copy}
/>
</Tooltip>
<Show when={answerMeta()}>
<span data-slot="text-part-meta" class="text-12-regular text-text-weak cursor-default">
{answerMeta()}
</span>
</Show>
</div>
</div>
</ScrollView>
)}
</Match>
</Switch>
</div>
)}
</Match>
</Switch>
</div>
</ScrollView>
</div>
)
}
+1 -1
View File
@@ -75,7 +75,7 @@ const setup: Setup<typeof Btw> = (ctx) => {
},
label: (state) => (
<div class="flex min-w-0 items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<Icon name="comment" class="shrink-0" />
<span class="truncate">{btw.question(state.session, id) ?? ctx.t("tab.title")}</span>
</div>
),
+4
View File
@@ -286,6 +286,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M14.5 8.01193V8" stroke-linecap="square" stroke="currentColor"/><path d="M1.5 8.00607V7.99414" stroke-linecap="square" stroke="currentColor"/><path d="M3.66699 10.5141V5.48438" stroke-linecap="square" stroke="currentColor"/><path d="M10.167 10.5141V5.48438" stroke-linecap="square" stroke="currentColor"/><path d="M8 11.6004V4.39844" stroke-linecap="square" stroke="currentColor"/><path d="M12.333 9.20144V6.80078" stroke-linecap="square" stroke="currentColor"/><path d="M5.83301 13.5323V2.4668" stroke-linecap="square" stroke="currentColor"/>`,
},
comment: {
viewBox: "0 0 16 16",
body: `<path d="M13.5 2.5H2.5V13.5H4.5L5.5 11.5H13.5V2.5Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
},
archive: {
viewBox: "0 0 16 16",
body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,