From 1b02abcfab10abd37588227b60a5be72f94bc07a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:54:40 +1000 Subject: [PATCH] feat(session-ui): group adjacent reads into one row (#52207) --- .../component-tests/read-group.spec.ts | 68 ++++ .../src/components/message-part.css | 84 +++++ .../src/message/current-message.tsx | 17 +- .../src/message/current-tool-state.ts | 15 + .../session-ui/src/timeline/projection.ts | 12 +- .../src/timeline/rows-current.test.ts | 52 ++- .../src/timeline/session-timeline-row.tsx | 21 ++ .../src/timeline/session-timeline.tsx | 4 + .../session-ui/src/timeline/timeline-row.ts | 2 +- .../src/tools/read-group.stories.tsx | 145 ++++++++ .../session-ui/src/tools/tool-renderer.tsx | 343 ++++++++++++------ 11 files changed, 627 insertions(+), 136 deletions(-) create mode 100644 packages/session-ui/component-tests/read-group.spec.ts create mode 100644 packages/session-ui/src/tools/read-group.stories.tsx diff --git a/packages/session-ui/component-tests/read-group.spec.ts b/packages/session-ui/component-tests/read-group.spec.ts new file mode 100644 index 00000000000..46912250c4b --- /dev/null +++ b/packages/session-ui/component-tests/read-group.spec.ts @@ -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) +}) diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index c30c36b9115..62f94196cc1 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -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; diff --git a/packages/session-ui/src/message/current-message.tsx b/packages/session-ui/src/message/current-message.tsx index 73552e6e47d..883a861854f 100644 --- a/packages/session-ui/src/message/current-message.tsx +++ b/packages/session-ui/src/message/current-message.tsx @@ -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 } +export function SessionReadToolGroup(props: ComponentProps) { + return +} + export function SessionFileToolGroup(props: { tools: SessionMessageAssistantTool[] fileOpen: (path: string) => boolean | undefined diff --git a/packages/session-ui/src/message/current-tool-state.ts b/packages/session-ui/src/message/current-tool-state.ts index 0cf9028daa8..6be1bc932af 100644 --- a/packages/session-ui/src/message/current-tool-state.ts +++ b/packages/session-ui/src/message/current-tool-state.ts @@ -65,6 +65,21 @@ export function currentToolHasLoadedFiles(tool: SessionMessageAssistantTool) { return Array.isArray(loaded) && loaded.some((path) => typeof path === "string") } +export function readImagePath(input: Record) { + 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, diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index a9f31918e2e..f51ab32fb96 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -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 diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 7f6af683ba5..d14c0612815 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -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, diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index b56109cc7ab..55bf15e5414 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -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 ( + input.disclosure.set(row().group.key, open)} + onSizeChange={onSizeChange} + /> + ) + } + if (row().group.type === "file") { const tools = createMemo(() => { const group = row().group diff --git a/packages/session-ui/src/timeline/session-timeline.tsx b/packages/session-ui/src/timeline/session-timeline.tsx index dcb2f985e65..e32554d7771 100644 --- a/packages/session-ui/src/timeline/session-timeline.tsx +++ b/packages/session-ui/src/timeline/session-timeline.tsx @@ -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>({}) 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), diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 752375beb88..2569260b8e9 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -18,7 +18,7 @@ export type PartGroup = } | { key: string - type: "file" + type: "file" | "read" refs: PartRef[] } diff --git a/packages/session-ui/src/tools/read-group.stories.tsx b/packages/session-ui/src/tools/read-group.stories.tsx new file mode 100644 index 00000000000..5d6ac031b15 --- /dev/null +++ b/packages/session-ui/src/tools/read-group.stories.tsx @@ -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] + +// 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((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((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 ( +
+ + + +
+ ) + }, +} + +export const WithoutUsedGroup = { + render: (args: { width: number }) => { + const document = storyDocument(content) + return ( +
+ + + +
+ ) + }, +} diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index ba3f9084406..cbafd69e588 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -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) { return undefined } -function readImagePath(input: Record) { - 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) { + return [ + ...(typeof input.offset === "number" ? [`offset=${input.offset}`] : []), + ...(typeof input.limit === "number" ? [`limit=${input.limit}`] : []), + ] } function skillToolName(input: Record, metadata?: Record) { @@ -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 (
1 && skills().length === group().length} - fallback={ - props.onToolOpenChange?.(tool().id, open)} - deferContent - virtualizeDiff={false} - onContentRendered={props.onSizeChange} - /> - } - > - props.fileOpen?.(`${patchKeys().get(tool())}:${path}`)) - } - onFileOpenChange={ - props.onFileOpenChange && - ((path, open) => - props.onFileOpenChange?.(`${patchKeys().get(tool())}:${path}`, open)) - } - onSizeChange={props.onSizeChange} - /> - - } - > -
item.id) - .join(",")} - aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, { - name: skills().join(", "), - })} - > - - - - {(suffix) => ( - - )} - -
-
+ props.onToolOpenChange?.(tool().id, open)} + onSizeChange={props.onSizeChange} + /> } > -
-
-
-
-
- - 1 && skills().length === group().length + } + fallback={ + props.onToolOpenChange?.(tool().id, open)} + deferContent + virtualizeDiff={false} + onContentRendered={props.onSizeChange} /> - - - {(subtitle) => {subtitle()}} - - - {(arg) => {arg}} + } + > + props.fileOpen?.(`${patchKeys().get(tool())}:${path}`)) + } + onFileOpenChange={ + props.onFileOpenChange && + ((path, open) => + props.onFileOpenChange?.(`${patchKeys().get(tool())}:${path}`, open)) + } + onSizeChange={props.onSizeChange} + /> + + } + > +
item.id) + .join(",")} + aria-label={i18n.plural("ui.tool.loadedSkills", skills().length, { + name: skills().join(", "), + })} + > + +
- - {(matches) => ( - <> - - {matches()} - + + + {(suffix) => ( + )}
+ + } + > +
+
+
+
+
+ + + + + {(subtitle) => {subtitle()}} + + + {(arg) => {arg}} + +
+ + {(matches) => ( + <> + + {matches()} + + )} + +
+
-
+
) @@ -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 ( +
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() + }} + > +
+
+
+
+
+ + + + + + {(file, index) => ( + <> + 0}> + + {file().name} + + {(arg) => {` ${arg}`}} + + , + + + )} + + +
+
+
+
+ + + +
+
+ ) +} + 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), }} >