Compare commits

...
2 Commits
15 changed files with 286 additions and 89 deletions
@@ -47,5 +47,10 @@ test("renders a completed single-file patch", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const file = wrapper.locator('[data-scope="apply-patch"]')
await expect(file.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(0)
await file.getByRole("button").click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
})
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
test("keeps patch file disclosures independent", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
@@ -21,15 +21,17 @@ test("preserves nested patch file state through outer collapse and reopen", asyn
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "false")
await outer.click()
await expect(outer).toHaveAttribute("aria-expanded", "true")
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
@@ -69,9 +69,43 @@ test.describe("session timeline projection", () => {
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("combines adjacent patch calls into one file group", async ({ page }) => {
const first = "prt_patch_first"
const second = "prt_patch_second"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(first, "patch", "completed", { patchText: "Update src/first.ts" }, {
metadata: { files: [patchFile("src/first.ts", "modified")] },
}),
toolPart(second, "patch", "completed", { patchText: "Update src/second.ts" }, {
metadata: { files: [patchFile("src/second.ts", "added")] },
}),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group).toBeVisible()
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByRole("button", { name: "Patch 2 files" })).toHaveCount(0)
await expect(group.getByRole("button")).toHaveCount(2)
await expect(group.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(2)
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts", "second.ts"])
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
@@ -196,11 +230,7 @@ function patchPart(id: string) {
{ patchText: "Update the projected files" },
{
metadata: {
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
],
files: [patchFile("src/a.ts", "modified")],
},
},
)
@@ -357,7 +357,7 @@ function MessageTimelineView(
deferred={(row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
const content = Timeline.resolveContent(messageByID().get(row.group.ref.messageID), row.group.ref.partID)
return content?.type === "tool" && ["edit", "write", "patch"].includes(content.name)
return content?.type === "tool" && ["edit", "write"].includes(content.name)
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
@@ -255,16 +255,29 @@ export function BasicTool(props: BasicToolProps) {
)
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible open={open()} onOpenChange={props.locked ? undefined : handleOpenChange} class="tool-collapsible">
<Show
when={props.triggerAsLink || props.triggerHref}
when={!props.locked && (props.triggerAsLink || props.triggerHref)}
fallback={
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
<Show
when={!props.locked}
fallback={
<div
data-slot="collapsible-trigger"
data-locked
data-hide-details={props.hideDetails ? "true" : undefined}
>
{trigger()}
</div>
}
>
{trigger()}
</Collapsible.Trigger>
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
</Show>
}
>
<Collapsible.Trigger
@@ -1219,7 +1219,8 @@
background: transparent;
}
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"] {
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"],
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][data-locked] {
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
@@ -26,10 +26,10 @@ describe("current content default open", () => {
test("uses the file-change disclosure preference", () => {
expect(currentContentDefaultOpen(tool("edit"), false, true)).toBe(true)
expect(currentContentDefaultOpen(tool("write"), false, false)).toBe(false)
expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true)
expect(currentContentDefaultOpen(tool("patch"), false, false)).toBe(true)
})
test("keeps deletion-only changes collapsed", () => {
test("opens deletion-only patches", () => {
expect(
currentContentDefaultOpen(
tool("patch", [
@@ -39,6 +39,6 @@ describe("current content default open", () => {
false,
true,
),
).toBe(false)
).toBe(true)
})
})
@@ -6,7 +6,7 @@ import type {
import { Match, Switch } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { AssistantReasoningContent, AssistantTextContent, CurrentUserMessageDisplay } from "./message-content"
import { CurrentContextToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { CurrentContextToolGroup, CurrentPatchToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
@@ -109,3 +109,10 @@ export function SessionContextToolGroup(props: {
/>
)
}
export function SessionPatchToolGroup(props: {
tools: SessionMessageAssistantTool[]
onSizeChange?: () => void
}) {
return <CurrentPatchToolGroup tools={props.tools} onSizeChange={props.onSizeChange} />
}
@@ -36,7 +36,8 @@ export function currentContentDefaultOpen(
) {
if (content.type !== "tool") return undefined
if (content.name === "shell" || content.name === "execute") return shellExpanded
if (content.name !== "edit" && content.name !== "write" && content.name !== "patch") return undefined
if (content.name === "patch") return true
if (content.name !== "edit" && content.name !== "write") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
if (!Array.isArray(files) || files.length === 0) return true
@@ -13,6 +13,17 @@ const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
previousAssistantPart: false,
})
const patch = (key: string, partIDs: string[], userMessageID = "user-1") =>
new TimelineRow.AssistantPart({
userMessageID,
group: {
key,
type: "patch",
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
} satisfies PartGroup,
previousAssistantPart: false,
})
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID })
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
@@ -32,6 +43,13 @@ describe("reuseTimelineRows", () => {
expected: ["assistant-part:user-1:context:a"],
reused: [],
},
{
name: "preserves a patch group key when a member is appended",
previous: [patch("patch:a", ["a"])],
rows: [patch("patch:a", ["a", "b"])],
expected: ["assistant-part:user-1:patch:a"],
reused: [],
},
{
name: "preserves the group key when the first member is removed",
previous: [context("context:a", ["a", "b"])],
+36 -18
View File
@@ -15,8 +15,8 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorGroup = { index: number; row: GroupRow }
const contextTools = new Set(["read", "glob", "grep", "list"])
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
@@ -309,20 +309,20 @@ export namespace Timeline {
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
const contextByPart = new Map<string, PriorContext>()
const groupByPart = new Map<string, PriorGroup>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
if (row._tag !== "AssistantPart" || row.group.type === "part") return
row.group.refs.forEach((ref) => groupByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
if (row._tag !== "AssistantPart" || row.group.type === "part") return
const key = TimelineRow.key(row)
if (byKey.has(key) && !reserved.has(key)) reserved.set(key, index)
})
const claimed = new Set<string>()
const next = rows.map((input, index) => {
const row = stabilizeContextKey(contextByPart, reserved, input, index, claimed)
const row = stabilizeGroupKey(groupByPart, reserved, input, index, claimed)
const existing = byKey.get(TimelineRow.key(row))
if (!existing) return row
return TimelineRow.equals(existing, row) ? existing : row
@@ -398,16 +398,16 @@ function indexAssistantMessages(messages: SessionMessageInfo[]) {
return result
}
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
function stabilizeGroupKey(
groupByPart: Map<string, PriorGroup>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
if (row._tag !== "AssistantPart" || row.group.type !== "context") return row
const existing = row.group.refs.reduce<PriorContext | undefined>((result, ref) => {
const candidate = contextByPart.get(`${row.userMessageID}:${ref.partID}`)
if (row._tag !== "AssistantPart" || row.group.type === "part") return row
const existing = row.group.refs.reduce<PriorGroup | undefined>((result, ref) => {
const candidate = groupByPart.get(`${row.userMessageID}:${ref.partID}`)
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
@@ -436,17 +436,35 @@ function renderable(content: Content, showReasoning: boolean) {
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let context: PartRef[] = []
let adjacent: { type: "context" | "patch"; refs: PartRef[] } | undefined
const flush = () => {
const first = context[0]
const current = adjacent
const first = current?.refs[0]
if (!first) return
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
if (current.type === "patch" && current.refs.length === 1) {
groups.push({ type: "part", key: `part:${first.messageID}:${first.partID}`, ref: first })
adjacent = undefined
return
}
groups.push({
type: current.type,
key: current.type === "patch" ? `part:${first.messageID}:${first.partID}` : `context:${first.partID}`,
refs: current.refs,
})
adjacent = undefined
}
items.forEach((item) => {
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
const type =
item.content.type === "tool" && contextTools.has(item.content.name)
? "context"
: item.content.type === "tool" && item.content.name === "patch" && item.content.state.status !== "error"
? "patch"
: undefined
if (type) {
if (adjacent?.type !== type) flush()
adjacent ??= { type, refs: [] }
adjacent.refs.push({ messageID: item.messageID, partID: item.partID })
return
}
flush()
@@ -276,6 +276,78 @@ describe("current session timeline rows", () => {
])
})
test("groups adjacent successful patches and leaves failed patches separate", () => {
const source = [
{ id: "msg_user", type: "user", text: "edit", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_patch_1",
name: "patch",
state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } },
time: { created: 2, completed: 3 },
},
{
type: "tool",
id: "tool_patch_2",
name: "patch",
state: { status: "running", input: {}, metadata: { files: [] } },
time: { created: 4 },
},
{
type: "tool",
id: "tool_patch_failed",
name: "patch",
state: {
status: "error",
input: {},
error: { type: "ToolError", message: "failed" },
metadata: { files: [] },
},
time: { created: 5, completed: 6 },
},
{
type: "tool",
id: "tool_patch_3",
name: "patch",
state: { status: "completed", input: {}, content: [{ type: "text", text: "done" }], metadata: { files: [] } },
time: { created: 7, completed: 8 },
},
],
time: { created: 2, completed: 8 },
},
] satisfies SessionMessageInfo[]
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
expect(groups).toEqual([
{
type: "patch",
key: "part:msg_assistant:tool_patch_1",
refs: [
{ messageID: "msg_assistant", partID: "tool_patch_1" },
{ messageID: "msg_assistant", partID: "tool_patch_2" },
],
},
{
type: "part",
key: "part:msg_assistant:tool_patch_failed",
ref: { messageID: "msg_assistant", partID: "tool_patch_failed" },
},
{
type: "part",
key: "part:msg_assistant:tool_patch_3",
ref: { messageID: "msg_assistant", partID: "tool_patch_3" },
},
])
})
test("places a divider after interrupted output unless the turn compacts", () => {
const messages = [
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
@@ -14,6 +14,7 @@ import {
MessageDivider,
SessionAssistantContent,
SessionContextToolGroup,
SessionPatchToolGroup,
SessionShellMessage,
SessionUserMessage,
currentContentDefaultOpen,
@@ -97,6 +98,24 @@ export function createSessionTimelineRowRenderer(input: {
)
}
if (row().group.type === "patch") {
const tools = createMemo(() => {
const group = row().group
if (group.type !== "patch") return []
return group.refs.flatMap((ref) => {
const message = input.projection.messageByID().get(ref.messageID)
const content = Timeline.resolveContent(message, ref.partID)
return message?.type === "assistant" && content?.type === "tool" ? [content] : []
})
})
return (
<SessionPatchToolGroup
tools={tools()}
onSizeChange={onSizeChange}
/>
)
}
const ref = createMemo(() => {
const group = row().group
return group.type === "part" ? group.ref : undefined
@@ -16,6 +16,11 @@ export type PartGroup =
type: "context"
refs: PartRef[]
}
| {
key: string
type: "patch"
refs: PartRef[]
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
+50 -44
View File
@@ -535,6 +535,40 @@ export function CurrentContextToolGroup(props: {
)
}
export function CurrentPatchToolGroup(props: {
tools: SessionMessageAssistantTool[]
onSizeChange?: () => void
}) {
const metadata = createMemo(() => ({
files: props.tools.flatMap((tool) => {
const files = currentToolMetadata(tool).files
return Array.isArray(files) ? files : []
}),
}))
const pending = createMemo(() =>
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
)
const render = ToolRegistry.render("patch") ?? GenericTool
return (
<div
data-component="tool-part-wrapper"
data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}
>
<Dynamic
component={render}
tool="patch"
input={{}}
metadata={metadata()}
status={pending() ? "running" : "completed"}
deferContent
virtualizeDiff={false}
onContentRendered={props.onSizeChange}
/>
</div>
)
}
function currentContextToolTrigger(tool: SessionMessageAssistantTool, i18n: ReturnType<typeof useI18n>) {
const input = currentToolInput(tool)
const metadata = currentToolMetadata(tool)
@@ -613,7 +647,7 @@ export const ToolRegistry = {
render: getTool,
}
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element; defaultOpen?: boolean }) {
const value = createMemo(() => props.path || "tool-file")
return (
@@ -621,7 +655,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
defaultValue={[value()]}
defaultValue={props.defaultOpen === false ? [] : [value()]}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
@@ -1391,22 +1425,12 @@ ToolRegistry.register({
const i18n = useI18n()
const fileComponent = useFileComponent()
const files = createMemo(() => patchFiles(props.metadata.files))
const pending = createMemo(() => props.status === "streaming" || props.status === "running")
const single = createMemo(() => {
const list = files()
if (list.length !== 1) return undefined
return list[0]
})
const [expanded, setExpanded] = createSignal<string[]>([])
let seeded = false
createEffect(() => {
const list = files()
if (list.length === 0) return
if (seeded) return
seeded = true
setExpanded(list.filter((file) => file.type !== "delete").map((file) => file.path))
})
const subtitle = createMemo(() => {
const count = files().length
@@ -1421,8 +1445,11 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={props.deferContent !== false}
defer={false}
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
@@ -1437,8 +1464,9 @@ ToolRegistry.register({
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file) => {
const active = createMemo(() => expanded().includes(file.path))
{(file, index) => {
const value = () => `${index()}:${file.path}`
const active = createMemo(() => expanded().includes(value()))
const [visible, setVisible] = createSignal(false)
createEffect(() => {
@@ -1454,7 +1482,7 @@ ToolRegistry.register({
})
return (
<Accordion.Item value={file.path} data-type={file.type}>
<Accordion.Item value={value()} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
@@ -1518,38 +1546,16 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={props.deferContent !== false}
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{getFilename(single()!.path)}</span>
</Show>
</div>
<Show when={!pending() && single()!.path.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{displayDirectory(single()!.path)}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={!pending()}>
<DiffChanges
appearance="standard"
changes={{ additions: single()!.additions, deletions: single()!.deletions }}
/>
</Show>
</div>
</div>
}
defer={false}
trigger={{ title: i18n.t("ui.tool.patch"), subtitle: subtitle() }}
>
<ToolFileAccordion
path={single()!.path}
defaultOpen={false}
actions={
<Switch>
<Match when={single()!.type === "add"}>