From 506dd758187c93bae028fbe7bbfd6ed75772ee1b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 1 Apr 2026 15:01:44 +0800 Subject: [PATCH 1/6] electron: port mergeShellEnv logic from tauri (#20192) --- packages/desktop-electron/src/main/cli.ts | 17 ++-- .../src/main/shell-env.test.ts | 43 +++++++++ .../desktop-electron/src/main/shell-env.ts | 88 +++++++++++++++++++ 3 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 packages/desktop-electron/src/main/shell-env.test.ts create mode 100644 packages/desktop-electron/src/main/shell-env.ts diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts index f2d918bd21..ebaf89fda9 100644 --- a/packages/desktop-electron/src/main/cli.ts +++ b/packages/desktop-electron/src/main/cli.ts @@ -9,6 +9,7 @@ import { app } from "electron" import treeKill from "tree-kill" import { WSL_ENABLED_KEY } from "./constants" +import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env" import { store } from "./store" const CLI_INSTALL_DIR = ".opencode/bin" @@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record) { const base = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), ) - const envs = { + const env = { ...base, OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", @@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record) { XDG_STATE_HOME: app.getPath("userData"), ...extraEnv, } + const shell = process.platform === "win32" ? null : getUserShell() + const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env - const { cmd, cmdArgs } = buildCommand(args, envs) + const { cmd, cmdArgs } = buildCommand(args, envs, shell) console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`) const child = spawn(cmd, cmdArgs, { env: envs, @@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) { return false } -function buildCommand(args: string, env: Record) { +function buildCommand(args: string, env: Record, shell: string | null) { if (process.platform === "win32" && isWslEnabled()) { console.log(`[cli] Using WSL mode`) const version = app.getVersion() @@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record) { } const sidecar = getSidecarPath() - const shell = process.env.SHELL || "/bin/sh" - const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}` - console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`) - return { cmd: shell, cmdArgs: ["-l", "-c", line] } + const user = shell || getUserShell() + const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}` + console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`) + return { cmd: user, cmdArgs: ["-l", "-c", line] } } function envPrefix(env: Record) { diff --git a/packages/desktop-electron/src/main/shell-env.test.ts b/packages/desktop-electron/src/main/shell-env.test.ts new file mode 100644 index 0000000000..cfe88277ea --- /dev/null +++ b/packages/desktop-electron/src/main/shell-env.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" + +import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env" + +describe("shell env", () => { + test("parseShellEnv supports null-delimited pairs", () => { + const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0")) + + expect(env.PATH).toBe("/usr/bin:/bin") + expect(env.FOO).toBe("bar=baz") + }) + + test("parseShellEnv ignores invalid entries", () => { + const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0")) + + expect(Object.keys(env).length).toBe(1) + expect(env.OK).toBe("1") + }) + + test("mergeShellEnv keeps explicit overrides", () => { + const env = mergeShellEnv( + { + PATH: "/shell/path", + HOME: "/tmp/home", + }, + { + PATH: "/desktop/path", + OPENCODE_CLIENT: "desktop", + }, + ) + + expect(env.PATH).toBe("/desktop/path") + expect(env.HOME).toBe("/tmp/home") + expect(env.OPENCODE_CLIENT).toBe("desktop") + }) + + test("isNushell handles path and binary name", () => { + expect(isNushell("nu")).toBe(true) + expect(isNushell("/opt/homebrew/bin/nu")).toBe(true) + expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true) + expect(isNushell("/bin/zsh")).toBe(false) + }) +}) diff --git a/packages/desktop-electron/src/main/shell-env.ts b/packages/desktop-electron/src/main/shell-env.ts new file mode 100644 index 0000000000..3000848212 --- /dev/null +++ b/packages/desktop-electron/src/main/shell-env.ts @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process" +import { basename } from "node:path" + +const SHELL_ENV_TIMEOUT = 5_000 + +type Probe = { type: "Loaded"; value: Record } | { type: "Timeout" } | { type: "Unavailable" } + +export function getUserShell() { + return process.env.SHELL || "/bin/sh" +} + +export function parseShellEnv(out: Buffer) { + const env: Record = {} + for (const line of out.toString("utf8").split("\0")) { + if (!line) continue + const ix = line.indexOf("=") + if (ix <= 0) continue + env[line.slice(0, ix)] = line.slice(ix + 1) + } + return env +} + +function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe { + const out = spawnSync(shell, [mode, "-c", "env -0"], { + stdio: ["ignore", "pipe", "ignore"], + timeout: SHELL_ENV_TIMEOUT, + windowsHide: true, + }) + + const err = out.error as NodeJS.ErrnoException | undefined + if (err) { + if (err.code === "ETIMEDOUT") return { type: "Timeout" } + console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`) + return { type: "Unavailable" } + } + + if (out.status !== 0) { + console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`) + return { type: "Unavailable" } + } + + const env = parseShellEnv(out.stdout) + if (Object.keys(env).length === 0) { + console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`) + return { type: "Unavailable" } + } + + return { type: "Loaded", value: env } +} + +export function isNushell(shell: string) { + const name = basename(shell).toLowerCase() + const raw = shell.toLowerCase() + return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe") +} + +export function loadShellEnv(shell: string) { + if (isNushell(shell)) { + console.log(`[cli] Skipping shell env probe for nushell: ${shell}`) + return null + } + + const interactive = probeShellEnv(shell, "-il") + if (interactive.type === "Loaded") { + console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`) + return interactive.value + } + if (interactive.type === "Timeout") { + console.warn(`[cli] Interactive shell env probe timed out: ${shell}`) + return null + } + + const login = probeShellEnv(shell, "-l") + if (login.type === "Loaded") { + console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`) + return login.value + } + + console.warn(`[cli] Falling back to app environment: ${shell}`) + return null +} + +export function mergeShellEnv(shell: Record | null, env: Record) { + return { + ...(shell || {}), + ...env, + } +} From 1df5ad470a49163ad623460b4e969da1b51cc404 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 1 Apr 2026 16:43:03 +0800 Subject: [PATCH 2/6] app: try to hide autofill popups in prompt input (#20197) --- packages/app/src/components/prompt-input.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c8f72b8d2f..338b04ba65 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1344,6 +1344,9 @@ export const PromptInput: Component = (props) => { autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocorrect={store.mode === "normal" ? "on" : "off"} spellcheck={store.mode === "normal"} + inputMode="text" + // @ts-expect-error + autocomplete="off" onInput={handleInput} onPaste={handlePaste} onCompositionStart={handleCompositionStart} From 47a676111a3532aebed01110494742e536b7e5b4 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 1 Apr 2026 15:47:15 +0530 Subject: [PATCH 3/6] fix(session): add keyboard support to question dock (#20439) --- .../e2e/session/session-composer-dock.spec.ts | 68 ++++++++++ .../composer/session-question-dock.tsx | 117 +++++++++++++++++- packages/ui/src/components/dock-prompt.tsx | 3 +- 3 files changed, 183 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index f083bf3597..c560793375 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -13,6 +13,7 @@ import { sessionComposerDockSelector, sessionTodoToggleButtonSelector, } from "../selectors" +import { modKey } from "../utils" type Sdk = Parameters[0] type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } @@ -310,6 +311,73 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess }) }) +test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => { + await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => { + await withDockSeed(sdk, session.id, async () => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Need input", + question: "Pick one option", + options: [ + { label: "Continue", description: "Continue now" }, + { label: "Stop", description: "Stop here" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + const second = dock.locator('[data-slot="question-option"]').nth(1) + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("ArrowDown") + await expect(second).toBeFocused() + + await page.keyboard.press("Space") + await page.keyboard.press(`${modKey}+Enter`) + await expectQuestionOpen(page) + }) + }) +}) + +test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => { + await withDockSession(sdk, "e2e composer dock question escape", async (session) => { + await withDockSeed(sdk, session.id, async () => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Need input", + question: "Pick one option", + options: [ + { label: "Continue", description: "Continue now" }, + { label: "Stop", description: "Stop here" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("Escape") + await expectQuestionOpen(page) + }) + }) +}) + test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock permission once", async (session) => { await gotoSession(session.id) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index ef1e52d264..38974b2465 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -29,16 +29,20 @@ function Option(props: { label: string description?: string disabled: boolean + ref?: (el: HTMLButtonElement) => void + onFocus?: VoidFunction onClick: VoidFunction }) { return (
@@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit {language.t("ui.common.back")} -
@@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit label={opt.label} description={opt.description} disabled={sending()} + ref={(el) => (optsRef[i()] = el)} + onFocus={() => setStore("focus", i())} onClick={() => selectOption(i())} /> )} @@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit fallback={ + ) + }} + + +
{i18n.t("ui.lineComment.editorLabel.prefix")} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83d2980f61..5000fcdc49 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -23,6 +23,7 @@ import { Dynamic } from "solid-js/web" import { mediaKindFromPath } from "../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge" import { createLineCommentController } from "./line-comment-annotations" +import type { LineCommentEditorProps } from "./line-comment" const MAX_DIFF_CHANGED_LINES = 500 @@ -88,6 +89,7 @@ export interface SessionReviewProps { diffs: ReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise + lineCommentMention?: LineCommentEditorProps["mention"] } function ReviewCommentMenu(props: { @@ -327,6 +329,7 @@ export const SessionReview = (props: SessionReviewProps) => { comments, label: i18n.t("ui.lineComment.submit"), draftKey: () => file, + mention: props.lineCommentMention, state: { opened: () => { const current = opened() From 9a1c9ae15a07fd0e308d29e2db4411661725a4a7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 1 Apr 2026 08:28:38 -0400 Subject: [PATCH 5/6] test(app): route prompt e2e through mock llm (#20383) --- packages/opencode/test/lib/llm-server.ts | 326 +++++++++++++++++++++-- 1 file changed, 302 insertions(+), 24 deletions(-) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 8e7365d97f..fb84f1175a 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -8,6 +8,13 @@ export type Usage = { input: number; output: number } type Line = Record +type Flow = + | { type: "text"; text: string } + | { type: "reason"; text: string } + | { type: "tool-start"; id: string; name: string } + | { type: "tool-args"; text: string } + | { type: "usage"; usage: Usage } + type Hit = { url: URL body: Record @@ -119,6 +126,276 @@ function bytes(input: Iterable) { return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText) } +function responseCreated(model: string) { + return { + type: "response.created", + sequence_number: 1, + response: { + id: "resp_test", + created_at: Math.floor(Date.now() / 1000), + model, + service_tier: null, + }, + } +} + +function responseCompleted(input: { seq: number; usage?: Usage }) { + return { + type: "response.completed", + sequence_number: input.seq, + response: { + incomplete_details: null, + service_tier: null, + usage: { + input_tokens: input.usage?.input ?? 0, + input_tokens_details: { cached_tokens: null }, + output_tokens: input.usage?.output ?? 0, + output_tokens_details: { reasoning_tokens: null }, + }, + }, + } +} + +function responseMessage(id: string, seq: number) { + return { + type: "response.output_item.added", + sequence_number: seq, + output_index: 0, + item: { type: "message", id }, + } +} + +function responseText(id: string, text: string, seq: number) { + return { + type: "response.output_text.delta", + sequence_number: seq, + item_id: id, + delta: text, + logprobs: null, + } +} + +function responseMessageDone(id: string, seq: number) { + return { + type: "response.output_item.done", + sequence_number: seq, + output_index: 0, + item: { type: "message", id }, + } +} + +function responseReason(id: string, seq: number) { + return { + type: "response.output_item.added", + sequence_number: seq, + output_index: 0, + item: { type: "reasoning", id, encrypted_content: null }, + } +} + +function responseReasonPart(id: string, seq: number) { + return { + type: "response.reasoning_summary_part.added", + sequence_number: seq, + item_id: id, + summary_index: 0, + } +} + +function responseReasonText(id: string, text: string, seq: number) { + return { + type: "response.reasoning_summary_text.delta", + sequence_number: seq, + item_id: id, + summary_index: 0, + delta: text, + } +} + +function responseReasonDone(id: string, seq: number) { + return { + type: "response.output_item.done", + sequence_number: seq, + output_index: 0, + item: { type: "reasoning", id, encrypted_content: null }, + } +} + +function responseTool(id: string, item: string, name: string, seq: number) { + return { + type: "response.output_item.added", + sequence_number: seq, + output_index: 0, + item: { + type: "function_call", + id: item, + call_id: id, + name, + arguments: "", + status: "in_progress", + }, + } +} + +function responseToolArgs(id: string, text: string, seq: number) { + return { + type: "response.function_call_arguments.delta", + sequence_number: seq, + output_index: 0, + item_id: id, + delta: text, + } +} + +function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) { + return { + type: "response.output_item.done", + sequence_number: seq, + output_index: 0, + item: { + type: "function_call", + id: tool.item, + call_id: tool.id, + name: tool.name, + arguments: tool.args, + status: "completed", + }, + } +} + +function choices(part: unknown) { + if (!part || typeof part !== "object") return + if (!("choices" in part) || !Array.isArray(part.choices)) return + const choice = part.choices[0] + if (!choice || typeof choice !== "object") return + return choice +} + +function flow(item: Sse) { + const out: Flow[] = [] + for (const part of [...item.head, ...item.tail]) { + const choice = choices(part) + const delta = + choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined + + if (delta && "content" in delta && typeof delta.content === "string") { + out.push({ type: "text", text: delta.content }) + } + + if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") { + out.push({ type: "reason", text: delta.reasoning_content }) + } + + if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const tool of delta.tool_calls) { + if (!tool || typeof tool !== "object") continue + const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined + if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") { + out.push({ type: "tool-start", id: tool.id, name: fn.name }) + } + if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) { + out.push({ type: "tool-args", text: fn.arguments }) + } + } + } + + if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") { + const raw = part.usage as Record + if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") { + out.push({ + type: "usage", + usage: { input: raw.prompt_tokens, output: raw.completion_tokens }, + }) + } + } + } + return out +} + +function responses(item: Sse, model: string) { + let seq = 1 + let msg: string | undefined + let reason: string | undefined + let hasMsg = false + let hasReason = false + let call: + | { + id: string + item: string + name: string + args: string + } + | undefined + let usage: Usage | undefined + const lines: unknown[] = [responseCreated(model)] + + for (const part of flow(item)) { + if (part.type === "text") { + msg ??= "msg_1" + if (!hasMsg) { + hasMsg = true + seq += 1 + lines.push(responseMessage(msg, seq)) + } + seq += 1 + lines.push(responseText(msg, part.text, seq)) + continue + } + + if (part.type === "reason") { + reason ||= "rs_1" + if (!hasReason) { + hasReason = true + seq += 1 + lines.push(responseReason(reason, seq)) + seq += 1 + lines.push(responseReasonPart(reason, seq)) + } + seq += 1 + lines.push(responseReasonText(reason, part.text, seq)) + continue + } + + if (part.type === "tool-start") { + call ||= { id: part.id, item: "fc_1", name: part.name, args: "" } + seq += 1 + lines.push(responseTool(call.id, call.item, call.name, seq)) + continue + } + + if (part.type === "tool-args") { + if (!call) continue + call.args += part.text + seq += 1 + lines.push(responseToolArgs(call.item, part.text, seq)) + continue + } + + usage = part.usage + } + + if (msg) { + seq += 1 + lines.push(responseMessageDone(msg, seq)) + } + if (reason) { + seq += 1 + lines.push(responseReasonDone(reason, seq)) + } + if (call && !item.hang && !item.error) { + seq += 1 + lines.push(responseToolDone(call, seq)) + } + if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage })) + return { ...item, head: lines, tail: [] } satisfies Sse +} + +function modelFrom(body: unknown) { + if (!body || typeof body !== "object") return "test-model" + if (!("model" in body) || typeof body.model !== "string") return "test-model" + return body.model +} + function send(item: Sse) { const head = bytes(item.head) const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])]) @@ -293,6 +570,13 @@ function item(input: Item | Reply) { return input instanceof Reply ? input.item() : input } +function hit(url: string, body: unknown) { + return { + url: new URL(url, "http://localhost"), + body: body && typeof body === "object" ? (body as Record) : {}, + } satisfies Hit +} + namespace TestLLMServer { export interface Service { readonly url: string @@ -342,30 +626,24 @@ export class TestLLMServer extends ServiceMap.Service ({}))) - hits = [ - ...hits, - { - url: new URL(req.originalUrl, "http://localhost"), - body: body && typeof body === "object" ? (body as Record) : {}, - }, - ] - yield* notify() - if (next.type === "sse" && next.reset) { - yield* reset(next) - return HttpServerResponse.empty() - } - if (next.type === "sse") return send(next) - return fail(next) - }), - ) + const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") { + const req = yield* HttpServerRequest.HttpServerRequest + const next = pull() + if (!next) return HttpServerResponse.text("unexpected request", { status: 500 }) + const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({}))) + hits = [...hits, hit(req.originalUrl, body)] + yield* notify() + if (next.type !== "sse") return fail(next) + if (mode === "responses") return send(responses(next, modelFrom(body))) + if (next.reset) { + yield* reset(next) + return HttpServerResponse.empty() + } + return send(next) + }) + + yield* router.add("POST", "/v1/chat/completions", handle("chat")) + yield* router.add("POST", "/v1/responses", handle("responses")) yield* server.serve(router.asHttpEffect()) From 44f83015cd8b7e4645a1904fe4e10fedea22d7a3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 1 Apr 2026 19:29:12 +0530 Subject: [PATCH 6/6] perf(review): defer offscreen diff mounts (#20469) --- packages/ui/src/components/session-review.tsx | 105 ++++++++++++++++-- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 5000fcdc49..1040aa2921 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js" -import { onCleanup } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -26,6 +25,7 @@ import { createLineCommentController } from "./line-comment-annotations" import type { LineCommentEditorProps } from "./line-comment" const MAX_DIFF_CHANGED_LINES = 500 +const REVIEW_MOUNT_MARGIN = 300 export type SessionReviewDiffStyle = "unified" | "split" @@ -69,7 +69,7 @@ export interface SessionReviewProps { split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void - onDiffRendered?: () => void + onDiffRendered?: VoidFunction onLineComment?: (comment: SessionReviewLineComment) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void @@ -137,11 +137,14 @@ type SessionReviewSelection = { export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined let focusToken = 0 + let frame: number | undefined const i18n = useI18n() const fileComponent = useFileComponent() const anchors = new Map() + const nodes = new Map() const [store, setStore] = createStore({ open: [] as string[], + visible: {} as Record, force: {} as Record, selection: null as SessionReviewSelection | null, commenting: null as SessionReviewSelection | null, @@ -154,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => { const open = () => props.open ?? store.open const files = createMemo(() => props.diffs.map((diff) => diff.file)) const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const))) + const grouped = createMemo(() => { + const next = new Map() + for (const comment of props.comments ?? []) { + const list = next.get(comment.file) + if (list) { + list.push(comment) + continue + } + next.set(comment.file, [comment]) + } + return next + }) const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 - const handleChange = (open: string[]) => { - props.onOpenChange?.(open) - if (props.open !== undefined) return - setStore("open", open) + const syncVisible = () => { + frame = undefined + if (!scroll) return + + const root = scroll.getBoundingClientRect() + const top = root.top - REVIEW_MOUNT_MARGIN + const bottom = root.bottom + REVIEW_MOUNT_MARGIN + const openSet = new Set(open()) + const next: Record = {} + + for (const [file, el] of nodes) { + if (!openSet.has(file)) continue + const rect = el.getBoundingClientRect() + if (rect.bottom < top || rect.top > bottom) continue + next[file] = true + } + + const prev = untrack(() => store.visible) + const prevKeys = Object.keys(prev) + const nextKeys = Object.keys(next) + if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return + setStore("visible", next) + } + + const queue = () => { + if (frame !== undefined) return + frame = requestAnimationFrame(syncVisible) + } + + const pinned = (file: string) => + props.focusedComment?.file === file || + props.focusedFile === file || + selection()?.file === file || + commenting()?.file === file || + opened()?.file === file + + const handleScroll: JSX.EventHandler = (event) => { + queue() + const next = props.onScroll + if (!next) return + if (Array.isArray(next)) { + const [fn, data] = next as [(data: unknown, event: Event) => void, unknown] + fn(data, event) + return + } + ;(next as JSX.EventHandler)(event) + } + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + createEffect(() => { + props.open + files() + queue() + }) + + const handleChange = (next: string[]) => { + props.onOpenChange?.(next) + if (props.open === undefined) setStore("open", next) + queue() } const handleExpandOrCollapseAll = () => { @@ -274,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => { viewportRef={(el) => { scroll = el props.scrollRef?.(el) + queue() }} - onScroll={props.onScroll as any} + onScroll={handleScroll} classList={{ [props.classes?.root ?? ""]: !!props.classes?.root, }} @@ -291,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => { const item = createMemo(() => diffs().get(file)!) const expanded = createMemo(() => open().includes(file)) + const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) const force = () => !!store.force[file] - const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) + const comments = createMemo(() => grouped().get(file) ?? []) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof item().before === "string" ? item().before : "") @@ -381,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => { onCleanup(() => { anchors.delete(file) + nodes.delete(file) + queue() }) const handleLineSelected = (range: SelectedLineRange | null) => { @@ -465,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => { ref={(el) => { wrapper = el anchors.set(file, el) + nodes.set(file, el) + queue() }} > + +
+