Compare commits

..
Author SHA1 Message Date
opencode-agent[bot]andBrendonovich ea3e0dde19 feat(desktop): restore CLI installer menu item (#43906)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-21 15:49:55 +00:00
25 changed files with 181 additions and 288 deletions
@@ -47,10 +47,5 @@ test("renders a completed single-file patch", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
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()
await expect(page.locator(`[data-timeline-part-id="${id}"] [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("keeps patch file disclosures independent", async ({ page }) => {
test("preserves nested patch file state through outer collapse and reopen", 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,17 +21,15 @@ test("keeps patch file disclosures independent", async ({ page }) => {
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
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 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")
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")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
@@ -69,43 +69,9 @@ 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(
[
@@ -230,7 +196,11 @@ function patchPart(id: string) {
{ patchText: "Update the projected files" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
files: [
patchFile("src/a.ts", "modified"),
patchFile("src/b.ts", "added"),
patchFile("src/old.ts", "deleted"),
],
},
},
)
@@ -236,6 +236,7 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.window": "Window",
"desktop.menu.help": "Help",
"desktop.menu.checkForUpdates": "Check for Updates...",
"desktop.menu.installCli": "Install CLI...",
"desktop.menu.settings": "Settings",
"desktop.menu.reloadWebview": "Reload Webview",
"desktop.menu.restart": "Restart",
@@ -283,6 +284,11 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.updater.dialog.restart": "Restart",
"desktop.updater.dialog.later": "Later",
"desktop.cli.installed.title": "CLI Installed",
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode2' command.",
"desktop.cli.failed.title": "Installation Failed",
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
"desktop.recovery.action.relaunch": "Relaunch",
"desktop.recovery.action.exportLogs": "Export Logs",
"desktop.recovery.action.keepWaiting": "Keep Waiting",
@@ -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"].includes(content.name)
return content?.type === "tool" && ["edit", "write", "patch"].includes(content.name)
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
@@ -2,6 +2,13 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("installs the CLI from the macOS application menu", () => {
const appMenu = DESKTOP_MENU.find((menu) => menu.id === "app")
const item = appMenu?.items?.find((entry) => entry.type === "item" && entry.action === "app.installCli")
expect(item).toEqual({ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" })
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
@@ -4,6 +4,7 @@ export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.installCli"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
@@ -84,6 +85,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
action: "app.checkForUpdates",
enabled: "updater",
},
{ type: "item", labelKey: "desktop.menu.installCli", action: "app.installCli" },
{ type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" },
{ type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" },
@@ -5,6 +5,7 @@ import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { showCliInstaller } from "../native/install-cli"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
import { setNativeTranslations } from "../native/translations"
@@ -12,6 +13,7 @@ import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
import { BackgroundService } from "../service/background-service"
import { DesktopCli } from "../service/desktop-cli"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
@@ -22,6 +24,7 @@ export const appHandlers = AppRpcs.toLayer(
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const background = yield* BackgroundService.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const runFork = Effect.runForkWith(yield* Effect.context())
@@ -56,6 +59,7 @@ export const appHandlers = AppRpcs.toLayer(
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
+4
View File
@@ -16,7 +16,9 @@ import { windowHandlers } from "./ipc-handlers/window"
import { wslHandlers } from "./ipc-handlers/wsl"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopCli } from "./service/desktop-cli"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
@@ -42,6 +44,7 @@ export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true })
export const registerIpcHandlers = Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const menu = {
@@ -50,6 +53,7 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
installCli: () => runFork(showCliInstaller(desktopCli)),
createWindow: lifecycle.createWindow,
openExternal: (url: string) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
@@ -0,0 +1,28 @@
import { dialog } from "electron"
import { Effect } from "effect"
import { DesktopCli } from "../service/desktop-cli"
import { nativeT } from "./translations"
export function showCliInstaller(desktopCli: DesktopCli.Interface) {
return desktopCli.install.pipe(
Effect.tap((path) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "info",
message: nativeT("desktop.cli.installed.message", { path }),
title: nativeT("desktop.cli.installed.title"),
}),
),
),
Effect.catch((error) =>
Effect.promise(() =>
dialog.showMessageBox({
type: "error",
message: nativeT("desktop.cli.failed.message", { error: error.message }),
title: nativeT("desktop.cli.failed.title"),
}),
),
),
Effect.asVoid,
)
}
@@ -4,6 +4,7 @@ import { updateTitlebar } from "../windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
relaunch: () => void
}>
@@ -17,6 +18,9 @@ export function runDesktopMenuAction(
case "app.checkForUpdates":
handlers.checkForUpdates?.()
return
case "app.installCli":
handlers.installCli?.()
return
case "app.relaunch":
handlers.relaunch?.()
return
+2
View File
@@ -16,6 +16,7 @@ import { nativeT } from "./translations"
type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
installCli: () => void
createWindow: () => void
openExternal: (url: string) => void
relaunch: () => void
@@ -60,6 +61,7 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
installCli: deps.installCli,
createWindow: deps.createWindow,
relaunch: deps.relaunch,
})
@@ -1,9 +1,10 @@
export * as DesktopCli from "./desktop-cli"
import { execFile } from "node:child_process"
import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { parseCliVersion } from "./cli-version"
@@ -18,6 +19,7 @@ export interface Resolved {
export interface Interface {
readonly resolve: Effect.Effect<Resolved>
readonly install: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopCli") {}
@@ -25,10 +27,19 @@ export class Service extends Context.Service<Service, Interface>()("opencode/des
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const resolve = yield* Effect.cached(
make().pipe(Effect.provide(yield* Effect.context<FileSystem.FileSystem | Path.Path>()), Effect.orDie),
)
return Service.of({ resolve })
const install = Effect.gen(function* () {
if (process.platform !== "darwin") return yield* Effect.fail(new Error("CLI installation requires macOS"))
const cli = yield* resolve
if (!cli.binary) return yield* Effect.fail(new Error("Bundled CLI executable is unavailable"))
const home = app.getPath("home")
yield* runInstaller(cli.binary, home)
return path.join(home, ".opencode", "bin", "opencode2")
})
return Service.of({ resolve, install })
}),
)
@@ -134,6 +145,27 @@ const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[
return stdout
})
const runInstaller = Effect.fn("DesktopCli.installForUser")(function* (binary: string, home: string) {
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn("/bin/bash", ["-s", "--", "--binary", binary], {
env: { ...process.env, HOME: home },
stdio: ["pipe", "ignore", "pipe"],
})
let stderr = ""
child.stderr.on("data", (chunk) => (stderr += chunk))
child.on("error", reject)
child.on("close", (code) => {
if (code === 0) return resolve()
reject(new Error(stderr.trim() || `CLI installer exited with code ${code}`))
})
child.stdin.end(installer)
}),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
})
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
@@ -3,6 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
"app.installCli",
"app.relaunch",
"edit.undo",
"edit.redo",
@@ -255,29 +255,16 @@ export function BasicTool(props: BasicToolProps) {
)
return (
<Collapsible open={open()} onOpenChange={props.locked ? undefined : handleOpenChange} class="tool-collapsible">
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Show
when={!props.locked && (props.triggerAsLink || props.triggerHref)}
when={props.triggerAsLink || props.triggerHref}
fallback={
<Show
when={!props.locked}
fallback={
<div
data-slot="collapsible-trigger"
data-locked
data-hide-details={props.hideDetails ? "true" : undefined}
>
{trigger()}
</div>
}
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
<Collapsible.Trigger
data-hide-details={props.hideDetails ? "true" : undefined}
onClick={props.onTriggerClick}
>
{trigger()}
</Collapsible.Trigger>
</Show>
{trigger()}
</Collapsible.Trigger>
}
>
<Collapsible.Trigger
@@ -1219,8 +1219,7 @@
background: transparent;
}
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"],
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][data-locked] {
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"] {
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, false)).toBe(true)
expect(currentContentDefaultOpen(tool("patch"), false, true)).toBe(true)
})
test("opens deletion-only patches", () => {
test("keeps deletion-only changes collapsed", () => {
expect(
currentContentDefaultOpen(
tool("patch", [
@@ -39,6 +39,6 @@ describe("current content default open", () => {
false,
true,
),
).toBe(true)
).toBe(false)
})
})
@@ -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, CurrentPatchToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { CurrentContextToolGroup, ToolDisplay } from "../tools/tool-renderer"
import { currentToolError, currentToolInput, currentToolMetadata, currentToolOutput } from "./current-tool-state"
export type { SessionUserActions, SessionUserComment } from "../actions"
@@ -109,10 +109,3 @@ export function SessionContextToolGroup(props: {
/>
)
}
export function SessionPatchToolGroup(props: {
tools: SessionMessageAssistantTool[]
onSizeChange?: () => void
}) {
return <CurrentPatchToolGroup tools={props.tools} onSizeChange={props.onSizeChange} />
}
@@ -36,8 +36,7 @@ export function currentContentDefaultOpen(
) {
if (content.type !== "tool") return undefined
if (content.name === "shell" || content.name === "execute") return shellExpanded
if (content.name === "patch") return true
if (content.name !== "edit" && content.name !== "write") return undefined
if (content.name !== "edit" && content.name !== "write" && content.name !== "patch") return undefined
if (!editExpanded) return false
const files = currentToolMetadata(content).files
if (!Array.isArray(files) || files.length === 0) return true
@@ -13,17 +13,6 @@ 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)
@@ -43,13 +32,6 @@ 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"])],
+18 -36
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 GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorGroup = { index: number; row: GroupRow }
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
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 groupByPart = new Map<string, PriorGroup>()
const contextByPart = new Map<string, PriorContext>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type === "part") return
row.group.refs.forEach((ref) => groupByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
row.group.refs.forEach((ref) => contextByPart.set(`${row.userMessageID}:${ref.partID}`, { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type === "part") return
if (row._tag !== "AssistantPart" || row.group.type !== "context") 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 = stabilizeGroupKey(groupByPart, reserved, input, index, claimed)
const row = stabilizeContextKey(contextByPart, 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 stabilizeGroupKey(
groupByPart: Map<string, PriorGroup>,
function stabilizeContextKey(
contextByPart: Map<string, PriorContext>,
reserved: Map<string, number>,
row: TimelineRow.TimelineRow,
rowIndex: number,
claimed: Set<string>,
) {
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 (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 (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
@@ -436,35 +436,17 @@ function renderable(content: Content, showReasoning: boolean) {
function groupContent(items: { messageID: string; partID: string; content: Content }[]): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "patch"; refs: PartRef[] } | undefined
let context: PartRef[] = []
const flush = () => {
const current = adjacent
const first = current?.refs[0]
const first = context[0]
if (!first) return
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
groups.push({ type: "context", key: `context:${first.partID}`, refs: context })
context = []
}
items.forEach((item) => {
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 })
if (item.content.type === "tool" && contextTools.has(item.content.name)) {
context.push({ messageID: item.messageID, partID: item.partID })
return
}
flush()
@@ -276,78 +276,6 @@ 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,7 +14,6 @@ import {
MessageDivider,
SessionAssistantContent,
SessionContextToolGroup,
SessionPatchToolGroup,
SessionShellMessage,
SessionUserMessage,
currentContentDefaultOpen,
@@ -98,24 +97,6 @@ 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,11 +16,6 @@ export type PartGroup =
type: "context"
refs: PartRef[]
}
| {
key: string
type: "patch"
refs: PartRef[]
}
export namespace TimelineRow {
export class TurnGap extends Data.TaggedClass("TurnGap")<{
+44 -50
View File
@@ -535,40 +535,6 @@ 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)
@@ -647,7 +613,7 @@ export const ToolRegistry = {
render: getTool,
}
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element; defaultOpen?: boolean }) {
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
const value = createMemo(() => props.path || "tool-file")
return (
@@ -655,7 +621,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={props.defaultOpen === false ? [] : [value()]}
defaultValue={[value()]}
>
<Accordion.Item value={value()}>
<StickyAccordionHeader>
@@ -1425,12 +1391,22 @@ 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
@@ -1445,11 +1421,8 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
defer={props.deferContent !== false}
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
@@ -1464,9 +1437,8 @@ ToolRegistry.register({
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file, index) => {
const value = () => `${index()}:${file.path}`
const active = createMemo(() => expanded().includes(value()))
{(file) => {
const active = createMemo(() => expanded().includes(file.path))
const [visible, setVisible] = createSignal(false)
createEffect(() => {
@@ -1482,7 +1454,7 @@ ToolRegistry.register({
})
return (
<Accordion.Item value={value()} data-type={file.type}>
<Accordion.Item value={file.path} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
@@ -1546,16 +1518,38 @@ ToolRegistry.register({
<div data-component="apply-patch-tool">
<BasicTool
{...props}
open
onOpenChange={undefined}
locked
icon="code-lines"
defer={false}
trigger={{ title: i18n.t("ui.tool.patch"), subtitle: subtitle() }}
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>
}
>
<ToolFileAccordion
path={single()!.path}
defaultOpen={false}
actions={
<Switch>
<Match when={single()!.type === "add"}>