Compare commits

...
12 changed files with 500 additions and 380 deletions
+18 -10
View File
@@ -1343,20 +1343,28 @@ const onMessageDelta = (
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
const pendingFinish = (() => {
const stopReason = event.delta?.stop_reason
if (stopReason === null || stopReason === undefined) return state.pendingFinish
const stopSequence = event.delta?.stop_sequence
const finishMetadata =
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.providerMetadataKey, { stopSequence })
return {
reason: {
normalized: mapFinishReason(stopReason),
raw: stopReason,
},
providerMetadata: finishMetadata,
}
})()
return [
{
...state,
usage,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.delta?.stop_reason),
raw: event.delta?.stop_reason ?? undefined,
},
providerMetadata:
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
},
pendingFinish,
},
NO_EVENTS,
]
@@ -949,6 +949,41 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("preserves terminal state across usage-only message deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "X" },
usage: { output_tokens: 8 },
},
{ type: "message_delta", delta: {}, usage: { output_tokens: 10 } },
{ type: "message_stop" },
),
),
),
)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 10, totalTokens: 15 })
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.events.find((event) => event.type === "step-finish")).toMatchObject({
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
expect(response.events.at(-1)).toMatchObject({
type: "finish",
reason: { normalized: "stop", raw: "end_turn" },
usage: { inputTokens: 5, outputTokens: 10, totalTokens: 15 },
providerMetadata: { anthropic: { stopSequence: "X" } },
})
}),
)
it.effect("requires message_stop before completing a streamed message", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -0,0 +1,126 @@
import { expect, story } from "../../storybook/playwright/story"
const id = "current-session-tool-headers--shared-headers"
story("shares compact title and detail metrics across tool families", async ({ mount }, info) => {
const root = await mount(id)
await root.getByRole("button", { name: /^Used 7 / }).click()
const headers = root.locator('[data-component="context-tool-group-list"] [data-component="tool-header"]')
await expect(headers).toHaveCount(7)
const titles = headers.locator('[data-slot="basic-tool-tool-title"]')
await expect(headers.locator('[data-component="text-shimmer"][aria-label="Write"]')).toBeVisible()
await expect(headers.locator('[data-component="text-shimmer"][aria-label="Edit"]')).toBeVisible()
for (const title of await titles.all()) {
await expect(title).toBeVisible()
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("font-weight", "530")
await expect(title.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
}
const details = headers.locator(
'[data-slot="basic-tool-tool-subtitle"], [data-slot="basic-tool-tool-arg"], [data-slot="tool-header-directory"]',
)
expect(await details.count()).toBeGreaterThan(7)
for (const detail of await details.all()) {
await expect(detail).toHaveCSS("font-size", "13px")
await expect(detail).toHaveCSS("line-height", "16px")
await expect(detail).toHaveCSS("font-weight", "440")
}
await expect(headers.locator('[data-slot="basic-tool-tool-arg"]')).toHaveText([
"offset=12",
"limit=40",
"pattern=header",
"include=*.tsx",
])
await root.locator('[data-component="session-timeline"]').screenshot({ path: info.outputPath("tool-headers.png") })
})
story("keeps pending file titles active without showing unfinished paths", async ({ mount }) => {
const root = await mount(id, { args: { phase: "streaming", pathKnown: false } })
await root
.locator(
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
)
.click()
for (const action of [undefined, "Provide paths", "Run tools"]) {
if (action) await root.getByRole("button", { name: action, exact: true }).click()
for (const name of ["edit", "write"]) {
const header = root.locator(`[data-timeline-part-id="tool_header_${name}"] [data-component="tool-header"]`)
await expect(header).toBeVisible()
await expect(header.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveCSS("line-height", "16px")
await expect(
header.locator('[data-slot="basic-tool-tool-subtitle"], [data-slot="tool-header-directory"]'),
).toHaveCount(0)
}
}
await root.getByRole("button", { name: "Complete tools", exact: true }).click()
const group = root.getByRole("button", { name: /^Used 7 / })
if ((await group.getAttribute("aria-expanded")) === "false") await group.click()
for (const name of ["edit", "write"]) {
const header = root.locator(`[data-timeline-part-id="tool_header_${name}"] [data-component="tool-header"]`)
await expect(header.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText(`${name}.ts`)
await expect(header.locator('[data-slot="tool-header-directory"]')).toContainText("src/components")
await expect(header.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false")
}
})
for (const theme of ["light", "dark"]) {
for (const width of [390, 1000]) {
story(`truncates long file headers at ${width}px in ${theme}`, async ({ mount, page }, info) => {
await page.setViewportSize({ width, height: 850 })
const root = await mount(id, { args: { longPath: true }, globals: { theme } })
await root.getByRole("button", { name: /^Used 7 / }).click()
for (const name of ["edit", "write"]) {
const header = root.locator(`[data-timeline-part-id="tool_header_${name}"] [data-component="tool-header"]`)
const filename = header.locator('[data-slot="basic-tool-tool-subtitle"]')
const directory = header.locator('[data-slot="tool-header-directory"] > span')
await expect(filename).toContainText(`${name}.ts`)
for (const text of [filename, directory]) {
await expect(text).toHaveCSS("text-overflow", "ellipsis")
await expect(text).toHaveCSS("white-space", "nowrap")
await expect(text).toHaveCSS("line-height", "16px")
}
expect(await filename.evaluate((node) => node.scrollWidth > node.clientWidth)).toBe(true)
const bounds = await header.boundingBox()
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
await root
.locator('[data-component="session-timeline"]')
.screenshot({ path: info.outputPath("long-headers.png") })
})
}
}
story("preserves keyboard disclosures and the webfetch link", async ({ mount }) => {
const root = await mount(id)
await root.getByRole("button", { name: /^Used 7 / }).click()
for (const name of ["shell", "execute", "edit", "write"]) {
const row = root.locator(`[data-timeline-part-id="tool_header_${name}"]`)
const trigger = row.locator('[data-slot="collapsible-trigger"]').first()
const content = row.locator('[data-slot="collapsible-content"]').first()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.focus()
await trigger.press("Enter")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(content).toBeVisible()
await expect(trigger).toBeFocused()
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(content).toBeHidden()
await expect(trigger).toBeFocused()
}
const link = root.getByRole("link", { name: "https://example.com/docs" })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute("href", "https://example.com/docs")
await expect(link).toHaveAttribute("target", "_blank")
await expect(link).toHaveAttribute("rel", /noopener/)
await expect(link).toHaveCSS("font-size", "13px")
await expect(link).toHaveCSS("font-weight", "440")
await expect(link).toHaveCSS("line-height", "16px")
await expect(link).toHaveCSS("letter-spacing", "-0.04px")
await link.focus()
await expect(link).toBeFocused()
})
@@ -83,22 +83,11 @@
[data-slot="basic-tool-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-base);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
}
}
[data-slot="basic-tool-tool-subtitle"] {
@@ -107,13 +96,6 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
&.clickable:not(.webfetch-link) {
@@ -153,13 +135,6 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
}
@@ -177,23 +152,6 @@
[data-slot="basic-tool-tool-info-main"] {
gap: 6px;
}
[data-slot="basic-tool-tool-title"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 530;
/* Keep compact text on the shared metric; solid 13px line boxes clip Inter descenders. */
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
[data-slot="basic-tool-tool-subtitle"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
}
[data-component="task-tool-card"] {
@@ -1,7 +1,6 @@
import {
createEffect,
createMemo,
For,
Match,
on,
onCleanup,
@@ -16,17 +15,9 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
import { createStore } from "solid-js/store"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import type { IconProps } from "@opencode-ai/ui/icon"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { ToolHeader, type ToolHeaderProps } from "./tool-header"
export type TriggerTitle = {
title: string
titleClass?: string
subtitle?: string
subtitleClass?: string
args?: string[]
argsClass?: string
action?: JSX.Element
}
export type TriggerTitle = Omit<ToolHeaderProps, "active" | "onSubtitleClick">
const isTriggerTitle = (val: unknown): val is TriggerTitle => {
if (typeof val !== "object" || val === null) return false
@@ -216,54 +207,12 @@ export function BasicTool(props: BasicToolProps) {
<Switch>
<Match when={triggerTitle()}>
{(title) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[title().titleClass ?? ""]: !!title().titleClass,
}}
>
<TextShimmer text={title().title} active={pending()} />
</span>
<Show when={!pending() || title().subtitle || title().args?.length}>
<Show when={title().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[title().subtitleClass ?? ""]: !!title().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{title().subtitle}
</span>
</Show>
<Show when={title().args?.length}>
<For each={title().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[title().argsClass ?? ""]: !!title().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && title().action}>
<span data-slot="basic-tool-tool-action">{title().action}</span>
</Show>
</div>
<ToolHeader
{...title()}
active={pending()}
onSubtitleClick={props.onSubtitleClick}
action={!pending() ? title().action : undefined}
/>
)}
</Match>
<Match when={true}>{triggerContent() as JSX.Element}</Match>
@@ -446,102 +446,6 @@
--tool-content-gap: 6px;
}
[data-component="edit-trigger"],
[data-component="write-trigger"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
[data-slot="message-part-title-area"] {
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
[data-slot="message-part-title"] {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--v2-text-text-muted);
}
[data-slot="message-part-title-spinner"] {
margin-left: 4px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--v2-text-text-muted);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="message-part-title-text"] {
flex-shrink: 0;
text-transform: capitalize;
color: var(--v2-text-text-base);
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-regular);
direction: ltr;
unicode-bidi: isolate;
}
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
font-weight: var(--font-weight-regular);
direction: ltr;
unicode-bidi: isolate;
}
[data-slot="message-part-directory"] {
color: var(--v2-text-text-muted);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="message-part-filename"] {
color: var(--v2-text-text-base);
flex-shrink: 0;
}
[data-slot="message-part-actions"] {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
}
}
[data-component="edit-content"] {
border-radius: inherit;
border-top: 0.5px solid var(--v2-border-border-muted);
@@ -678,24 +582,6 @@
gap: 0px;
cursor: default;
[data-slot="context-tool-group-title"] {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 1;
min-width: 0;
}
[data-slot="context-tool-group-prefix"] {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
[data-slot="basic-tool-tool-title"] {
overflow: hidden;
text-overflow: ellipsis;
@@ -740,17 +626,6 @@
gap: 6px;
}
[data-slot="basic-tool-tool-title"],
[data-slot="basic-tool-tool-subtitle"],
[data-slot="basic-tool-tool-arg"],
[data-slot="context-tool-group-matches"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
[data-slot="basic-tool-tool-subtitle"] {
color: var(--v2-text-text-faint);
}
@@ -0,0 +1,87 @@
[data-component="tool-header"] {
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-style: normal;
font-weight: 440;
/* Truncated text still needs room for Inter descenders. */
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
&[data-slot="basic-tool-tool-info-structured"] {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
[data-slot="basic-tool-tool-info-main"] {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
overflow: hidden;
}
[data-slot="basic-tool-tool-title"] {
flex-shrink: 0;
font: inherit;
font-weight: 530;
color: var(--v2-text-text-base);
&.capitalize {
text-transform: capitalize;
}
}
[data-slot="tool-header-affix"] {
flex-shrink: 0;
}
[data-slot="basic-tool-tool-subtitle"],
[data-slot="basic-tool-tool-arg"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: inherit;
font-variant-numeric: tabular-nums;
&[dir] {
unicode-bidi: isolate;
}
&.clickable {
cursor: pointer;
text-decoration: underline;
}
}
[data-slot="tool-header-directory"] {
display: flex;
min-width: 0;
unicode-bidi: isolate;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}
}
[data-slot="basic-tool-tool-action"] {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.webfetch-link {
font: inherit;
letter-spacing: inherit;
}
}
@@ -0,0 +1,73 @@
import { children, For, Show, type JSX } from "solid-js"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
export type ToolHeaderProps = {
title: string
active?: boolean
titleClass?: string
prefix?: string
suffix?: string
subtitle?: JSX.Element
subtitleClass?: string
subtitleDir?: "ltr" | "rtl"
directory?: string
args?: string[]
argsClass?: string
action?: JSX.Element
onSubtitleClick?: () => void
}
/** Shared presentation for tool rows; callers own values, status, and disclosure. */
export function ToolHeader(props: ToolHeaderProps) {
const subtitle = children(() => props.subtitle)
const action = children(() => props.action)
return (
<div data-component="tool-header" data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<Show when={props.prefix}>
<span data-slot="tool-header-affix">{props.prefix}</span>
</Show>
<span data-slot="basic-tool-tool-title" class={props.titleClass}>
<Show when={props.active !== undefined} fallback={props.title}>
<TextShimmer text={props.title} active={props.active} />
</Show>
</span>
<Show when={props.suffix}>
<span data-slot="tool-header-affix">{props.suffix}</span>
</Show>
<Show when={subtitle()}>
<span
data-slot="basic-tool-tool-subtitle"
dir={props.subtitleDir}
classList={{
[props.subtitleClass ?? ""]: !!props.subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(event) => {
if (!props.onSubtitleClick) return
event.stopPropagation()
props.onSubtitleClick()
}}
>
{subtitle()}
</span>
</Show>
<For each={props.args}>
{(arg) => (
<span data-slot="basic-tool-tool-arg" class={props.argsClass}>
{arg}
</span>
)}
</For>
</div>
<Show when={props.directory}>
<span data-slot="tool-header-directory" dir="ltr">
<span>{props.directory}</span>
</span>
</Show>
<Show when={action()}>
<span data-slot="basic-tool-tool-action">{action()}</span>
</Show>
</div>
)
}
@@ -14,7 +14,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { BasicTool } from "../components/basic-tool"
import { reasoningHeading } from "../timeline/projection"
import { Card } from "@opencode-ai/ui/card"
@@ -534,30 +533,14 @@ export function AssistantReasoningContent(props: {
props.onOpenChange?.(value)
props.onContentRendered?.()
}}
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={i18n.t(props.streaming ? "ui.sessionTurn.status.thinking" : "ui.message.thought")}
active={props.streaming}
/>
</span>
<Show
when={props.streaming && !open()}
fallback={
<Show when={!props.streaming && duration()}>
{(value) => <span data-slot="basic-tool-tool-subtitle">{value()}</span>}
</Show>
}
>
<span data-slot="basic-tool-tool-subtitle">
<TextReveal text={heading()} />
</span>
</Show>
</div>
</div>
}
trigger={{
title: i18n.t(props.streaming ? "ui.sessionTurn.status.thinking" : "ui.message.thought"),
subtitle: (
<Show when={props.streaming && !open()} fallback={!props.streaming ? duration() : undefined}>
<TextReveal text={heading()} />
</Show>
),
}}
>
<PacedMarkdown text={props.content.text} cacheKey={props.id} streaming={props.streaming} />
</BasicTool>
+1
View File
@@ -1,6 +1,7 @@
@layer theme, base, components, utilities;
@import "../components/basic-tool.css" layer(components);
@import "../components/tool-header.css" layer(components);
@import "../components/file.css" layer(components);
@import "../components/markdown.css" layer(components);
@import "../components/message-part.css" layer(components);
@@ -0,0 +1,84 @@
import { createMemo, createSignal } from "solid-js"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
title: "OpenCode/Work/Tool headers",
id: "current-session-tool-headers",
component: SessionTimeline,
parameters: { layout: "fullscreen" },
}
export const SharedHeaders = {
args: { phase: "completed", pathKnown: true, longPath: false },
argTypes: { phase: { control: "select", options: ["streaming", "running", "completed"] } },
render: (args: { phase: "streaming" | "running" | "completed"; pathKnown: boolean; longPath: boolean }) => {
const [phase, setPhase] = createSignal(args.phase)
const [known, setKnown] = createSignal(args.pathKnown)
const path = (name: string) =>
args.longPath
? `src/components/session/timeline/tools/deeply/nested/directory/with/a/long/path/${"long-filename-".repeat(8)}${name}.ts`
: `src/components/${name}.ts`
const document = createMemo(() =>
storyDocument(
[
storyTool("tool_header_read", "read", phase(), { path: path("read"), offset: 12, limit: 40 }),
storyTool(
"tool_header_grep",
"grep",
phase(),
{ path: "src/components", pattern: "header", include: "*.tsx" },
{ metadata: { matches: 3 } },
),
storyTool("tool_header_shell", "shell", phase(), { command: "printf checked" }, { output: "checked" }),
storyTool(
"tool_header_execute",
"execute",
phase(),
{ code: 'console.log("checked")' },
{ output: "checked" },
),
storyTool("tool_header_webfetch", "webfetch", phase(), { url: "https://example.com/docs" }),
storyTool("tool_header_edit", "edit", phase(), {
...(known() ? { path: path("edit") } : {}),
oldString: "export const before = true\n",
newString: "export const after = true\n",
}),
storyTool("tool_header_write", "write", phase(), {
...(known() ? { path: path("write") } : {}),
content: "export const written = true\n",
}),
],
phase() !== "completed",
),
)
return (
<section class="mx-auto flex w-full max-w-[840px] flex-col gap-4 p-6">
<div class="flex flex-wrap gap-3">
<button type="button" onClick={() => setKnown(true)}>
Provide paths
</button>
<button type="button" onClick={() => setPhase("running")}>
Run tools
</button>
<button type="button" onClick={() => setPhase("completed")}>
Complete tools
</button>
<button
type="button"
onClick={() => {
setPhase(args.phase)
setKnown(args.pathKnown)
}}
>
Reset
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
},
}
+60 -119
View File
@@ -19,6 +19,7 @@ import { type SessionSummary, useData } from "../context"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { type UiI18n, useI18n } from "@opencode-ai/ui/context/i18n"
import { BasicTool, GenericTool } from "../components/basic-tool"
import { ToolHeader } from "../components/tool-header"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Collapsible } from "@opencode-ai/ui/collapsible"
@@ -560,15 +561,7 @@ export function CurrentContextToolGroup(props: {
onOpenChange={change}
trigger={
<div data-component="context-tool-group-trigger" aria-label={label().text}>
<span data-slot="context-tool-group-title">
<Show when={label().before}>
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
</Show>
<span data-slot="basic-tool-tool-title">{label().title}</span>
<Show when={label().after}>
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
</Show>
</span>
<ToolHeader title={label().title} prefix={label().before} suffix={label().after} />
</div>
}
>
@@ -689,32 +682,22 @@ export function CurrentContextToolGroup(props: {
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer
text={trigger().title}
active={
tool().state.status === "streaming" || tool().state.status === "running"
}
/>
</span>
<Show when={trigger().subtitle}>
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
<ToolHeader
title={trigger().title}
subtitle={trigger().subtitle}
args={trigger().args}
active={tool().state.status === "streaming" || tool().state.status === "running"}
action={
<Show when={trigger().matches}>
{(matches) => (
<>
<span data-slot="context-tool-group-dot" />
<span data-slot="context-tool-group-matches">{matches()}</span>
</>
)}
</Show>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
</For>
</div>
<Show when={trigger().matches}>
{(matches) => (
<>
<span data-slot="context-tool-group-dot" />
<span data-slot="context-tool-group-matches">{matches()}</span>
</>
)}
</Show>
</div>
}
/>
</div>
</div>
</div>
@@ -1209,28 +1192,23 @@ ToolRegistry.register({
{...props}
hideDetails
icon="window-cursor"
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
</span>
<Show when={!pending() && url()}>
<a
data-slot="basic-tool-tool-subtitle"
class="webfetch-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
<span data-slot="webfetch-link-text">{url()}</span>
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
</a>
</Show>
</div>
</div>
}
trigger={{
title: i18n.t("ui.tool.webfetch"),
subtitle: (
<Show when={!pending() && url()}>
<a
class="webfetch-link"
href={url()}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => event.stopPropagation()}
>
<span data-slot="webfetch-link-text">{url()}</span>
<Icon name="outline-square-arrow" class="webfetch-link-icon" />
</a>
</Show>
),
}}
/>
)
},
@@ -1443,16 +1421,15 @@ ToolRegistry.register({
compact
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<ToolHeader
title={i18n.t("ui.tool.execute")}
active={pending()}
subtitle={
<Show when={!open() && code()}>
<ShellSubmessage text={code().split("\n")[0]} animate={sawPending} />
</Show>
</div>
</div>
}
/>
)}
>
<ConsoleOutput copy={code()} variant="shell">
@@ -1532,25 +1509,20 @@ ToolRegistry.register({
compact
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<ToolHeader
title={i18n.t("ui.tool.shell")}
active={pending()}
subtitle={
<Show when={!open()}>
<Show
when={command()}
fallback={
<Show when={streaming()}>
<span data-slot="basic-tool-tool-subtitle">{i18n.t("ui.tool.shell.writingCommand")}</span>
</Show>
}
fallback={<Show when={streaming()}>{i18n.t("ui.tool.shell.writingCommand")}</Show>}
>
{(command) => <ShellSubmessage text={command()} animate={sawStreaming} />}
</Show>
</Show>
</div>
</div>
}
/>
)}
>
<ConsoleOutput copy={command()} variant="shell">
@@ -1663,30 +1635,13 @@ ToolRegistry.register({
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={!pending() && inputPath().includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(inputPath())}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={!pending() ? diff() : undefined}>
{(diff) => <DiffChanges appearance="standard" changes={diff()} />}
</Show>
</div>
</div>
}
trigger={{
title: i18n.t("ui.messagePart.title.edit"),
subtitle: !pending() ? filename() : undefined,
subtitleDir: "ltr",
directory: !pending() && inputPath().includes("/") ? displayDirectory(inputPath()) : undefined,
action: <Show when={diff()}>{(diff) => <DiffChanges appearance="standard" changes={diff()} />}</Show>,
}}
>
<Show when={path()}>
<ToolFileAccordion
@@ -1732,26 +1687,12 @@ ToolRegistry.register({
icon="code-lines"
rail={false}
defer={props.deferContent !== false}
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
</Show>
</div>
<Show when={!pending() && path().includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(path())}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
</div>
}
trigger={{
title: i18n.t("ui.messagePart.title.write"),
subtitle: !pending() ? filename() : undefined,
subtitleDir: "ltr",
directory: !pending() && path().includes("/") ? displayDirectory(path()) : undefined,
}}
>
<Show when={content() && path()}>
<ToolFileAccordion path={path()}>