Compare commits

...
13 Commits
Author SHA1 Message Date
Luke Parker 0d2c865c93 feat(app): move background work to session summary (#44068) 2026-08-22 16:31:53 +10:00
Luke Parker 7032a096bf fix(desktop): configure app before instance lock (#44067) 2026-08-22 16:09:13 +10:00
Luke Parker 80ef4f454f feat(desktop): align loaded resource calls with figma (#44060) 2026-08-22 15:17:11 +10:00
Luke Parker aa2dd5040f fix(desktop): omit shared rail from framed disclosures (#44061) 2026-08-22 15:17:09 +10:00
Luke Parker 9b17449e88 fix(desktop): match instructions notice typography to figma (#44059) 2026-08-22 15:06:13 +10:00
Luke Parker 346689fb43 fix(storybook): use production styles and remove synthetic tool stories (#44058) 2026-08-22 15:05:51 +10:00
Luke Parker 87d2488c78 feat(desktop): apply left border rail styling to all tool disclosures (#44052) 2026-08-22 14:23:18 +10:00
Aiden ClineandMajor Hayden a1d0f43531 fix(ai): preserve unique Gemini function call IDs (#44051)
Signed-off-by: Major Hayden <major@mhtx.net>
Co-authored-by: Major Hayden <major@mhtx.net>
2026-08-21 23:12:04 -05:00
Luke Parker 030b2d9543 feat(desktop): align instructions updated notice presentation with figma (#44050) 2026-08-22 13:49:24 +10:00
Luke Parker e7177a8764 fix(desktop): align web search results presentation with figma (#44039) 2026-08-22 13:22:22 +10:00
opencode-agent[bot]andHona a84b1c15ce fix(app): wait for session route id (#44048)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 13:22:10 +10:00
opencode-agent[bot]andHona d3eee25ee2 fix(session-ui): scope context row keys (#44043)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-08-22 02:57:53 +00:00
Luke Parker b4fabf5984 fix(desktop): align webfetch link styling with figma (#44037) 2026-08-22 02:29:03 +00:00
37 changed files with 877 additions and 507 deletions
+4 -5
View File
@@ -212,7 +212,6 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
interface ParserState {
readonly finishReason?: string
readonly hasToolCalls: boolean
readonly nextToolCallId: number
readonly promptFeedback?: GeminiPromptFeedback
readonly usage?: Usage
readonly lifecycle: Lifecycle.State
@@ -580,7 +579,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
const events: LLMEvent[] = []
let hasToolCalls = nextState.hasToolCalls
let lifecycle = nextState.lifecycle
let nextToolCallId = nextState.nextToolCallId
let reasoningSignature = nextState.reasoningSignature
let textSignature = nextState.textSignature
@@ -620,7 +618,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
if ("functionCall" in part) {
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
const id = `tool_${nextToolCallId++}`
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
const metadata = {
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
@@ -649,7 +649,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
...nextState,
hasToolCalls,
lifecycle,
nextToolCallId,
reasoningSignature,
textSignature,
finishReason: candidate.finishReason ?? nextState.finishReason,
@@ -673,7 +672,7 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }),
step,
onHalt: finish,
},
+52 -26
View File
@@ -848,7 +848,7 @@ describe("Gemini route", () => {
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
})
expect(toolCall).toMatchObject({
id: "tool_0",
id: "provider_call",
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
})
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
@@ -862,14 +862,14 @@ describe("Gemini route", () => {
Message.assistant([
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
ToolCallPart.make({
id: "tool_0",
id: "provider_call",
name: "lookup",
input: { query: "weather" },
providerMetadata: toolCall?.providerMetadata,
}),
]),
Message.tool({
id: "tool_0",
id: "provider_call",
name: "lookup",
result: "done",
resultType: "text",
@@ -1101,21 +1101,17 @@ describe("Gemini route", () => {
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
})
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "weather" },
})
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{
type: "tool-call",
id: "tool_0",
id: response.toolCalls[0].id,
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1158,7 +1154,8 @@ describe("Gemini route", () => {
),
)
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
expect(response.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls).toMatchObject([{ type: "tool-call", name: "ping", input: {} }])
}),
)
@@ -1198,7 +1195,7 @@ describe("Gemini route", () => {
content: {
role: "model",
parts: [
{ functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { id: "call_0", name: "lookup", args: { query: "weather" } } },
{ functionCall: { name: "lookup", args: { query: "news" } } },
],
},
@@ -1212,16 +1209,20 @@ describe("Gemini route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toEqual([
{
type: "tool-call",
id: "tool_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "tool_0" } },
},
{ type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
])
expect(response.toolCalls[0]).toMatchObject({
type: "tool-call",
id: "call_0",
name: "lookup",
input: { query: "weather" },
providerMetadata: { google: { functionCallId: "call_0" } },
})
expect(response.toolCalls[1]).toMatchObject({
type: "tool-call",
name: "lookup",
input: { query: "news" },
})
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(response.toolCalls[0].id).not.toBe(response.toolCalls[1].id)
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "tool-calls", raw: "STOP" },
@@ -1229,6 +1230,31 @@ describe("Gemini route", () => {
}),
)
it.effect("assigns distinct unique fallback ids across separate requests", () =>
Effect.gen(function* () {
const body = sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
finishReason: "STOP",
},
],
})
const req = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
})
const first = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
const second = yield* LLMClient.generate(req).pipe(Effect.provide(fixedResponse(body)))
expect(first.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(second.toolCalls[0].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
expect(first.toolCalls[0].id).not.toBe(second.toolCalls[0].id)
}),
)
it.effect("maps length and content-filter finish reasons", () =>
Effect.gen(function* () {
const length = yield* LLMClient.generate(request).pipe(
@@ -81,7 +81,25 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
const thinking = page.locator('[data-slot="session-turn-thinking"]')
await expect(hint).toBeVisible()
await expect
.poll(async () => {
const [cardBox, hintBox, prefixBox, thinkingBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
thinking.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox || !thinkingBox) return undefined
return {
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
ordered: cardBox.y < hintBox.y && hintBox.y < thinkingBox.y,
}
})
.toEqual({ aligned: true, ordered: true })
const request = page.waitForRequest(
(request) =>
@@ -104,10 +122,10 @@ test("navigates from a running subagent card and hides background controls in th
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.locator('[data-action="session-background-toggle"]')).toContainText("Move 1 subagent to background")
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0)
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
test("shows a badge for active background work", async ({ page }) => {
@@ -118,7 +136,14 @@ test("shows a badge for active background work", async ({ page }) => {
sessionStatus: { [childID]: { type: "busy" } },
})
await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 item running in background" })
await expect(summary).toContainText("1")
await expect(summary).toContainText("Running work in background")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
).toBeVisible()
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
@@ -193,10 +218,15 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const dock = page.locator('[data-component="session-background-dock"]')
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(dock).toContainText("Move 1 subagent to background")
await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible()
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -127,19 +127,16 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
],
})
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
for (const id of [pending, completed]) {
for (const [id, name] of [
[pending, "sample-skill"],
[completed, "OpenCode"],
] as const) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
await expect(skill.locator('[data-slot="skill-tool-label"]')).toHaveText("Skill")
await expect(skill.locator('[data-slot="skill-tool-separator"]')).toHaveText("·")
await expect(skill.locator('use[href="#opencode-v2-icon-post-skill"]')).toBeVisible()
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
}
})
+4 -1
View File
@@ -544,7 +544,6 @@ export const dict = {
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
"toast.session.unshare.success.title": "Session unshared",
"toast.session.unshare.success.description": "Session unshared successfully!",
"toast.session.unshare.failed.title": "Failed to unshare session",
@@ -657,6 +656,10 @@ export const dict = {
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -2,15 +2,12 @@ import { Show, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { SessionPermissionDock } from "@/session/requests/session-permission-dock"
import { SessionQuestionDock } from "@/session/requests/session-question-dock"
import { SessionBackgroundDock } from "@/session/requests/session-background-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
type SessionComposerRegionState = Pick<
SessionComposerRegionController["state"],
"questionRequest" | "permissionRequest" | "permissionResponding" | "decide" | "blocked"
> & {
background: Pick<SessionComposerRegionController["state"]["background"], "blocking" | "tasks" | "move">
}
>
export type SessionComposerRegionViewController = Pick<
SessionComposerRegionController,
@@ -32,9 +29,6 @@ export function SessionComposerRegion(props: {
}) {
const language = useLanguage()
const controller = props.controller
const background = () =>
controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0
return (
<div
ref={controller.setDockRef}
@@ -81,22 +75,10 @@ export function SessionComposerRegion(props: {
</>
}
>
<Show when={background()}>
<div>
<SessionBackgroundDock
blocking={controller.state.background.blocking()}
tasks={controller.state.background.tasks()}
onBackground={() => void controller.state.background.move()}
/>
</div>
</Show>
<div
classList={{
"relative z-[70]": true,
}}
style={{
"margin-top": `${background() ? -36 : 0}px`,
}}
>
<Show
when={controller.child()}
@@ -63,6 +63,7 @@ export function createSessionRequestModel() {
return [
{
type: part.name as "shell" | "subagent",
partID: part.id,
id: typeof value === "string" ? value : undefined,
label: typeof label === "string" ? label : undefined,
},
@@ -93,11 +94,13 @@ export function createSessionRequestModel() {
const sessionID = part.state.metadata.sessionID
if (typeof sessionID !== "string" || completed.has(sessionID)) return []
const description = part.state.input.description
const agent = part.state.input.agent
return [
{
id: sessionID,
type: "subagent" as const,
label: typeof description === "string" ? description : sessionID,
agent: typeof agent === "string" ? agent : undefined,
},
]
})
@@ -1,83 +0,0 @@
import { useLanguage } from "@/runtime/i18n/language"
import { useCommand } from "@/shell/commands/command"
import { Keybind } from "@opencode-ai/ui/keybind"
import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionBackgroundPullout } from "./session-background-pullout"
export function SessionBackgroundDock(props: {
blocking: { type: "shell" | "subagent"; id?: string; label?: string }[]
tasks: { id: string; type: "shell" | "subagent"; label: string }[]
onBackground: () => void
}) {
const language = useLanguage()
const command = useCommand()
const [store, setStore] = createStore({ collapsed: true })
const describe = (shells: number, subagents: number) => {
const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined
const subagent = subagents
? language.plural("session.background.subagent", subagents, { count: subagents })
: undefined
if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent })
return shell ?? subagent ?? ""
}
const summary = createMemo(() => {
const shells = props.tasks.filter((task) => task.type === "shell").length
return describe(shells, props.tasks.length - shells)
})
const moving = createMemo(() => {
const shells = props.blocking.filter((task) => task.type === "shell").length
const subagents = props.blocking.length - shells
const tasks = describe(shells, subagents)
return tasks ? language.t("session.background.moveTasks", { tasks }) : ""
})
const background = createMemo(() =>
summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "",
)
const blocking = () => props.blocking.length > 0
const toggle = () => {
if (blocking()) {
props.onBackground()
return
}
setStore("collapsed", (value) => !value)
}
return (
<SessionBackgroundPullout
label={
<span class="flex flex-col items-start">
{blocking() && (
<span>
<span class="text-v2-text-text-muted">{moving()}</span>
<span class="pl-2">
<Keybind keys={command.keybindParts("session.background")} variant="neutral" />
</span>
</span>
)}
{!!props.tasks.length && <span class="text-v2-text-text-faint">{background()}</span>}
</span>
}
ariaLabel={[moving(), background()].filter(Boolean).join(". ")}
multiline={blocking() && props.tasks.length > 0}
collapsed={blocking() || store.collapsed}
collapsible={!blocking()}
onToggle={toggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
>
<div class="px-4 pb-11 flex flex-col gap-1.5">
<For each={props.tasks}>
{(task) => (
<div class="flex min-w-0 items-baseline gap-2 text-13-regular">
<span class="shrink-0 text-13-medium text-text-strong">
{language.t(task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default")}
</span>
<span class="truncate text-text-weak">{task.label}</span>
</div>
)}
</For>
</div>
</SessionBackgroundPullout>
)
}
@@ -1,114 +0,0 @@
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createEffect, createMemo, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
export function SessionBackgroundPullout(props: {
label: JSX.Element
ariaLabel: string
multiline?: boolean
collapsed: boolean
collapsible?: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
children: JSX.Element
}) {
const [store, setStore] = createStore({ height: 78, header: 42 })
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
const off = createMemo(() => value() > 0.98)
const base = createMemo(() => Math.max(78, store.header + 36))
const full = createMemo(() => Math.max(base(), store.height))
let contentRef: HTMLDivElement | undefined
let headerRef: HTMLDivElement | undefined
createEffect(() => {
const element = contentRef
const header = headerRef
if (!element || !header) return
const update = () => {
setStore("height", (height) => Math.max(height, element.scrollHeight))
setStore("header", header.getBoundingClientRect().height)
}
update()
createResizeObserver([element, header], update)
})
return (
<div
data-component="session-background-dock"
class="w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01"
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(base(), full() - value() * (full() - base()))}px`,
}}
>
<div ref={contentRef}>
<div
ref={headerRef}
data-action="session-background-toggle"
class="flex items-center gap-2 overflow-visible pl-4 pr-2"
classList={{
"h-[42px]": !props.multiline,
"min-h-[42px] py-2": props.multiline,
}}
role="button"
tabIndex={0}
onClick={props.onToggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
}}
>
<span
class="cursor-default inline-flex items-baseline shrink-0 overflow-visible font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
aria-label={props.ariaLabel}
style={{
"--tool-motion-odometer-ms": "600ms",
"--tool-motion-mask": "18%",
"--tool-motion-mask-height": "0px",
"--tool-motion-spring-ms": "560ms",
"white-space": "pre",
}}
>
{props.label}
</span>
{props.collapsible !== false && (
<div class="ml-auto">
<IconButton
data-action="session-background-toggle-button"
data-collapsed={props.collapsed ? "true" : "false"}
icon={<Icon name="chevron-down" />}
size="normal"
variant="ghost"
style={{ transform: `rotate(${value() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
props.onToggle()
}}
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
)}
</div>
<div
data-slot="session-background-list"
aria-hidden={props.collapsed || off()}
classList={{ "pointer-events-none": value() > 0.1 }}
style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - value())}` }}
>
{props.children}
</div>
</div>
</div>
)
}
+1
View File
@@ -72,6 +72,7 @@ export function SessionScreen(props: { session: SessionModel }) {
{(_id) => (
<MessageTimeline
session={session}
background={composer.region.state.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createSessionResolution } from "./session-resolution"
describe("session resolution", () => {
test("waits for a route session ID", () => {
createRoot((dispose) => {
let syncs = 0
const sessions = {
get: () => undefined,
sync: () => {
syncs++
return Promise.resolve()
},
}
const session = createSessionResolution(() => undefined, () => sessions)
expect(session()).toBeUndefined()
expect(syncs).toBe(0)
dispose()
})
})
})
@@ -29,15 +29,20 @@ type Resolution<T> = { id: string; store: SessionStore<T> } & (
// session that simply has not resolved yet. Resolve failures rethrow on read so
// the enclosing SessionRouteErrorBoundary renders the scoped session error.
export function createSessionResolution<T>(
sessionID: () => string,
sessionID: () => string | undefined,
sessions: () => SessionStore<T>,
options?: { children?: boolean },
) {
const cached = createMemo(() => sessions().get(sessionID()))
const cached = createMemo(() => {
const id = sessionID()
if (!id) return
return sessions().get(id)
})
const [status, setStatus] = createSignal<Resolution<T>>()
createEffect(
on([sessionID, sessions] as const, ([id, store]) => {
if (!id) return
let stale = false
onCleanup(() => {
stale = true
@@ -60,10 +65,11 @@ export function createSessionResolution<T>(
return createMemo(() => {
const id = sessionID()
if (!id) return
const value = cached()
if (value) return value
const state = status()
if (state?.id !== id || state.store !== sessions()) return undefined
if (!state || state.id !== id || state.store !== sessions()) return undefined
if (state.state === "failed") throw state.failure
// A session missing after settlement was deleted, possibly by another client.
// Match the resolve error so the boundary shows the
-8
View File
@@ -84,7 +84,6 @@ export type SessionPreviewProps = {
draft?: string
request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo }
reviewOpened?: boolean
backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[]
child?: { parentID: string }
terminal?: { title: string; lines: string[] }
}
@@ -196,13 +195,6 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
setState("request", undefined)
setState("activity", `Permission response: ${response}`)
},
background: {
blocking: () => [],
tasks: () => props.backgroundTasks ?? [],
move: async () => {
setState("activity", "Requested background execution")
},
},
blocked: () => state.request !== undefined,
},
centered: () => true,
@@ -0,0 +1,36 @@
import { BackgroundMoveHint, BackgroundWorkSummary } from "./message-timeline"
const tasks = [
{ id: "task_explore", type: "subagent" as const, agent: "explore", label: "Reviewing component implementation" },
{ id: "task_status", type: "shell" as const, label: "opencode2 service status" },
{ id: "task_openapi", type: "shell" as const, label: "opencode2 api get /openapi.json" },
{ id: "task_tests", type: "shell" as const, label: "bun test packages/app" },
]
export default {
title: "OpenCode/Session/Background work",
id: "session-background-work",
parameters: {
docs: {
description: {
component: "Production controls for moving blocking work and inspecting active background tasks.",
},
},
},
}
export const InlineMoveHint = {
render: () => (
<div class="flex w-[696px] max-w-full flex-col items-start gap-4">
<BackgroundMoveHint keybind={["Ctrl", "B"]} />
</div>
),
}
export const SummaryPanelEntry = {
render: () => (
<div class="w-[280px] rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<BackgroundWorkSummary tasks={tasks} />
</div>
),
}
@@ -1,11 +1,14 @@
import { createEffect, createMemo, createSignal, on, Show, type Accessor } from "solid-js"
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Badge } from "@opencode-ai/ui/badge"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Menu } from "@opencode-ai/ui/menu"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import type { Project } from "@/runtime/server/types"
@@ -15,7 +18,7 @@ import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline } from "@opencode-ai/session-ui/timeline/projection"
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { createTimelineVirtualizer } from "./virtualizer"
@@ -24,6 +27,98 @@ import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
type BackgroundTask = {
id: string
type: "shell" | "subagent"
label: string
agent?: string
}
type SessionBackground = {
blocking: Accessor<{ type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]>
tasks: Accessor<BackgroundTask[]>
move: () => Promise<void>
}
export function BackgroundMoveHint(props: { keybind?: string[] }) {
const language = useLanguage()
const command = useCommand()
const marker = "__OPENCODE_BACKGROUND_KEYBIND__"
const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker))
const keys = () => props.keybind ?? command.keybindParts("session.background")
const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background")
return (
<div
data-component="session-background-hint"
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
>
<span data-slot="session-background-hint-prefix" class="shrink-0">
{parts()[0].trim()}
</span>
<Keybind keys={keys()} variant="neutral" />
<span class="min-w-0 truncate">{parts()[1].trim()}</span>
</div>
)
}
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
const taskType = (task: BackgroundTask) => {
if (task.type === "shell") return language.t("ui.tool.shell")
if (!task.agent) return language.t("ui.tool.agent.default")
return task.agent.slice(0, 1).toUpperCase() + task.agent.slice(1)
}
return (
<Popover
open={open()}
placement={language.direction() === "rtl" ? "right-end" : "left-end"}
gutter={4}
onOpenChange={setOpen}
>
<Popover.Trigger
as="button"
type="button"
data-component="session-background-summary"
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
aria-label={language.plural("session.background.runningCount", props.tasks.length)}
>
<Badge class="!w-4 !px-0 !border-v2-border-border-strong !bg-v2-background-bg-layer-03">
{props.tasks.length}
</Badge>
<TextShimmer
as="span"
text={language.t("session.background.running")}
active
class="min-w-0 flex-1 truncate text-start"
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
data-component="session-background-list"
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none"
>
<For each={props.tasks.slice(0, 10)}>
{(task) => (
<div
data-component="session-background-list-item"
class="flex h-7 min-w-0 items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-none tracking-[-0.04px]"
>
<span class="shrink-0 text-v2-text-text-base">{taskType(task)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-faint">{task.label}</span>
</div>
)}
</For>
</Popover.Content>
</Popover.Portal>
</Popover>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
@@ -94,6 +189,7 @@ function SessionSummaryPanel(props: {
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
backgroundTasks: BackgroundTask[]
}) {
const language = useLanguage()
const location = () => {
@@ -168,6 +264,9 @@ function SessionSummaryPanel(props: {
)}
</Show>
</button>
<Show when={props.backgroundTasks.length > 0}>
<BackgroundWorkSummary tasks={props.backgroundTasks} />
</Show>
</div>
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
@@ -186,6 +285,7 @@ function SessionSummaryPanel(props: {
type MessageTimelineProps = {
session: TimelineSessionSource
background: SessionBackground
actions?: SessionUserActions
scroll: { overflow: boolean; jump: boolean }
onResumeScroll: () => void
@@ -350,6 +450,18 @@ function MessageTimelineView(
padding: turnPadding,
anchor: props.anchor,
})
const backgroundHintPartID = createMemo(() => {
const blocking = new Set(props.background.blocking().map((task) => task.partID))
const row = projection
.rows()
.findLast(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
)
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
return row.group.ref.partID
})
const backgroundHint = (row: TimelineRow.TimelineRow) =>
row._tag === "AssistantPart" && row.group.type === "part" && row.group.ref.partID === backgroundHintPartID()
return (
<VirtualizedTimeline
@@ -359,7 +471,23 @@ function MessageTimelineView(
const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID)
return content?.type === "tool" && ["edit", "write", "patch"].includes(content.name)
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
renderRow={(row, onSizeChange) => (
<>
<rowRenderer.Row row={row} onSizeChange={onSizeChange} />
<Show when={backgroundHint(row())}>
<div
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div class={`flex h-10 items-start pt-4 ${turnPadding()}`}>
<BackgroundMoveHint />
</div>
</div>
</Show>
</>
)}
header={
<div
data-session-title
@@ -485,6 +613,7 @@ function MessageTimelineView(
setSummary(false)
props.onReview()
}}
backgroundTasks={props.background.tasks()}
/>
</Popover.Content>
</Popover.Portal>
+2 -1
View File
@@ -147,8 +147,9 @@ const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
export const layer = Layer.unwrap(
Effect.gen(function* () {
if (!acquireApplicationLock()) return yield* Effect.interrupt
// Electron scopes the single-instance lock to userData.
yield* configureApplication()
if (!acquireApplicationLock()) return yield* Effect.interrupt
return runtime.pipe(Layer.provideMerge(platform))
}),
)
@@ -116,7 +116,7 @@
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
&.clickable {
&.clickable:not(.webfetch-link) {
cursor: pointer;
text-decoration: underline;
transition: color 0.15s ease;
@@ -256,6 +256,24 @@
}
}
[data-component="collapsible"].tool-collapsible:not([data-rail="false"]) {
> [data-slot="collapsible-content"] {
position: relative;
margin-inline-start: 12px;
padding-inline-start: 16px;
&::before {
content: "";
position: absolute;
inset-inline-start: 0;
top: 0;
bottom: 12px;
width: 0.5px;
background-color: var(--v2-border-border-muted, rgba(0, 0, 0, 0.08));
}
}
}
:root body {
[data-component="task-tool-card"] {
gap: 8px;
@@ -325,3 +343,72 @@
}
}
}
.webfetch-link,
[data-slot="basic-tool-tool-subtitle"].webfetch-link,
[data-slot="exa-tool-link"].webfetch-link,
[data-component="tool-trigger"] [data-slot="basic-tool-tool-subtitle"].webfetch-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--v2-text-text-accent);
text-decoration: none;
overflow: visible;
max-width: 100%;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
font-size: inherit;
font-style: normal;
font-weight: var(--font-weight-regular, 440);
line-height: inherit;
letter-spacing: var(--letter-spacing-normal);
&:visited,
&:active {
color: var(--v2-text-text-accent);
}
[data-slot="webfetch-link-text"] {
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: inherit;
}
.webfetch-link-icon {
display: none;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--v2-icon-icon-accent, var(--v2-text-text-accent));
}
&:hover {
color: var(--v2-text-text-accent);
text-decoration: none;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
[data-slot="webfetch-link-text"] {
text-decoration: underline;
text-underline-offset: 2px;
}
.webfetch-link-icon {
display: inline-flex;
}
}
}
@@ -1,75 +0,0 @@
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "./basic-tool"
export default {
title: "OpenCode/Tools/Disclosure",
id: "components-basic-tool",
component: BasicTool,
parameters: {
docs: {
description: {
component:
"The disclosure frame shared by production tool messages. Use these stories to inspect common resting, running, expanded, and summary-only states.",
},
},
},
}
export const Completed = {
render: () => (
<BasicTool
icon="glasses"
defaultOpen
trigger={{ title: "Read", subtitle: "src/session.ts", args: ["offset=1", "limit=80"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">Loaded the requested file.</div>
</BasicTool>
),
}
export const Running = {
render: () => (
<BasicTool icon="console" status="running" trigger={{ title: "Running tests", subtitle: "bun test src/timeline" }}>
<div class="px-3 py-2 font-mono text-12-regular text-text-base">Running timeline tests...</div>
</BasicTool>
),
}
export const Collapsed = {
render: () => (
<BasicTool
icon="magnifying-glass-menu"
trigger={{ title: "Searched", subtitle: "packages/session-ui", args: ["pattern=TimelineRow.key"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">2 matching files</div>
</BasicTool>
),
}
export const SummaryOnly = {
render: () => (
<BasicTool icon="post-skill" hideDetails trigger={{ title: "Skill", subtitle: "rtl-aware-development" }} />
),
}
export const Controlled = {
render: () => {
const [state, setState] = createStore({ open: false })
return (
<div class="flex max-w-[620px] flex-col gap-3">
<Button class="w-fit" size="small" variant="neutral" onClick={() => setState("open", (value) => !value)}>
{state.open ? "Close tool details" : "Open tool details"}
</Button>
<BasicTool
icon="code-lines"
open={state.open}
onOpenChange={(open) => setState("open", open)}
trigger={{ title: "Edited", subtitle: "src/session.ts", args: ["+3", "-1"] }}
>
<div class="px-3 py-2 text-12-regular text-text-base">Changed the active Session label.</div>
</BasicTool>
</div>
)
},
}
@@ -36,6 +36,7 @@ export interface BasicToolProps {
defer?: boolean
locked?: boolean
animated?: boolean
rail?: boolean
onSubtitleClick?: () => void
onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent>
onTriggerKeyDown?: JSX.EventHandlerUnion<HTMLElement, KeyboardEvent>
@@ -255,7 +256,12 @@ export function BasicTool(props: BasicToolProps) {
)
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible
open={open()}
onOpenChange={handleOpenChange}
class="tool-collapsible"
data-rail={props.rail === false ? "false" : undefined}
>
<Show
when={props.triggerAsLink || props.triggerHref}
fallback={
@@ -326,7 +326,7 @@
[data-component="tool-output"] {
white-space: pre;
padding: 0;
margin-bottom: 24px;
margin-bottom: 0px;
height: fit-content;
display: flex;
flex-direction: column;
@@ -569,12 +569,13 @@
}
[data-component="exa-tool-output"] {
width: 100%;
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
line-height: var(--line-height-large);
font-size: 13px;
line-height: 16px;
color: var(--v2-text-text-muted);
}
@@ -589,27 +590,39 @@
[data-slot="exa-tool-links"] {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
gap: 8px;
min-width: 0;
flex: 1 0 0;
}
[data-slot="exa-tool-link"] {
display: block;
width: fit-content;
max-width: 100%;
font: inherit;
line-height: inherit;
color: var(--v2-text-text-accent);
text-decoration: underline;
text-underline-offset: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
align-self: flex-start;
}
[data-slot="exa-tool-more"] {
all: unset;
cursor: pointer;
width: fit-content;
font-family: var(--font-family-sans);
font-size: 13px;
font-weight: var(--font-weight-regular, 440);
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-faint, #808080);
user-select: none;
&:hover {
color: var(--v2-text-text-accent);
color: var(--v2-text-text-muted);
text-decoration: underline;
text-underline-offset: 2px;
}
&:visited {
color: var(--v2-text-text-accent);
&:focus-visible {
outline: 1px solid var(--v2-text-text-accent);
outline-offset: 2px;
}
}
@@ -655,10 +668,7 @@
}
[data-component="context-tool-group-list"] {
padding-top: 0;
padding-right: 0;
padding-bottom: 0;
padding-left: 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
@@ -1216,7 +1226,15 @@
> [data-component="collapsible"] > [data-slot="collapsible-content"] {
border: none;
border-inline-start: none;
margin-inline-start: 0;
padding-inline-start: 0;
padding-bottom: 0;
background: transparent;
&::before {
display: none;
}
}
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"] {
@@ -1320,20 +1338,36 @@
}
}
[data-component="tool-loaded-file"] {
[data-component="tool-loaded-item"] {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 4px 28px;
gap: 6px;
min-width: 0;
max-width: 100%;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
color: var(--v2-text-text-muted);
font-size: 13px;
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
[data-slot="icon-svg"] {
[data-slot="tool-loaded-label"] {
flex-shrink: 0;
color: var(--icon-weak);
font-weight: 530;
}
[data-slot="tool-loaded-value"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 440;
}
[data-slot="tool-loaded-kind"] {
flex-shrink: 0;
margin-inline-start: -2px;
font-weight: 440;
color: var(--v2-text-text-muted);
}
}
@@ -17,6 +17,17 @@
> [data-component="collapsible"].tool-collapsible {
gap: 0px;
> [data-slot="collapsible-content"] {
border-inline-start: none;
margin-inline-start: 0;
padding-inline-start: 0;
padding-bottom: 0;
&::before {
display: none;
}
}
}
> [data-component="collapsible"].tool-collapsible[data-open="true"] {
@@ -702,15 +702,32 @@ export const webResearchDocument = document([
id: "tool_web_search",
name: "websearch",
offset: 73_100,
args: { query: "WAI ARIA live region status message guidance" },
output: "WAI-ARIA Authoring Practices and MDN live region guidance",
metadata: { provider: "exa" },
args: { query: "figma mcp setup" },
output: [
"https://www.figma.com/community/file/1606560040358762787/figma-mcp-console-setup-guide",
"https://designagentlab.com",
"https://www.figma.com/community/whiteboarding?resource_type=widgets",
"https://figma-console-mcp.southleft.com/mcp",
"https://designagentlab.com/figma-console-mcp",
"https://designagentlab.com/figma-tutorials",
"https://github.com/southleft/figma-console-mcp/issues",
"https://designagentlab.com/ui-kits",
"https://designagentlab.com/prototyping-tools",
"https://www.inthepocket.design/guidelines/figma-mcp/setup-figma-mcp",
"https://www.figma.com/community/plugins",
"https://figma-console-mcp.southleft.com/docs",
"https://designagentlab.com/resources",
"https://github.com/southleft/figma-console-mcp/releases",
"https://www.inthepocket.design/blog/figma-mcp",
"https://designagentlab.com/community",
].join("\n"),
metadata: { provider: "firecrawl" },
}),
completedTool({
id: "tool_web_fetch",
name: "webfetch",
offset: 74_000,
args: { url: "https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html" },
args: { url: "https://www.figma.com" },
output: "Status messages should be programmatically determinable without receiving focus.",
}),
],
@@ -728,33 +745,25 @@ export const webResearchDocument = document([
}),
] satisfies SessionMessageInfo[])
export const skillWorkflowDocument = document([
{
id: "msg_agent_switched_review",
type: "agent-switched",
agent: "review",
previous: "build",
time: { created: STORY_TIME + 78_000 },
},
{
id: "msg_skill_loaded_rtl",
type: "skill",
skill: "rtl-aware-development",
name: "RTL-aware development",
text: "Verify direction independently from language.",
time: { created: STORY_TIME + 78_500 },
},
user("msg_user_skill", "Review the mixed-direction file row before I merge it.", 79_000),
export const loadedResourcesDocument = document([
user("msg_user_skill", "Read the project instructions, load the RTL-aware skill, and review the file row.", 79_000),
assistant({
id: "msg_assistant_skill",
offset: 80_000,
completed: 82_000,
agent: "review",
content: [
completedTool({
id: "tool_loaded_file",
name: "read",
offset: 80_100,
args: { path: "C:/workspaces/opencode/packages/cli/AGENTS.md" },
output: "Project instructions loaded.",
metadata: { loaded: ["C:/workspaces/opencode/packages/cli/AGENTS.md"] },
}),
completedTool({
id: "tool_skill_rtl",
name: "skill",
offset: 80_100,
offset: 80_200,
args: { name: "rtl-aware-development" },
output: "Loaded RTL-aware development guidance",
metadata: { name: "rtl-aware-development" },
@@ -767,6 +776,50 @@ export const skillWorkflowDocument = document([
}),
] satisfies SessionMessageInfo[])
export const instructionsUpdatedSingleDocument = document([
user("msg_user_instructions_single", "Check if beta service reports the shared session as running.", 85_000),
assistant({
id: "msg_assistant_instructions_single",
offset: 86_000,
completed: 88_000,
content: [
{
type: "text",
text: "The beta service is healthy and already reports this shared session as running. I found unrelated desktop changes in the worktree and will leave them untouched; next I'm narrowing the beta-only capabilities to features that can be demonstrated safely in this session rather than invoking every administrative API.",
},
],
}),
{
id: "msg_instructions_updated_single",
type: "system",
text: "Updated instructions for api/v2-demo",
description: "Instructions updated: api/v2-demo",
time: { created: STORY_TIME + 89_000 },
},
] satisfies SessionMessageInfo[])
export const instructionsUpdatedMultipleDocument = document([
user("msg_user_instructions_multi", "Check if beta service reports the shared session as running.", 85_000),
assistant({
id: "msg_assistant_instructions_multi",
offset: 86_000,
completed: 88_000,
content: [
{
type: "text",
text: "The beta service is healthy and already reports this shared session as running. I found unrelated desktop changes in the worktree and will leave them untouched; next I'm narrowing the beta-only capabilities to features that can be demonstrated safely in this session rather than invoking every administrative API.",
},
],
}),
{
id: "msg_instructions_updated_multi",
type: "system",
text: "Updated instructions for api/v2-demo and api/session",
description: "Instructions updated: api/v2-demo, api/session",
time: { created: STORY_TIME + 89_000 },
},
] satisfies SessionMessageInfo[])
export const permissionPendingDocument = document(
[
user("msg_user_permission_pending", "Publish the verified preview build to the canary channel.", 83_000),
+1 -1
View File
@@ -23,7 +23,7 @@ export {
retryDocument,
revertDocument,
reviewDiffs,
skillWorkflowDocument,
loadedResourcesDocument,
standaloneShellCompletedDocument,
standaloneShellRunningDocument,
streamingDocument,
@@ -2,13 +2,17 @@ import { describe, expect, test } from "bun:test"
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection"
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
const context = (
key: string,
partIDs: string[],
identity: { userMessageID?: string; messageID?: string } = {},
) =>
new TimelineRow.AssistantPart({
userMessageID,
userMessageID: identity.userMessageID ?? "user-1",
group: {
key,
type: "context",
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
refs: partIDs.map((partID) => ({ messageID: identity.messageID ?? "assistant-1", partID })),
} satisfies PartGroup,
previousAssistantPart: false,
})
@@ -62,11 +66,18 @@ describe("reuseTimelineRows", () => {
},
{
name: "does not reuse context identity across user messages",
previous: [context("context:a", ["a", "b"], "user-1")],
rows: [context("context:b", ["b"], "user-2")],
previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })],
rows: [context("context:b", ["b"], { userMessageID: "user-2" })],
expected: ["assistant-part:user-2:context:b"],
reused: [],
},
{
name: "does not reuse context identity across assistant messages",
previous: [context("context:assistant-1:a", ["a"], { messageID: "assistant-1" })],
rows: [context("context:assistant-2:a", ["a"], { messageID: "assistant-2" })],
expected: ["assistant-part:user-1:context:assistant-2:a"],
reused: [],
},
{
name: "reuses an unaffected ordinary row",
previous: [user()],
+14 -4
View File
@@ -312,7 +312,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine
const contextByPart = new Map<string, PriorContext>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
row.group.refs.forEach((ref) => contextByPart.set(contextPartKey(row.userMessageID, ref), { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
@@ -407,7 +407,7 @@ function stabilizeContextKey(
) {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
const candidate = contextByPart.get(contextPartKey(row.userMessageID, ref))
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
@@ -426,6 +426,10 @@ function stabilizeContextKey(
})
}
function contextPartKey(userMessageID: string, ref: PartRef) {
return `${userMessageID}:${ref.messageID}:${ref.partID}`
}
function renderable(content: Content, showReasoning: boolean) {
if (content.type === "text") return !!content.text.trim()
if (content.type === "reasoning") return showReasoning && !!content.text.trim()
@@ -440,12 +444,12 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
const flush = () => {
const first = context[0]
if (!first) return
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
groups.push({ type: "context", key: `context:${first.messageID}:${first.partID}`, refs: context })
context = []
}
items.forEach((item) => {
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
if (item.content.type === "tool" && contextTools.has(item.content.name) && !hasLoadedFiles(item.content)) {
context.push({ messageID: item.messageID, partID: item.partID })
return
}
@@ -460,6 +464,12 @@ function groupContent(items: { messageID: string; partID: string; content: Conte
return groups
}
function hasLoadedFiles(content: Extract<Content, { type: "tool" }>) {
if (content.name !== "read" || content.state.status !== "completed") return false
const loaded = content.state.metadata?.loaded
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
}
function reasoningHeading(text: string): string | undefined {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
@@ -1,7 +1,7 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
inspectAndExplainDocument,
skillWorkflowDocument,
loadedResourcesDocument,
subagentDocument,
webResearchDocument,
} from "../storybook/current-session-fixtures"
@@ -44,12 +44,12 @@ export const ResearchTheWeb = {
),
}
export const UseASpecializedSkill = {
export const LoadedResources = {
render: () => (
<CurrentSessionTimelineStory
title="Use a specialized skill"
description="The selected review agent loads RTL guidance and applies it to a mixed-direction file row."
document={skillWorkflowDocument}
title="Loaded instruction file and skill"
description="The assistant reads project instructions, loads specialized guidance, and applies both to its response."
document={loadedResourcesDocument}
width="760px"
/>
),
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
@@ -262,7 +262,7 @@ describe("current session timeline rows", () => {
expect(groups).toEqual([
{
type: "context",
key: "context:tool_read",
key: "context:msg_assistant:tool_read",
refs: [
{ messageID: "msg_assistant", partID: "tool_read" },
{ messageID: "msg_assistant", partID: "tool_grep" },
@@ -276,6 +276,89 @@ describe("current session timeline rows", () => {
])
})
test("keeps reads that load files outside context groups", () => {
const read = {
type: "tool",
id: "tool_read",
name: "read",
state: {
status: "completed",
input: { path: "packages/cli/AGENTS.md" },
content: [{ type: "text", text: "instructions" }],
metadata: { loaded: ["packages/cli/AGENTS.md"] },
},
time: { created: 2, ran: 3, completed: 4 },
} satisfies SessionMessageAssistantTool
const grep = {
type: "tool",
id: "tool_grep",
name: "grep",
state: { status: "running", input: {}, metadata: {} },
time: { created: 5 },
} satisfies SessionMessageAssistantTool
const source = [
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [read, grep],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const groups = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.flatMap((row) =>
row._tag === "AssistantPart" ? [row.group] : [],
)
expect(groups).toEqual([
{
type: "part",
key: "part:msg_assistant:tool_read",
ref: { messageID: "msg_assistant", partID: "tool_read" },
},
{
type: "context",
key: "context:msg_assistant:tool_grep",
refs: [{ messageID: "msg_assistant", partID: "tool_grep" }],
},
])
})
test("keeps context row keys unique when tool IDs repeat across assistant messages", () => {
const tool = (name: string) => ({
type: "tool" as const,
id: "tool_0",
name,
state: { status: "running" as const, input: {}, metadata: {} },
time: { created: 2 },
})
const assistant = (id: string, name: string) => ({
id,
type: "assistant" as const,
agent: "build",
model: { id: "model", providerID: "provider" },
content: [tool(name)],
time: { created: 2 },
})
const source = [
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
assistant("msg_assistant_1", "read"),
assistant("msg_assistant_2", "execute"),
assistant("msg_assistant_3", "grep"),
] satisfies SessionMessageInfo[]
const keys = Timeline.constructSessionMessageRows(source, false, { type: "idle" }).rows.map(TimelineRow.key)
expect(keys).toEqual([
"user-message:msg_user",
"assistant-part:msg_user:context:msg_assistant_1:tool_0",
"assistant-part:msg_user:part:msg_assistant_2:tool_0",
"assistant-part:msg_user:context:msg_assistant_3:tool_0",
])
})
test("places a divider after interrupted output unless the turn compacts", () => {
const messages = [
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
@@ -8,7 +8,7 @@ import { Card } from "@opencode-ai/ui/card"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Show, createMemo, type Accessor, type JSX } from "solid-js"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import {
MessageDivider,
@@ -152,7 +152,17 @@ export function createSessionTimelineRowRenderer(input: {
if (message.type === "location-switched")
return { label: i18n.t("ui.patch.action.moved"), data: message.location.directory }
if (message.type === "skill") return { label: i18n.t("ui.tool.skill"), data: message.name }
if (message.type === "system") return { label: message.description ?? message.text }
if (message.type === "system") {
const prefix = "Instructions updated: "
if (message.description?.startsWith(prefix)) {
const keys = message.description.slice(prefix.length).split(",").map((s) => s.trim()).filter(Boolean)
return {
label: i18n.t("ui.sessionTimeline.notice.instructionsUpdated"),
items: keys,
}
}
return { label: message.description ?? message.text }
}
if (message.type === "compaction") return { label: i18n.t("ui.messagePart.compaction"), data: message.status }
if (message.type !== "synthetic") return undefined
if (message.description === "Continuing after restart") return { label: message.description }
@@ -272,22 +282,48 @@ export function createSessionTimelineRowRenderer(input: {
<Frame row={current()}>
<Show when={content()}>
{(content) => (
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
<Show
when={content().items?.length}
fallback={
<div
data-slot="session-timeline-notice"
class={`w-full pt-3 pb-1 text-13-regular text-text-weak ${padding()}`}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
}
>
<bdi dir="auto" class="text-13-medium">
{content().label}
</bdi>
<Show when={content().data}>
{(data) => (
<span>
{" "}
· <bdi dir="auto">{data()}</bdi>
</span>
)}
</Show>
</div>
<div data-slot="session-timeline-notice" class={`w-full py-1 ${padding()}`}>
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
</bdi>
<For each={content().items}>
{(item) => (
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
</bdi>
)}
</For>
</div>
</div>
</Show>
)}
</Show>
</Frame>
@@ -4,10 +4,11 @@ import {
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionDocument,
instructionsUpdatedMultipleDocument,
instructionsUpdatedSingleDocument,
requestHistoryDocument,
retryDocument,
revertDocument,
skillWorkflowDocument,
streamingDocument,
thinkingDocument,
} from "../storybook/current-session-fixtures"
@@ -71,17 +72,6 @@ export const CompactionAndContinuation = {
),
}
export const AgentAndSkillContext = {
render: () => (
<CurrentSessionTimelineStory
title="Agent and skill context"
description="A review agent and its loaded skill appear chronologically before the response."
document={skillWorkflowDocument}
width="600px"
/>
),
}
export const AnsweredQuestionAndDeclinedCommand = {
render: () => (
<CurrentSessionTimelineStory
@@ -129,3 +119,25 @@ export const MixedDirectionRtl = {
/>
),
}
export const InstructionsUpdatedSingle = {
render: () => (
<CurrentSessionTimelineStory
title="Instructions updated (single)"
description="A system notice in the timeline showing a single updated instruction source."
document={instructionsUpdatedSingleDocument}
width="600px"
/>
),
}
export const InstructionsUpdatedMultiple = {
render: () => (
<CurrentSessionTimelineStory
title="Instructions updated (multiple)"
description="A system notice in the timeline showing multiple updated instruction sources."
document={instructionsUpdatedMultipleDocument}
width="600px"
/>
),
}
+84 -45
View File
@@ -412,25 +412,52 @@ function taskSession(
}
function ExaOutput(props: { output?: string }) {
const i18n = useI18n()
const [showAll, setShowAll] = createSignal(false)
let firstRevealedRef: HTMLAnchorElement | undefined
const links = createMemo(() => urls(props.output))
const visibleLinks = createMemo(() => {
const all = links()
if (showAll() || all.length <= 10) return all
return all.slice(0, 10)
})
const remaining = createMemo(() => Math.max(0, links().length - 10))
const expand = (event: MouseEvent) => {
event.stopPropagation()
setShowAll(true)
requestAnimationFrame(() => {
firstRevealedRef?.focus()
})
}
return (
<Show when={links().length > 0}>
<div data-component="exa-tool-output">
<div data-slot="exa-tool-links">
<For each={links()}>
{(url) => (
<For each={visibleLinks()}>
{(url, index) => (
<a
ref={(el) => {
if (index() === 10) firstRevealedRef = el
}}
data-slot="exa-tool-link"
class="webfetch-link"
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{url}
<span data-slot="webfetch-link-text">{url}</span>
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
</a>
)}
</For>
<Show when={!showAll() && remaining() > 0}>
<button type="button" data-slot="exa-tool-more" onClick={expand}>
{i18n.plural("ui.common.moreCount", remaining())}
</button>
</Show>
</div>
</div>
</Show>
@@ -768,14 +795,29 @@ ToolRegistry.register({
}}
/>
<For each={loaded()}>
{(filepath) => (
<div data-component="tool-loaded-file">
<Icon name="enter" size="small" />
<span>
{i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)}
</span>
</div>
)}
{(filepath) => {
const relative = relativizeProjectPath(filepath, data.directory)
const path = relative === filepath ? relative : relative.replace(/^[/\\]/, "")
const marker = "__OPENCODE_LOADED_PATH__"
const parts = i18n.t("ui.tool.loadedFile", { path: marker }).split(marker)
return (
<div data-component="tool-loaded-item" aria-label={i18n.t("ui.tool.loadedFile", { path })}>
<span data-slot="tool-loaded-label" aria-hidden="true">
{parts[0].trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
{path}
</span>
<Show when={parts[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
)
}}
</For>
</>
)
@@ -898,21 +940,17 @@ ToolRegistry.register({
<Show when={!pending() && url()}>
<a
data-slot="basic-tool-tool-subtitle"
class="clickable subagent-link"
class="webfetch-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
{url()}
<span data-slot="webfetch-link-text">{url()}</span>
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
</a>
</Show>
</div>
<Show when={!pending() && url()}>
<div data-component="tool-action">
<Icon name="square-arrow-top-right" size="small" />
</div>
</Show>
</div>
}
/>
@@ -1112,6 +1150,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
rail={false}
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
@@ -1155,6 +1194,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
rail={false}
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
@@ -1271,6 +1311,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
@@ -1339,6 +1380,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
@@ -1422,6 +1464,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={{
title: i18n.t("ui.tool.patch"),
@@ -1519,6 +1562,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
@@ -1643,34 +1687,29 @@ ToolRegistry.register({
const i18n = useI18n()
const name = createMemo(() => skillToolName(props.input, props.metadata))
const running = createMemo(() => props.status === "streaming" || props.status === "running")
const marker = "__OPENCODE_LOADED_SKILL__"
const parts = createMemo(() => i18n.t("ui.tool.loadedSkill", { name: marker }).split(marker))
const trigger = () => (
<div data-slot="skill-tool-trigger" class="flex min-w-0 items-center gap-1.5">
<Icon name="post-skill" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span
data-slot="skill-tool-label"
class="shrink-0 text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
>
{i18n.t("ui.tool.skill")}
</span>
<Show when={name()}>
{(name) => (
<>
<span data-slot="skill-tool-separator" aria-hidden="true" class="shrink-0 text-v2-text-text-muted">
·
</span>
<TextShimmer
as="bdi"
text={name()}
active={running()}
class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
/>
</>
)}
</Show>
</div>
return (
<Show when={name()} fallback={<TextShimmer text={i18n.t("ui.tool.skill")} active={running()} />}>
{(name) => (
<div data-component="tool-loaded-item" aria-label={i18n.t("ui.tool.loadedSkill", { name: name() })}>
<span data-slot="tool-loaded-label" aria-hidden="true">
{parts()[0].trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
<TextShimmer as="span" text={name()} active={running()} />
</span>
<Show when={parts()[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
)}
</Show>
)
return <BasicTool icon="post-skill" status={props.status} trigger={trigger()} hideDetails />
},
})
+1
View File
@@ -22,6 +22,7 @@ export default defineMain({
"@storybook/addon-a11y",
"@storybook/addon-vitest",
],
staticDirs: [path.resolve(here, "../../app/public")],
stories: [
"../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"../../session-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
@@ -6,6 +6,26 @@ const keybinds: Record<string, string> = {
"agent.cycle": "mod+.",
"model.choose": "mod+m",
"model.variant.cycle": "mod+shift+m",
"session.background": "ctrl+b",
}
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
export function parseKeybind(config: string) {
if (!config || config === "none") return []
return config.split(",").map((combo) => {
const parts = combo.trim().toLowerCase().split("+")
return {
key:
parts.find(
(part) => !["ctrl", "control", "meta", "cmd", "command", "mod", "alt", "option", "shift"].includes(part),
) ?? "",
ctrl: parts.includes("ctrl") || parts.includes("control") || parts.includes("mod"),
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"),
shift: parts.includes("shift"),
alt: parts.includes("alt") || parts.includes("option"),
}
})
}
export function formatKeybind(config: string) {
@@ -25,6 +25,10 @@ const [all, setAll] = createSignal<string[]>([])
const [active, setActive] = createSignal<string | undefined>(undefined)
const [reviewOpen, setReviewOpen] = createSignal(false)
export function useCurrentRoute() {
return () => ({ type: "home" as const })
}
const tabs = {
all,
active,
+1 -3
View File
@@ -1,6 +1,4 @@
import "@opencode-ai/ui/styles/tailwind"
import "@opencode-ai/session-ui/styles"
import "@opencode-ai/ui/styles/tokens"
import "../../app/src/index.css"
import { createEffect, onCleanup, onMount } from "solid-js"
import addonA11y from "@storybook/addon-a11y"
+1 -1
View File
@@ -35,7 +35,7 @@ function CollapsibleArrow(props?: ComponentProps<"div">) {
return (
<div data-slot="collapsible-arrow" {...(props || {})}>
<span data-slot="collapsible-arrow-icon">
<Icon name="chevron-down" size="small" />
<Icon name="fill-triangle-down" size="small" />
</span>
</div>
)
+5
View File
@@ -152,6 +152,8 @@ const source = {
"ui.tool.read": "Read",
"ui.tool.loaded": "Loaded",
"ui.tool.loadedFile": "Loaded {{path}}",
"ui.tool.loadedSkill": "Loaded {{name}} skill",
"ui.tool.list": "List",
"ui.tool.glob": "Glob",
"ui.tool.grep": "Grep",
@@ -188,6 +190,8 @@ const source = {
"ui.common.next": "Next",
"ui.common.submit": "Submit",
"ui.common.showMore": "Show more",
"ui.common.moreCount.one": "+{{count}} more",
"ui.common.moreCount.other": "+{{count}} more",
"ui.permission.deny": "Deny",
"ui.permission.allowAlways": "Allow always",
@@ -208,6 +212,7 @@ const source = {
"ui.sessionTimeline.notice.failed": "{{actor}} failed",
"ui.sessionTimeline.notice.cancelled": "{{actor}} cancelled",
"ui.sessionTimeline.notice.finished": "{{actor}} finished",
"ui.sessionTimeline.notice.instructionsUpdated": "Instructions updated",
"ui.message.queued": "Queued",
"ui.message.attachment.alt": "attachment",
+4
View File
@@ -160,6 +160,10 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
},
"fill-triangle-down": {
viewBox: "0 0 16 16",
body: `<path d="M5.37624 6.75194C5.1818 6.41861 5.42223 6 5.80813 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43199 10.5096C8.23905 10.8404 7.76115 10.8404 7.56821 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
},
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"/>`,