diff --git a/packages/app/e2e/regression/btw-sidebar.spec.ts b/packages/app/e2e/regression/btw-sidebar.spec.ts index 43a345a8013..5398b1bf496 100644 --- a/packages/app/e2e/regression/btw-sidebar.spec.ts +++ b/packages/app/e2e/regression/btw-sidebar.spec.ts @@ -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() @@ -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?") diff --git a/packages/gui-extensions/src/btw/index.ts b/packages/gui-extensions/src/btw/index.ts index c834901fad4..541fc7e8e1a 100644 --- a/packages/gui-extensions/src/btw/index.ts +++ b/packages/gui-extensions/src/btw/index.ts @@ -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), + }), ), }) diff --git a/packages/gui-extensions/src/btw/model.ts b/packages/gui-extensions/src/btw/model.ts index dbb1b2df0b7..9c38771f421 100644 --- a/packages/gui-extensions/src/btw/model.ts +++ b/packages/gui-extensions/src/btw/model.ts @@ -29,12 +29,35 @@ export function createBtw(ctx: SetupContext) { 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) { ) .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) { 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) { 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) => { diff --git a/packages/gui-extensions/src/btw/panel.tsx b/packages/gui-extensions/src/btw/panel.tsx index 151e13f1fc1..0561dbd326c 100644 --- a/packages/gui-extensions/src/btw/panel.tsx +++ b/packages/gui-extensions/src/btw/panel.tsx @@ -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() - 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 (
-
-
{props.btw.question(props.session, props.id)}
- - - } - aria-label={copied() ? ctx.t("common.copied") : ctx.t("copy")} - onClick={copy} - /> - - -
+ +
+
+
+
+ {question()} +
+
+ +
+ + + + {userMeta()} + + + + + {"\u00A0\u00B7\u00A0"} + + + + + {stamp()} + + + + + } + aria-label={isQuestionCopied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + onMouseDown={(event) => event.preventDefault()} + onClick={copyQuestion} + /> + +
+
+
-
- - -
- -
-
- -
-
{ctx.t("error")}
- -
-
- - {(text) => ( - -
- + + +
+ +
+
+ +
+
{ctx.t("error")}
+ +
+
+ + {(text) => ( +
+
+ +
+
+ + } + aria-label={isAnswerCopied() ? ctx.t("common.copied") : ctx.t("copy")} + onMouseDown={(event) => event.preventDefault()} + onClick={copy} + /> + + + + {answerMeta()} + + +
- - )} -
-
-
+ )} +
+
+
+
) } diff --git a/packages/gui-extensions/src/btw/renderer.tsx b/packages/gui-extensions/src/btw/renderer.tsx index 1a80809362b..ba2dc445e64 100644 --- a/packages/gui-extensions/src/btw/renderer.tsx +++ b/packages/gui-extensions/src/btw/renderer.tsx @@ -75,7 +75,7 @@ const setup: Setup = (ctx) => { }, label: (state) => (
- + {btw.question(state.session, id) ?? ctx.t("tab.title")}
), diff --git a/packages/ui/src/icons/catalog.ts b/packages/ui/src/icons/catalog.ts index 682952e9894..0be7b8fc328 100644 --- a/packages/ui/src/icons/catalog.ts +++ b/packages/ui/src/icons/catalog.ts @@ -286,6 +286,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + comment: { + viewBox: "0 0 16 16", + body: ``, + }, archive: { viewBox: "0 0 16 16", body: ``,