feat(session-ui): group adjacent reads into one row (#52207)

This commit is contained in:
Luke Parker
2026-09-30 12:54:40 +10:00
committed by GitHub
Unverified
parent 74dbc509d7
commit 1b02abcfab
11 changed files with 627 additions and 136 deletions
@@ -0,0 +1,68 @@
import { expect, story } from "../../storybook/playwright/story"
const runs = [
"index.tsx, main.ts, rpc.ts, model.ts, toolbar.tsx",
"connection.ts limit=120, index.tsx, environment.tsx, env.d.ts",
"index.tsx, button.tsx, toggle.ts, index.tsx",
"context.tsx",
]
story("collapses each run of reads inside a Used group into one row", async ({ mount }) => {
const root = await mount("current-read-group--used-group", { args: { width: "960" } })
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.getByRole("button", { name: "Used 65 Read, Shell", exact: true })).toBeVisible()
const reads = group.locator('[data-component="read-tool-group"]')
await expect(reads).toHaveCount(9)
await expect(reads.locator('[data-slot="basic-tool-tool-subtitle"]').first()).toHaveText(runs[0]!)
for (const [index, text] of runs.entries())
await expect(reads.nth(index).locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText(text)
await expect(reads.first().locator('[data-slot="read-tool-group-file"]')).toHaveCount(5)
await expect(reads.nth(1).locator('[data-slot="read-tool-group-file"]').first()).toHaveAttribute(
"data-timeline-part-id",
"read_group_read_2_0",
)
await expect(group.locator('[data-slot="context-tool-group-item"]').nth(1)).toHaveText(/^Thought/)
const thought = group.locator('[data-component="reasoning-part"]').first()
const color = (locator: typeof thought, slot: string) =>
locator
.locator(`[data-slot="${slot}"]`)
.first()
.evaluate((node) => getComputedStyle(node).color)
expect(await color(reads.first(), "basic-tool-tool-title")).toBe(await color(thought, "basic-tool-tool-title"))
expect(await color(reads.first(), "basic-tool-tool-subtitle")).toBe(await color(thought, "basic-tool-tool-subtitle"))
const box = await reads.first().boundingBox()
expect(box?.height).toBe(28)
})
story("truncates long read rows and expands them in place", async ({ mount }) => {
const root = await mount("current-read-group--used-group", { args: { width: "420" } })
const reads = root.locator('[data-component="read-tool-group"]')
await expect(reads.first()).not.toHaveAttribute("role", "button")
const row = reads.filter({ hasText: "runner.ts" })
const files = row.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(row).toHaveAttribute("role", "button")
await expect(row).toHaveAttribute("aria-expanded", "false")
await expect(files).toHaveCSS("text-overflow", "ellipsis")
const collapsed = (await row.boundingBox())!.height
await row.click()
await expect(row).toHaveAttribute("aria-expanded", "true")
await expect(files).toHaveCSS("white-space", "normal")
expect((await row.boundingBox())!.height).toBeGreaterThan(collapsed)
expect(await files.evaluate((node) => node.scrollWidth <= node.clientWidth)).toBe(true)
await row.press("Enter")
await expect(row).toHaveAttribute("aria-expanded", "false")
expect((await row.boundingBox())!.height).toBe(collapsed)
await row.press(" ")
await expect(row).toHaveAttribute("aria-expanded", "true")
})
story("merges adjacent reads when tools render outside a Used group", async ({ mount }) => {
const root = await mount("current-read-group--without-used-group", { args: { width: "960" } })
await expect(root.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
const reads = root.locator('[data-timeline-row="AssistantPart"] [data-component="read-tool-group"]')
await expect(reads).toHaveCount(9)
for (const [index, text] of runs.entries())
await expect(reads.nth(index).locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText(text)
await expect(root.locator('[data-component="reasoning-part"]')).toHaveCount(8)
})
@@ -1420,6 +1420,90 @@
}
}
[data-component="read-tool-group"] {
--read-tool-group-height: 32px;
width: 100%;
min-width: 0;
min-height: var(--read-tool-group-height);
display: flex;
align-items: center;
border-radius: var(--radius-md);
&[role="button"] {
cursor: pointer;
}
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
}
[data-slot="read-tool-group-file"] {
white-space: nowrap;
/* Args carry a leading space for their accessible text; the margin completes the 6px row gap. */
> [data-slot="basic-tool-tool-arg"] {
margin-inline-start: 2px;
}
}
[data-slot="read-tool-group-arrow"] {
flex-shrink: 0;
width: 16px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-weaker);
opacity: 0;
transform: rotate(-90deg);
transition:
opacity 0.15s ease,
transform 0.15s ease;
&:dir(rtl) {
transform: rotate(90deg);
}
@media (hover: none) {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
&:hover [data-slot="read-tool-group-arrow"],
&:focus-visible [data-slot="read-tool-group-arrow"] {
opacity: 1;
}
&[data-expanded="true"] {
/* Wrapped lines use body line height; padding keeps the first line where the collapsed row placed it. */
padding-block: calc((var(--read-tool-group-height) - var(--line-height-base)) / 2);
> [data-component="tool-trigger"] {
align-items: flex-start;
}
[data-slot="basic-tool-tool-subtitle"] {
overflow: visible;
white-space: normal;
line-height: var(--line-height-base);
}
[data-slot="read-tool-group-arrow"] {
opacity: 1;
transform: none;
}
}
}
[data-slot="context-tool-group-item"] > [data-component="read-tool-group"] {
--read-tool-group-height: 28px;
}
:root body [data-component="user-message"] {
font-weight: 440;
@@ -1,12 +1,13 @@
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageUser,
} from "@opencode/client/promise"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageUser } from "@opencode/client/promise"
import { Match, Switch, type ComponentProps } from "solid-js"
import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import { CurrentContextToolGroup, CurrentFileToolGroup, ToolDisplay } from "../tools/tool-renderer"
import {
CurrentContextToolGroup,
CurrentFileToolGroup,
CurrentReadToolGroup,
ToolDisplay,
} from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions"
@@ -102,6 +103,10 @@ export function SessionContextToolGroup(props: ComponentProps<typeof CurrentCont
return <CurrentContextToolGroup {...props} />
}
export function SessionReadToolGroup(props: ComponentProps<typeof CurrentReadToolGroup>) {
return <CurrentReadToolGroup {...props} />
}
export function SessionFileToolGroup(props: {
tools: SessionMessageAssistantTool[]
fileOpen: (path: string) => boolean | undefined
@@ -65,6 +65,21 @@ export function currentToolHasLoadedFiles(tool: SessionMessageAssistantTool) {
return Array.isArray(loaded) && loaded.some((path) => typeof path === "string")
}
export function readImagePath(input: Record<string, unknown>) {
if (typeof input.path !== "string" || !/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(input.path)) return
return input.path.replaceAll("\\", "/")
}
// Plain file reads render as one comma-separated row; images and loaded instructions keep their own rows.
export function currentToolGroupedRead(tool: SessionMessageAssistantTool) {
return (
tool.name === "read" &&
tool.state.status !== "error" &&
!readImagePath(currentToolInput(tool)) &&
!currentToolHasLoadedFiles(tool)
)
}
export function currentContentDefaultOpen(
content: SessionMessageAssistant["content"][number],
shellExpanded: boolean,
@@ -8,7 +8,12 @@ import type {
} from "@opencode/client/promise"
import { Option, Schema } from "effect"
import { createMemo, mapArray, type Accessor } from "solid-js"
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
import {
currentContentDefaultOpen,
currentToolFailed,
currentToolGroupedRead,
currentToolHasLoadedFiles,
} from "../message/current-tool-state"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
@@ -602,7 +607,7 @@ function groupContent(
detail?: TimelineDetail,
): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
let adjacent: { type: "context" | "file" | "read"; refs: PartRef[]; tools: boolean } | undefined
const flush = () => {
const current = adjacent
const first = current?.refs[0]
@@ -615,7 +620,7 @@ function groupContent(
return
}
groups.push({
type: current.type === "context" ? "context" : "file",
type: current.type,
key:
current.type !== "context"
? `part:${first.messageID}:${first.partID}`
@@ -669,6 +674,7 @@ function toolGroupType(
if (content.name === "question" && !currentToolFailed(content)) return undefined
const category = timelineCategory(content)!
if (detail[category].placement === "grouped") return "context"
if (currentToolGroupedRead(content)) return "read"
if (currentToolFailed(content)) return undefined
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
return undefined
@@ -1,9 +1,5 @@
import { describe, expect, test } from "bun:test"
import type {
SessionMessageAssistant,
SessionMessageAssistantTool,
SessionMessageInfo,
} from "@opencode/client/promise"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode/client/promise"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { timelinePresets } from "./detail"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
@@ -862,6 +858,52 @@ describe("current session timeline rows", () => {
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
})
test("merges adjacent separate reads into one read row split by other parts", () => {
const rows = Timeline.constructSessionMessageRows(
storyDocument([
storyTool("read_1", "read", "completed", { path: "src/a.ts" }),
storyTool("read_2", "read", "running", { path: "src/b.ts", limit: 120 }),
{ type: "reasoning", text: "Keep reading." },
storyTool("read_3", "read", "completed", { path: "src/c.ts" }),
storyTool("read_image", "read", "completed", { path: "src/logo.png" }),
storyTool("read_failed", "read", "error", { path: "src/missing.ts" }),
storyTool("read_4", "read", "completed", { path: "src/d.ts" }),
storyTool("read_5", "read", "completed", { path: "src/e.ts" }),
storyTool("glob", "glob", "completed", { pattern: "*.ts" }),
storyTool("read_6", "read", "completed", { path: "src/f.ts" }),
]).messages,
true,
{ type: "idle" },
undefined,
false,
false,
undefined,
timelinePresets[0].value,
).rows
expect(
rows.flatMap((row) =>
row._tag !== "AssistantPart"
? []
: [
[
row.group.type,
...(row.group.type === "part" ? [row.group.ref] : row.group.refs).map((ref) => ref.partID),
],
],
),
).toEqual([
["read", "read_1", "read_2"],
["part", "msg_tool_projection_assistant:reasoning:0"],
["read", "read_3"],
["part", "read_image"],
["part", "read_failed"],
["read", "read_4", "read_5"],
["part", "glob"],
["read", "read_6"],
])
})
test.each(["shell", "execute", "subagent"])("keeps %s in an existing group throughout execution", (name) => {
const initial = createTimelineProjection({
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
@@ -14,6 +14,7 @@ import {
SessionAssistantContent,
SessionContextToolGroup,
SessionFileToolGroup,
SessionReadToolGroup,
SessionShellMessage,
SessionUserMessage,
currentContentDefaultOpen,
@@ -210,6 +211,26 @@ export function createSessionTimelineRowRenderer(input: {
)
}
if (row().group.type === "read") {
const tools = createMemo(() => {
const group = row().group
if (group.type !== "read") return []
const contents = indexGroupContents(group.refs)
return group.refs.flatMap((ref) => {
const content = contents.get(ref.messageID)?.get(ref.partID)
return content?.type === "tool" ? [content] : []
})
})
return (
<SessionReadToolGroup
tools={tools()}
open={input.disclosure.value(row().group.key)}
onOpenChange={(open) => input.disclosure.set(row().group.key, open)}
onSizeChange={onSizeChange}
/>
)
}
if (row().group.type === "file") {
const tools = createMemo(() => {
const group = row().group
@@ -2,6 +2,7 @@ import { For, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import type { SessionUserActions } from "../actions"
import type { TimelineDetail } from "./detail"
import { createReactiveTimelineProjection, TimelineRow, type ReasoningMode } from "./projection"
import { createSessionTimelineRowRenderer, type SessionUserPresentation } from "./session-timeline-row"
@@ -14,6 +15,7 @@ export type SessionTimelineProps = {
reasoningMode?: ReasoningMode
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
timelineDetail?: TimelineDetail
class?: string
}
@@ -24,6 +26,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
reasoningMode: () => props.reasoningMode ?? "compact",
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
timelineDetail: props.timelineDetail && (() => props.timelineDetail!),
})
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>({})
const renderer = createSessionTimelineRowRenderer({
@@ -35,6 +38,7 @@ export function SessionTimeline(props: SessionTimelineProps) {
reasoningMode: () => props.reasoningMode ?? "compact",
shellToolDefaultOpen: () => props.shellToolDefaultOpen ?? false,
editToolDefaultOpen: () => props.editToolDefaultOpen ?? false,
timelineDetail: props.timelineDetail && (() => props.timelineDetail!),
disclosure: {
value: (key) => toolOpen[key],
set: (key, open) => setToolOpen(key, open),
@@ -18,7 +18,7 @@ export type PartGroup =
}
| {
key: string
type: "file"
type: "file" | "read"
refs: PartRef[]
}
@@ -0,0 +1,145 @@
import type { JsonValue, SessionMessageAssistant } from "@opencode/client/promise"
import { createSignal } from "solid-js"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { STORY_TIME } from "../storybook/current-session-fixtures"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "../timeline/session-timeline"
import { timelinePresets } from "../timeline/detail"
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
export default {
title: "OpenCode/Work/Read group",
id: "current-read-group",
component: CurrentContextToolGroup,
args: { width: 600 },
argTypes: { width: { control: { type: "range", min: 280, max: 960, step: 10 } } },
}
type Read = string | [string, Record<string, JsonValue>]
// Mirrors a real exploration turn: runs of reads split by thoughts, with a few shell calls.
const steps: (Read[] | { thought: number } | { shell: string })[] = [
["src/index.tsx", "src/main.ts", "src/rpc.ts", "src/model.ts", "src/toolbar.tsx"],
{ thought: 4 },
[["src/connection.ts", { limit: 120 }], "src/app/index.tsx", "src/environment.tsx", "src/env.d.ts"],
{ thought: 3 },
["src/components/index.tsx", "src/components/button.tsx", "src/components/toggle.ts", "src/hooks/index.tsx"],
{ thought: 4 },
["src/context.tsx"],
{ thought: 4 },
[
"src/plugin/host.ts",
"src/plugin/surfaces.ts",
"src/plugin/builtins.ts",
"src/plugin/manager.ts",
"src/plugin/module.ts",
"src/plugin/registry.ts",
"src/plugin/loader.ts",
],
{ thought: 2 },
{ shell: "bun typecheck" },
[
"src/session/index.ts",
"src/session/prompt.ts",
["src/session/runner.ts", { offset: 200, limit: 80 }],
"src/session/store.ts",
"src/session/inbox.ts",
"src/session/compaction.ts",
"src/session/execution.ts",
"src/session/history.ts",
"src/session/events.ts",
"src/session/projection.ts",
],
{ thought: 5 },
[
"src/tui/app.tsx",
"src/tui/theme.ts",
"src/tui/keymap.ts",
"src/tui/layout.tsx",
"src/tui/prompt.tsx",
"src/tui/timeline.tsx",
"src/tui/sidebar.tsx",
"src/tui/dialog.tsx",
"src/tui/toast.tsx",
"src/tui/status.tsx",
"src/tui/scroll.ts",
"src/tui/input.ts",
],
{ shell: "git status --short" },
{ thought: 3 },
[
"src/server/index.ts",
"src/server/routes.ts",
"src/server/auth.ts",
"src/server/cors.ts",
"src/server/events.ts",
"src/server/session.ts",
"src/server/project.ts",
"src/server/file.ts",
"src/server/config.ts",
],
{ thought: 4 },
[
"src/config/index.ts",
"src/config/agent.ts",
"src/config/model.ts",
"src/config/provider.ts",
"src/config/permission.ts",
"src/config/theme.ts",
"src/config/keybind.ts",
"src/config/mcp.ts",
"src/config/lsp.ts",
"src/config/formatter.ts",
"src/config/plugin.ts",
],
]
const parts = steps.flatMap<ContextGroupPart>((step, index) => {
if ("thought" in step)
return [
{
type: "reasoning",
id: `read_group_thought_${index}`,
text: "Keep tracing where the renderer groups these tool rows before changing anything.",
time: { created: STORY_TIME, completed: STORY_TIME + step.thought * 1000 },
},
]
if ("shell" in step)
return [storyTool(`read_group_shell_${index}`, "shell", "completed", { command: step.shell }, { output: "ok" })]
return step.map((read, item) => {
const [path, args] = typeof read === "string" ? [read, {}] : read
return storyTool(`read_group_read_${index}_${item}`, "read", "completed", { path, ...args })
})
})
const content = parts.flatMap<SessionMessageAssistant["content"][number]>((part) => {
if (part.type === "tool") return [part]
if (part.type === "reasoning") return [{ type: "reasoning", text: part.text, time: part.time }]
return []
})
export const UsedGroup = {
render: (args: { width: number }) => {
const [open, setOpen] = createSignal(true)
return (
<section style={{ width: "100%", "max-width": `${args.width}px`, padding: "16px" }}>
<CurrentSessionProviders document={storyDocument(content)}>
<CurrentContextToolGroup parts={parts} busy={false} open={open()} onOpenChange={setOpen} />
</CurrentSessionProviders>
</section>
)
},
}
export const WithoutUsedGroup = {
render: (args: { width: number }) => {
const document = storyDocument(content)
return (
<section style={{ width: "100%", "max-width": `${args.width}px`, padding: "16px" }}>
<CurrentSessionProviders document={document}>
<SessionTimeline document={document} timelineDetail={timelinePresets[0].value} />
</CurrentSessionProviders>
</section>
)
},
}
+222 -121
View File
@@ -13,8 +13,11 @@ import {
Show,
Switch,
Index,
on,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import stripAnsi from "strip-ansi"
import { createTwoFilesPatch, diffLines } from "diff"
import { Dynamic } from "solid-js/web"
@@ -45,11 +48,12 @@ import type {
} from "@opencode/client/promise"
import {
currentToolError,
currentToolHasLoadedFiles,
currentToolGroupedRead,
currentToolInput,
currentToolMetadata,
currentToolOutput,
executeToolFailed,
readImagePath,
} from "../message/current-tool-state"
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
import { followShellOutput } from "./shell-output"
@@ -276,10 +280,11 @@ function readToolPath(input: Record<string, unknown>) {
return undefined
}
function readImagePath(input: Record<string, unknown>) {
const path = readToolPath(input)
if (!path || !/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(path)) return
return path.replaceAll("\\", "/")
function readArgs(input: Record<string, unknown>) {
return [
...(typeof input.offset === "number" ? [`offset=${input.offset}`] : []),
...(typeof input.limit === "number" ? [`limit=${input.limit}`] : []),
]
}
function skillToolName(input: Record<string, unknown>, metadata?: Record<string, unknown>) {
@@ -569,6 +574,15 @@ export function CurrentContextToolGroup(props: {
previous.push(tool)
return groups
}
if (
currentToolGroupedRead(tool) &&
Array.isArray(previous) &&
previous[0] &&
currentToolGroupedRead(previous[0])
) {
previous.push(tool)
return groups
}
if (
tool.name === "skill" &&
tool.state.status !== "error" &&
@@ -692,121 +706,130 @@ export function CurrentContextToolGroup(props: {
return (
<div data-slot="context-tool-group-item">
<Show
when={
tool().state.status !== "error" &&
["read", "glob", "grep", "list"].includes(tool().name) &&
!(tool().name === "read" && readImagePath(currentToolInput(tool()))) &&
!currentToolHasLoadedFiles(tool())
}
when={!currentToolGroupedRead(tool())}
fallback={
<Show
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
fallback={
<Show
when={isFileChangeTool(tool())}
fallback={
<ToolDisplay
id={tool().id}
tool={tool().name}
input={currentToolInput(tool())}
metadata={currentToolMetadata(tool())}
output={currentToolOutput(tool())}
error={currentToolError(tool())}
status={tool().state.status}
defaultOpen={props.toolDefaultOpen?.(tool()) ?? false}
open={props.toolOpen?.(tool().id) ?? props.toolDefaultOpen?.(tool())}
onOpenChange={(open) => props.onToolOpenChange?.(tool().id, open)}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
}
>
<CurrentFileToolGroup
tools={group()}
fileOpen={
props.fileOpen &&
((path) => props.fileOpen?.(`${patchKeys().get(tool())}:${path}`))
}
onFileOpenChange={
props.onFileOpenChange &&
((path, open) =>
props.onFileOpenChange?.(`${patchKeys().get(tool())}:${path}`, open))
}
onSizeChange={props.onSizeChange}
/>
</Show>
}
>
<div
data-component="tool-loaded-item"
data-timeline-part-ids={group()
.map((item) => item.id)
.join(",")}
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
name: skills().join(", "),
})}
>
<span data-slot="tool-loaded-label" aria-hidden="true">
{loaded().split(marker)[0]?.trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
<For each={skills()}>
{(name, index) => (
<>
<Show when={index() > 0}>, </Show>
<TextShimmer
as="span"
text={name}
active={["streaming", "running"].includes(group()[index()]!.state.status)}
/>
</>
)}
</For>
</span>
<Show when={loaded().split(marker)[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
</Show>
<CurrentReadToolGroup
tools={group()}
open={props.toolOpen?.(tool().id)}
onOpenChange={(open) => props.onToolOpenChange?.(tool().id, open)}
onSizeChange={props.onSizeChange}
/>
}
>
<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"
}
<Show
when={tool().state.status !== "error" && ["glob", "grep", "list"].includes(tool().name)}
fallback={
<Show
when={
tool().name === "skill" && group().length > 1 && skills().length === group().length
}
fallback={
<Show
when={isFileChangeTool(tool())}
fallback={
<ToolDisplay
id={tool().id}
tool={tool().name}
input={currentToolInput(tool())}
metadata={currentToolMetadata(tool())}
output={currentToolOutput(tool())}
error={currentToolError(tool())}
status={tool().state.status}
defaultOpen={props.toolDefaultOpen?.(tool()) ?? false}
open={props.toolOpen?.(tool().id) ?? props.toolDefaultOpen?.(tool())}
onOpenChange={(open) => props.onToolOpenChange?.(tool().id, open)}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
</span>
<Show when={trigger().subtitle}>
{(subtitle) => <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>}
</Show>
<For each={trigger().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
}
>
<CurrentFileToolGroup
tools={group()}
fileOpen={
props.fileOpen &&
((path) => props.fileOpen?.(`${patchKeys().get(tool())}:${path}`))
}
onFileOpenChange={
props.onFileOpenChange &&
((path, open) =>
props.onFileOpenChange?.(`${patchKeys().get(tool())}:${path}`, open))
}
onSizeChange={props.onSizeChange}
/>
</Show>
}
>
<div
data-component="tool-loaded-item"
data-timeline-part-ids={group()
.map((item) => item.id)
.join(",")}
aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, {
name: skills().join(", "),
})}
>
<span data-slot="tool-loaded-label" aria-hidden="true">
{loaded().split(marker)[0]?.trim()}
</span>
<span data-slot="tool-loaded-value" aria-hidden="true">
<For each={skills()}>
{(name, index) => (
<>
<Show when={index() > 0}>, </Show>
<TextShimmer
as="span"
text={name}
active={["streaming", "running"].includes(group()[index()]!.state.status)}
/>
</>
)}
</For>
</div>
<Show when={trigger().matches}>
{(matches) => (
<>
<span data-slot="context-tool-group-dot" />
<span data-slot="context-tool-group-matches">{matches()}</span>
</>
</span>
<Show when={loaded().split(marker)[1]?.trim()}>
{(suffix) => (
<span data-slot="tool-loaded-kind" aria-hidden="true">
{suffix()}
</span>
)}
</Show>
</div>
</Show>
}
>
<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>}
</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>
</div>
</Show>
</Show>
</div>
)
@@ -821,6 +844,94 @@ export function CurrentContextToolGroup(props: {
)
}
export function CurrentReadToolGroup(props: {
tools: SessionMessageAssistantTool[]
open?: boolean
onOpenChange?: (open: boolean) => void
onSizeChange?: () => void
}) {
const i18n = useI18n()
const [state, setState] = createStore({ open: false, truncated: false })
const open = () => props.open ?? state.open
const expandable = () => open() || state.truncated
const pending = createMemo(() =>
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
)
const files = createMemo(() =>
props.tools.flatMap((tool) => {
const input = currentToolInput(tool)
const name = getFilename(readToolPath(input) ?? "")
return name ? [{ id: tool.id, name, args: readArgs(input) }] : []
}),
)
let list: HTMLSpanElement | undefined
const measure = () => {
if (!list || open()) return
setState("truncated", list.scrollWidth > list.clientWidth)
}
createResizeObserver(() => list, measure)
createEffect(on(files, measure))
const toggle = () => {
if (!expandable()) return
const next = !open()
setState("open", next)
props.onOpenChange?.(next)
props.onSizeChange?.()
}
return (
<div
data-component="read-tool-group"
data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}
data-expanded={open() ? "true" : undefined}
role={expandable() ? "button" : undefined}
tabIndex={expandable() ? 0 : undefined}
aria-expanded={expandable() ? open() : undefined}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<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={i18n.t("ui.tool.read")} active={pending()} />
</span>
<span ref={list} data-slot="basic-tool-tool-subtitle">
<Index each={files()}>
{(file, index) => (
<>
<Show when={index > 0}> </Show>
<span data-slot="read-tool-group-file" data-timeline-part-id={file().id}>
{file().name}
<For each={file().args}>
{(arg) => <span data-slot="basic-tool-tool-arg">{` ${arg}`}</span>}
</For>
<Show when={index < files().length - 1}>,</Show>
</span>
</>
)}
</Index>
</span>
</div>
</div>
</div>
</div>
<Show when={expandable()}>
<span data-slot="read-tool-group-arrow" aria-hidden="true">
<Icon name="fill-triangle-down" size="small" />
</span>
</Show>
</div>
</div>
)
}
export function CurrentFileToolGroup(props: {
tools: SessionMessageAssistantTool[]
fileOpen?: (path: string) => boolean | undefined
@@ -950,13 +1061,6 @@ function currentContextToolTrigger(tool: SessionMessageAssistantTool, i18n: Retu
typeof count === "number" && Number.isFinite(count) && count !== 0
? i18n.plural("ui.messagePart.context.match", count)
: undefined
if (tool.name === "read") {
const args = [
...(typeof input.offset === "number" ? [`offset=${input.offset}`] : []),
...(typeof input.limit === "number" ? [`limit=${input.limit}`] : []),
]
return { title: i18n.t("ui.tool.read"), subtitle: getFilename(path), args, matches: undefined }
}
if (tool.name === "list")
return { title: i18n.t("ui.tool.list"), subtitle: displayDirectory(path), args: [], matches: undefined }
if (tool.name === "glob")
@@ -1297,9 +1401,6 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const image = createMemo(() => (props.status === "completed" ? readImagePath(props.input) : undefined))
const args: string[] = []
if (typeof props.input.offset === "number") args.push("offset=" + props.input.offset)
if (typeof props.input.limit === "number") args.push("limit=" + props.input.limit)
const loaded = createMemo(() => {
if (props.status !== "completed") return []
const value = props.metadata.loaded
@@ -1328,7 +1429,7 @@ ToolRegistry.register({
trigger={{
title: i18n.t("ui.tool.read"),
subtitle: getFilename(readToolPath(props.input) ?? ""),
args,
args: readArgs(props.input),
}}
>
<Show when={image()} keyed>