mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 01:31:22 +03:00
feat(session-ui): group adjacent reads into one row (#52207)
This commit is contained in:
committed by
GitHub
Unverified
parent
74dbc509d7
commit
1b02abcfab
@@ -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>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user