Browse Source

Merge branch 'dev' into worktree-audit-effect-services

Kit Langton 3 weeks ago
parent
commit
a96edcf39b

+ 68 - 0
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<typeof clearSessionDockSeed>[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)

+ 3 - 0
packages/app/src/components/prompt-input.tsx

@@ -1344,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (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}

+ 24 - 0
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
     expect(synthetic).toHaveLength(1)
   })
 
+  test("adds file parts for @mentions inside comment text", () => {
+    const result = buildRequestParts({
+      prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
+      context: [
+        {
+          key: "ctx:comment-mention",
+          type: "file",
+          path: "src/review.ts",
+          comment: "Compare with @src/shared.ts and @src/review.ts.",
+        },
+      ],
+      images: [],
+      text: "look",
+      messageID: "msg_comment_mentions",
+      sessionID: "ses_comment_mentions",
+      sessionDirectory: "/repo",
+    })
+
+    const files = result.requestParts.filter((part) => part.type === "file")
+    expect(files).toHaveLength(2)
+    expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
+    expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
+  })
+
   test("handles Windows paths correctly (simulated on macOS)", () => {
     const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
 

+ 26 - 0
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
 const fileQuery = (selection: FileSelection | undefined) =>
   selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
 
+const mention = /(^|[\s([{"'])@(\S+)/g
+
+const parseCommentMentions = (comment: string) => {
+  return Array.from(comment.matchAll(mention)).flatMap((match) => {
+    const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
+    if (!path) return []
+    return [path]
+  })
+}
+
 const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
 const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
 
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
 
     if (!comment) return [filePart]
 
+    const mentions = parseCommentMentions(comment).flatMap((path) => {
+      const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
+      if (used.has(url)) return []
+      used.add(url)
+      return [
+        {
+          id: Identifier.ascending("part"),
+          type: "file",
+          mime: "text/plain",
+          url,
+          filename: getFilename(path),
+        } satisfies PromptRequestPart,
+      ]
+    })
+
     return [
       {
         id: Identifier.ascending("part"),
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
         }),
       } satisfies PromptRequestPart,
       filePart,
+      ...mentions,
     ]
   })
 

+ 3 - 0
packages/app/src/pages/session.tsx

@@ -1046,6 +1046,9 @@ export default function Page() {
         onLineCommentUpdate={updateCommentInContext}
         onLineCommentDelete={removeCommentFromContext}
         lineCommentActions={reviewCommentActions()}
+        commentMentions={{
+          items: file.searchFilesAndDirectories,
+        }}
         comments={comments.all()}
         focusedComment={comments.focus()}
         onFocusedCommentChange={comments.setFocus}

+ 113 - 4
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 (
     <button
       type="button"
+      ref={props.ref}
       data-slot="question-option"
       data-picked={props.picked}
       role={props.multi ? "checkbox" : "radio"}
       aria-checked={props.picked}
       disabled={props.disabled}
+      onFocus={props.onFocus}
       onClick={props.onClick}
     >
       <Mark multi={props.multi} picked={props.picked} />
@@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     custom: cached?.custom ?? ([] as string[]),
     customOn: cached?.customOn ?? ([] as boolean[]),
     editing: false,
+    focus: 0,
   })
 
   let root: HTMLDivElement | undefined
+  let customRef: HTMLButtonElement | undefined
+  let optsRef: HTMLButtonElement[] = []
   let replied = false
+  let focusFrame: number | undefined
 
   const question = createMemo(() => questions()[store.tab])
   const options = createMemo(() => question()?.options ?? [])
   const input = createMemo(() => store.custom[store.tab] ?? "")
   const on = createMemo(() => store.customOn[store.tab] === true)
   const multi = createMemo(() => question()?.multiple === true)
+  const count = createMemo(() => options().length + 1)
 
   const summary = createMemo(() => {
     const n = Math.min(store.tab + 1, total())
@@ -129,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     root.style.setProperty("--question-prompt-max-height", `${max}px`)
   }
 
+  const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
+
+  const pickFocus = (tab: number = store.tab) => {
+    const list = questions()[tab]?.options ?? []
+    if (store.customOn[tab] === true) return list.length
+    return Math.max(
+      0,
+      list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
+    )
+  }
+
+  const focus = (i: number) => {
+    const next = clamp(i)
+    setStore("focus", next)
+    if (store.editing) return
+    if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
+    focusFrame = requestAnimationFrame(() => {
+      focusFrame = undefined
+      const el = next === options().length ? customRef : optsRef[next]
+      el?.focus()
+    })
+  }
+
   onMount(() => {
     let raf: number | undefined
     const update = () => {
@@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       observer.disconnect()
       if (raf !== undefined) cancelAnimationFrame(raf)
     })
+
+    focus(pickFocus())
   })
 
   onCleanup(() => {
+    if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
     if (replied) return
     cache.set(props.request.id, {
       tab: store.tab,
@@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
 
   const customToggle = () => {
     if (sending()) return
+    setStore("focus", options().length)
 
     if (!multi()) {
       setStore("customOn", store.tab, true)
@@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     const value = input().trim()
     if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
     setStore("editing", false)
+    focus(options().length)
   }
 
   const customOpen = () => {
     if (sending()) return
+    setStore("focus", options().length)
     if (!on()) setStore("customOn", store.tab, true)
     setStore("editing", true)
     customUpdate(input(), true)
   }
 
+  const move = (step: number) => {
+    if (store.editing || sending()) return
+    focus(store.focus + step)
+  }
+
+  const nav = (event: KeyboardEvent) => {
+    if (event.defaultPrevented) return
+
+    if (event.key === "Escape") {
+      event.preventDefault()
+      void reject()
+      return
+    }
+
+    const mod = (event.metaKey || event.ctrlKey) && !event.altKey
+    if (mod && event.key === "Enter") {
+      if (event.repeat) return
+      event.preventDefault()
+      next()
+      return
+    }
+
+    const target =
+      event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
+    if (store.editing) return
+    if (!(target instanceof HTMLElement)) return
+    if (event.altKey || event.ctrlKey || event.metaKey) return
+
+    if (event.key === "ArrowDown" || event.key === "ArrowRight") {
+      event.preventDefault()
+      move(1)
+      return
+    }
+
+    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
+      event.preventDefault()
+      move(-1)
+      return
+    }
+
+    if (event.key === "Home") {
+      event.preventDefault()
+      focus(0)
+      return
+    }
+
+    if (event.key !== "End") return
+    event.preventDefault()
+    focus(count() - 1)
+  }
+
   const selectOption = (optIndex: number) => {
     if (sending()) return
 
@@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     const opt = options()[optIndex]
     if (!opt) return
     if (multi()) {
+      setStore("editing", false)
       toggle(opt.label)
       return
     }
@@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   const commitCustom = () => {
     setStore("editing", false)
     customUpdate(input())
+    focus(options().length)
   }
 
   const resizeInput = (el: HTMLTextAreaElement) => {
@@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       return
     }
 
-    setStore("tab", store.tab + 1)
+    const tab = store.tab + 1
+    setStore("tab", tab)
     setStore("editing", false)
+    focus(pickFocus(tab))
   }
 
   const back = () => {
     if (sending()) return
     if (store.tab <= 0) return
-    setStore("tab", store.tab - 1)
+    const tab = store.tab - 1
+    setStore("tab", tab)
     setStore("editing", false)
+    focus(pickFocus(tab))
   }
 
   const jump = (tab: number) => {
     if (sending()) return
     setStore("tab", tab)
     setStore("editing", false)
+    focus(pickFocus(tab))
   }
 
   return (
     <DockPrompt
       kind="question"
       ref={(el) => (root = el)}
+      onKeyDown={nav}
       header={
         <>
           <div data-slot="question-header-title">{summary()}</div>
@@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
       }
       footer={
         <>
-          <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
+          <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
             {language.t("ui.common.dismiss")}
           </Button>
           <div data-slot="question-footer-actions">
@@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                 {language.t("ui.common.back")}
               </Button>
             </Show>
-            <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
+            <Button
+              variant={last() ? "primary" : "secondary"}
+              size="large"
+              disabled={sending()}
+              onClick={next}
+              aria-keyshortcuts="Meta+Enter Control+Enter"
+            >
               {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
             </Button>
           </div>
@@ -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={
             <button
               type="button"
+              ref={customRef}
               data-slot="question-option"
               data-custom="true"
               data-picked={on()}
               role={multi() ? "checkbox" : "radio"}
               aria-checked={on()}
               disabled={sending()}
+              onFocus={() => setStore("focus", options().length)}
               onClick={customOpen}
             >
               <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
@@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
                   if (e.key === "Escape") {
                     e.preventDefault()
                     setStore("editing", false)
+                    focus(options().length)
                     return
                   }
+                  if ((e.metaKey || e.ctrlKey) && !e.altKey) return
                   if (e.key !== "Enter" || e.shiftKey) return
                   e.preventDefault()
                   commitCustom()

+ 3 - 0
packages/app/src/pages/session/file-tabs.tsx

@@ -302,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
     comments: fileComments,
     label: language.t("ui.lineComment.submit"),
     draftKey: () => path() ?? props.tab,
+    mention: {
+      items: file.searchFilesAndDirectories,
+    },
     state: {
       opened: () => note.openedComment,
       setOpened: (id) => setNote("openedComment", id),

+ 4 - 0
packages/app/src/pages/session/review-tab.tsx

@@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
   onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
   focusedFile?: string
   onScrollRef?: (el: HTMLDivElement) => void
+  commentMentions?: {
+    items: (query: string) => string[] | Promise<string[]>
+  }
   classes?: {
     root?: string
     header?: string
@@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       onLineCommentUpdate={props.onLineCommentUpdate}
       onLineCommentDelete={props.onLineCommentDelete}
       lineCommentActions={props.lineCommentActions}
+      lineCommentMention={props.commentMentions}
       comments={props.comments}
       focusedComment={props.focusedComment}
       onFocusedCommentChange={props.onFocusedCommentChange}

+ 10 - 7
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<string, string>) {
   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<string, string>) {
     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<string, string>) {
+function buildCommand(args: string, env: Record<string, string>, 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<string, string>) {
   }
 
   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<string, string>) {

+ 43 - 0
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)
+  })
+})

+ 88 - 0
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<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
+
+export function getUserShell() {
+  return process.env.SHELL || "/bin/sh"
+}
+
+export function parseShellEnv(out: Buffer) {
+  const env: Record<string, string> = {}
+  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<string, string> | null, env: Record<string, string>) {
+  return {
+    ...(shell || {}),
+    ...env,
+  }
+}

+ 302 - 24
packages/opencode/test/lib/llm-server.ts

@@ -8,6 +8,13 @@ export type Usage = { input: number; output: number }
 
 type Line = Record<string, unknown>
 
+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<string, unknown>
@@ -119,6 +126,276 @@ function bytes(input: Iterable<unknown>) {
   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<string, unknown>
+      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<string, unknown>) : {},
+  } satisfies Hit
+}
+
 namespace TestLLMServer {
   export interface Service {
     readonly url: string
@@ -342,30 +626,24 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
         return first
       }
 
-      yield* router.add(
-        "POST",
-        "/v1/chat/completions",
-        Effect.gen(function* () {
-          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,
-            {
-              url: new URL(req.originalUrl, "http://localhost"),
-              body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
-            },
-          ]
-          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())
 

+ 2 - 1
packages/ui/src/components/dock-prompt.tsx

@@ -7,11 +7,12 @@ export function DockPrompt(props: {
   children: JSX.Element
   footer: JSX.Element
   ref?: (el: HTMLDivElement) => void
+  onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
 }) {
   const slot = (name: string) => `${props.kind}-${name}`
 
   return (
-    <div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
+    <div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
       <DockShell data-slot={slot("body")}>
         <div data-slot={slot("header")}>{props.header}</div>
         <div data-slot={slot("content")}>{props.children}</div>

+ 7 - 1
packages/ui/src/components/line-comment-annotations.tsx

@@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web"
 import { useI18n } from "../context/i18n"
 import { createHoverCommentUtility } from "../pierre/comment-hover"
 import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
-import { LineComment, LineCommentEditor } from "./line-comment"
+import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
 
 export type LineCommentAnnotationMeta<T> =
   | { kind: "comment"; key: string; comment: T }
@@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
   comments: Accessor<T[]>
   draftKey: Accessor<string>
   label: string
+  mention?: LineCommentEditorProps["mention"]
   state: LineCommentStateProps<string>
   onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
   onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
@@ -85,6 +86,7 @@ type CommentProps = {
 type DraftProps = {
   value: string
   selection: JSX.Element
+  mention?: LineCommentEditorProps["mention"]
   onInput: (value: string) => void
   onCancel: VoidFunction
   onSubmit: (value: string) => void
@@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
               onPopoverFocusOut={view().editor!.onPopoverFocusOut}
               cancelLabel={view().editor!.cancelLabel}
               submitLabel={view().editor!.submitLabel}
+              mention={view().editor!.mention}
             />
           </Show>
         )
@@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
           onCancel={view().onCancel}
           onSubmit={view().onSubmit}
           onPopoverFocusOut={view().onPopoverFocusOut}
+          mention={view().mention}
         />
       )
     }, host)
@@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>(
                   return note.draft()
                 },
                 selection: formatSelectedLineLabel(comment.selection, i18n.t),
+                mention: props.mention,
                 onInput: note.setDraft,
                 onCancel: note.cancelDraft,
                 onSubmit: (value: string) => {
@@ -415,6 +420,7 @@ export function createLineCommentController<T extends LineCommentShape>(
         return note.draft()
       },
       selection: formatSelectedLineLabel(range, i18n.t),
+      mention: props.mention,
       onInput: note.setDraft,
       onCancel: note.cancelDraft,
       onSubmit: (comment) => {

+ 52 - 0
packages/ui/src/components/line-comment-styles.ts

@@ -178,6 +178,58 @@ export const lineCommentStyles = `
   box-shadow: var(--shadow-xs-border-select);
 }
 
+[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  max-height: 180px;
+  overflow: auto;
+  padding: 4px;
+  border: 1px solid var(--border-base);
+  border-radius: var(--radius-md);
+  background: var(--surface-base);
+}
+
+[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  min-width: 0;
+  padding: 6px 8px;
+  border: 0;
+  border-radius: var(--radius-sm);
+  background: transparent;
+  color: var(--text-strong);
+  text-align: left;
+}
+
+[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
+  background: var(--surface-raised-base-hover);
+}
+
+[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
+  display: flex;
+  align-items: center;
+  min-width: 0;
+  font-family: var(--font-family-sans);
+  font-size: var(--font-size-small);
+  line-height: var(--line-height-large);
+}
+
+[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
+  min-width: 0;
+  color: var(--text-weak);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
+  color: var(--text-strong);
+  white-space: nowrap;
+}
+
 [data-component="line-comment"] [data-slot="line-comment-actions"] {
   display: flex;
   align-items: center;

+ 136 - 1
packages/ui/src/components/line-comment.tsx

@@ -1,5 +1,8 @@
-import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
+import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { createEffect, createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
 import { Button } from "./button"
+import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
 import { installLineCommentStyles } from "./line-comment-styles"
 import { useI18n } from "../context/i18n"
@@ -183,6 +186,9 @@ export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "
   autofocus?: boolean
   cancelLabel?: string
   submitLabel?: string
+  mention?: {
+    items: (query: string) => string[] | Promise<string[]>
+  }
 }
 
 export const LineCommentEditor = (props: LineCommentEditorProps) => {
@@ -198,12 +204,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
     "autofocus",
     "cancelLabel",
     "submitLabel",
+    "mention",
   ])
 
   const refs = {
     textarea: undefined as HTMLTextAreaElement | undefined,
   }
   const [text, setText] = createSignal(split.value)
+  const [open, setOpen] = createSignal(false)
+
+  function selectMention(item: { path: string } | undefined) {
+    if (!item) return
+
+    const textarea = refs.textarea
+    const query = currentMention()
+    if (!textarea || !query) return
+
+    const value = `${text().slice(0, query.start)}@${item.path} ${text().slice(query.end)}`
+    const cursor = query.start + item.path.length + 2
+
+    setText(value)
+    split.onInput(value)
+    closeMention()
+
+    requestAnimationFrame(() => {
+      textarea.focus()
+      textarea.setSelectionRange(cursor, cursor)
+    })
+  }
+
+  const mention = useFilteredList<{ path: string }>({
+    items: async (query) => {
+      if (!split.mention) return []
+      if (!query.trim()) return []
+      const paths = await split.mention.items(query)
+      return paths.map((path) => ({ path }))
+    },
+    key: (item) => item.path,
+    filterKeys: ["path"],
+    onSelect: selectMention,
+  })
 
   const focus = () => refs.textarea?.focus()
   const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
@@ -221,6 +261,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
     setText(split.value)
   })
 
+  const closeMention = () => {
+    setOpen(false)
+    mention.clear()
+  }
+
+  const currentMention = () => {
+    const textarea = refs.textarea
+    if (!textarea) return
+    if (!split.mention) return
+    if (textarea.selectionStart !== textarea.selectionEnd) return
+
+    const end = textarea.selectionStart
+    const match = textarea.value.slice(0, end).match(/@(\S*)$/)
+    if (!match) return
+
+    return {
+      query: match[1] ?? "",
+      start: end - match[0].length,
+      end,
+    }
+  }
+
+  const syncMention = () => {
+    const item = currentMention()
+    if (!item) {
+      closeMention()
+      return
+    }
+
+    setOpen(true)
+    mention.onInput(item.query)
+  }
+
+  const selectActiveMention = () => {
+    const items = mention.flat()
+    if (items.length === 0) return
+    const active = mention.active()
+    selectMention(items.find((item) => item.path === active) ?? items[0])
+  }
+
   const submit = () => {
     const value = text().trim()
     if (!value) return
@@ -247,11 +327,38 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
             const value = (e.currentTarget as HTMLTextAreaElement).value
             setText(value)
             split.onInput(value)
+            syncMention()
           }}
+          on:click={() => syncMention()}
+          on:select={() => syncMention()}
           on:keydown={(e) => {
             const event = e as KeyboardEvent
             if (event.isComposing || event.keyCode === 229) return
             event.stopPropagation()
+            if (open()) {
+              if (e.key === "Escape") {
+                event.preventDefault()
+                closeMention()
+                return
+              }
+
+              if (e.key === "Tab") {
+                if (mention.flat().length === 0) return
+                event.preventDefault()
+                selectActiveMention()
+                return
+              }
+
+              const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
+              const ctrlNav =
+                event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
+              if ((nav || ctrlNav) && mention.flat().length > 0) {
+                mention.onKeyDown(event)
+                event.preventDefault()
+                return
+              }
+            }
+
             if (e.key === "Escape") {
               event.preventDefault()
               e.currentTarget.blur()
@@ -264,6 +371,34 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
             submit()
           }}
         />
+        <Show when={open() && mention.flat().length > 0}>
+          <div data-slot="line-comment-mention-list">
+            <For each={mention.flat().slice(0, 10)}>
+              {(item) => {
+                const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
+                const name = item.path.endsWith("/") ? "" : getFilename(item.path)
+                return (
+                  <button
+                    type="button"
+                    data-slot="line-comment-mention-item"
+                    data-active={mention.active() === item.path ? "" : undefined}
+                    onMouseDown={(event) => event.preventDefault()}
+                    onMouseEnter={() => mention.setActive(item.path)}
+                    onClick={() => selectMention(item)}
+                  >
+                    <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
+                    <div data-slot="line-comment-mention-path">
+                      <span data-slot="line-comment-mention-dir">{directory}</span>
+                      <Show when={name}>
+                        <span data-slot="line-comment-mention-file">{name}</span>
+                      </Show>
+                    </div>
+                  </button>
+                )
+              }}
+            </For>
+          </div>
+        </Show>
         <div data-slot="line-comment-actions">
           <div data-slot="line-comment-editor-label">
             {i18n.t("ui.lineComment.editorLabel.prefix")}

+ 99 - 9
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"
@@ -23,8 +22,10 @@ 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
+const REVIEW_MOUNT_MARGIN = 300
 
 export type SessionReviewDiffStyle = "unified" | "split"
 
@@ -68,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
@@ -88,6 +89,7 @@ export interface SessionReviewProps {
   diffs: ReviewDiff[]
   onViewFile?: (file: string) => void
   readFile?: (path: string) => Promise<FileContent | undefined>
+  lineCommentMention?: LineCommentEditorProps["mention"]
 }
 
 function ReviewCommentMenu(props: {
@@ -135,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<string, HTMLElement>()
+  const nodes = new Map<string, HTMLDivElement>()
   const [store, setStore] = createStore({
     open: [] as string[],
+    visible: {} as Record<string, boolean>,
     force: {} as Record<string, boolean>,
     selection: null as SessionReviewSelection | null,
     commenting: null as SessionReviewSelection | null,
@@ -152,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<string, SessionReviewComment[]>()
+    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<string, boolean> = {}
+
+    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<HTMLDivElement, Event> = (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<HTMLDivElement, Event>)(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 = () => {
@@ -272,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,
         }}
@@ -289,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 : "")
@@ -327,6 +405,7 @@ export const SessionReview = (props: SessionReviewProps) => {
                       comments,
                       label: i18n.t("ui.lineComment.submit"),
                       draftKey: () => file,
+                      mention: props.lineCommentMention,
                       state: {
                         opened: () => {
                           const current = opened()
@@ -378,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
 
                     onCleanup(() => {
                       anchors.delete(file)
+                      nodes.delete(file)
+                      queue()
                     })
 
                     const handleLineSelected = (range: SelectedLineRange | null) => {
@@ -462,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
                             ref={(el) => {
                               wrapper = el
                               anchors.set(file, el)
+                              nodes.set(file, el)
+                              queue()
                             }}
                           >
                             <Show when={expanded()}>
                               <Switch>
+                                <Match when={!mounted() && !tooLarge()}>
+                                  <div
+                                    data-slot="session-review-diff-placeholder"
+                                    class="rounded-lg border border-border-weak-base bg-background-stronger/40"
+                                    style={{ height: "160px" }}
+                                  />
+                                </Match>
                                 <Match when={tooLarge()}>
                                   <div data-slot="session-review-large-diff">
                                     <div data-slot="session-review-large-diff-title">