Compare commits

...

1 Commits

Author SHA1 Message Date
usrnk1 06dffe3671 feat(desktop): add model change notices 2026-08-18 22:04:56 +02:00
10 changed files with 118 additions and 15 deletions
@@ -852,8 +852,26 @@ function provider() {
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
{
id: "company-gateway",
name: "Company Gateway",
models: {
"fast-nano": {
id: "fast-nano",
api: { id: "openai/gpt-5.4-nano" },
name: "GPT-5.4 nano",
limit: { context: 128_000 },
},
"long-context": {
id: "long-context",
api: { id: "company/long-context" },
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
limit: { context: 128_000 },
},
},
},
],
connected: ["opencode"],
connected: ["opencode", "company-gateway"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
@@ -128,6 +128,41 @@ test.describe("session timeline projection", () => {
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
sessionMessages: [
{
id: "msg_model_fast_nano",
type: "model-switched",
time: { created: 1700000000000 },
model: { providerID: "company-gateway", id: "fast-nano" },
},
{
id: "msg_model_long_context",
type: "model-switched",
time: { created: 1700000001000 },
model: { providerID: "company-gateway", id: "long-context" },
},
userMessage(),
assistantMessage(),
],
})
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(shortNotice).toBeVisible()
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice).toBeVisible()
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
+1 -1
View File
@@ -473,7 +473,7 @@ function currentModels(value: unknown) {
return [
{
id: model.id,
modelID: model.id,
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
+1
View File
@@ -670,6 +670,7 @@ export const dict = {
"session.timeline.notice.finished": "{{actor}} finished",
"session.timeline.notice.failed": "{{actor}} failed",
"session.timeline.notice.cancelled": "{{actor}} cancelled",
"session.timeline.notice.modelSwitched": "Switched to {{model}}",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "Files Changed {{count}}",
"session.review.change.one": "Change",
@@ -13,9 +13,10 @@ import {
import { createStore } from "solid-js/store"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { Card } from "@opencode-ai/ui/card"
import { MessageDivider, SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
import { SessionShellMessage, type UserActions } from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -103,6 +104,23 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
)
}
function TimelineSeparator(props: { label: string; providerID?: string }) {
return (
<div class="flex h-8 w-full items-center gap-3 text-v2-text-text-faint">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-base" />
<span class="flex min-w-0 items-center gap-1 text-[13px] font-[440] leading-4 tracking-[-0.04px]">
<Show when={props.providerID}>
{(providerID) => <ProviderIcon id={providerID()} class="text-v2-icon-icon-faint" aria-hidden="true" />}
</Show>
<span class="truncate" title={props.label}>
{props.label}
</span>
</span>
<span class="h-px min-w-0 flex-1 bg-v2-border-border-base" />
</div>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
@@ -369,11 +387,7 @@ function MessageTimelineView(
label: language.t("ui.tool.agent.default"),
data: message.previous ? `${message.previous}${message.agent}` : message.agent,
}
if (message.type === "model-switched")
return {
label: language.t("command.category.model"),
data: `${message.model.providerID}/${message.model.id}`,
}
if (message.type === "model-switched") return undefined
if (message.type === "location-switched")
return { label: language.t("ui.patch.action.moved"), data: message.location.directory }
if (message.type === "skill") return { label: language.t("ui.tool.skill"), data: message.name }
@@ -952,20 +966,45 @@ function MessageTimelineView(
}
case "Notice": {
const noticeRow = row as Accessor<TimelineRowByTag<"Notice">>
const model = createMemo(() => {
const message = sessionMessageByID().get(noticeRow().messageID)
if (message?.type !== "model-switched") return undefined
const info = data.location.model
.list({ directory: sessionDirectory() })
?.find((item) => item.providerID === message.model.providerID && item.id === message.model.id)
return {
providerID: message.model.providerID,
label: language.t("session.timeline.notice.modelSwitched", { model: info?.name ?? message.model.id }),
}
})
const content = createMemo(() => {
const message = sessionMessageByID().get(noticeRow().messageID)
return message ? noticeContent(message) : undefined
})
return (
<TimelineRowFrame row={noticeRow()}>
<Show when={content()}>
{(content) => (
<Show
when={model()}
fallback={
<Show when={content()}>
{(content) => (
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${turnPadding()}`}
>
<span class="text-13-medium">{content().label}</span>
<Show when={content().data}>{(data) => <span> · {data()}</span>}</Show>
</div>
)}
</Show>
}
>
{(model) => (
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${turnPadding()}`}
class={`w-full py-2 ${turnPadding()}`}
>
<span class="text-13-medium">{content().label}</span>
<Show when={content().data}>{(data) => <span> · {data()}</span>}</Show>
<TimelineSeparator label={model().label} providerID={model().providerID} />
</div>
)}
</Show>
@@ -978,7 +1017,9 @@ function MessageTimelineView(
<TimelineRowFrame row={turnDividerRow()}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-compaction">
<MessageDivider label={language.t("ui.message.interrupted")} />
<div class="py-2">
<TimelineSeparator label={language.t("ui.message.interrupted")} />
</div>
</div>
</div>
</TimelineRowFrame>
+1 -1
View File
@@ -9,7 +9,7 @@ export type ProviderIconProps = JSX.SVGElementTags["svg"] & {
export const ProviderIcon: Component<ProviderIconProps> = (props) => {
const [local, rest] = splitProps(props, ["id", "class", "classList"])
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "session.synthetic"))
const resolved = createMemo(() => (iconNames.includes(local.id as IconName) ? local.id : "synthetic"))
return (
<svg
data-component="provider-icon"
@@ -252,6 +252,7 @@
--color-v2-text-text-code-accent: var(--v2-text-text-code-accent);
--color-v2-icon-icon-base: var(--v2-icon-icon-base);
--color-v2-icon-icon-muted: var(--v2-icon-icon-muted);
--color-v2-icon-icon-faint: var(--v2-icon-icon-faint);
--color-v2-icon-icon-inverse: var(--v2-icon-icon-inverse);
--color-v2-icon-icon-contrast: var(--v2-icon-icon-contrast);
--color-v2-icon-icon-accent: var(--v2-icon-icon-accent);
+2
View File
@@ -176,6 +176,7 @@
"v2-text-text-code-accent": "var(--v2-blue-900)",
"v2-icon-icon-base": "var(--v2-grey-800)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-faint": "var(--v2-grey-500)",
"v2-icon-icon-inverse": "var(--v2-grey-50)",
"v2-icon-icon-contrast": "var(--v2-grey-100)",
"v2-icon-icon-accent": "var(--v2-blue-600)",
@@ -411,6 +412,7 @@
"v2-text-text-code-accent": "var(--v2-blue-400)",
"v2-icon-icon-base": "var(--v2-grey-400)",
"v2-icon-icon-muted": "var(--v2-grey-600)",
"v2-icon-icon-faint": "var(--v2-grey-700)",
"v2-icon-icon-inverse": "var(--v2-grey-1100)",
"v2-icon-icon-contrast": "var(--v2-grey-100)",
"v2-icon-icon-accent": "var(--v2-blue-400)",
+1
View File
@@ -52,6 +52,7 @@ export function mapV2Foreground(
"v2-text-text-faint": shift(body, { l: isDark ? -0.2 : 0.21, c: isDark ? 0.78 : 0.72 }),
"v2-icon-icon-base": greyRef(pickGrey(primitives, bgBase, 7, isDark ? 400 : 800)),
"v2-icon-icon-muted": greyRef(pickGrey(primitives, bgBase, 3, 600)),
"v2-icon-icon-faint": greyRef(pickGrey(primitives, bgBase, 2, isDark ? 700 : 500)),
"v2-icon-icon-inverse": greyRef(pickGrey(primitives, bgInverse, 7, inverseTarget)),
"v2-icon-icon-contrast": greyRef(pickGrey(primitives, bgContrast, 7, 100)),
"v2-icon-icon-accent": isDark ? "var(--v2-blue-400)" : "var(--v2-blue-600)",
+4
View File
@@ -27,6 +27,7 @@
/* ── Icon ── */
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-50);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-600);
@@ -158,6 +159,7 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-700);
--v2-icon-icon-inverse: var(--v2-grey-1000);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-400);
@@ -271,6 +273,7 @@
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-500);
--v2-icon-icon-inverse: var(--v2-grey-50);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-600);
@@ -393,6 +396,7 @@
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-faint: var(--v2-grey-700);
--v2-icon-icon-inverse: var(--v2-grey-1100);
--v2-icon-icon-contrast: var(--v2-grey-100);
--v2-icon-icon-accent: var(--v2-blue-400);