mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
feat(desktop): refine /btw tab icon and panel layout (#55708)
This commit is contained in:
committed by
GitHub
Unverified
parent
ece6e3bfbb
commit
ab6ac0099f
@@ -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?")
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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"/>`,
|
||||
|
||||
Reference in New Issue
Block a user