mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 17:16:14 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3e0dde19 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])],
|
||||
|
||||
@@ -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")<{
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
Reference in New Issue
Block a user