Merge branch 'dev' into worktree-audit-effect-services
This commit is contained in:
commit
a96edcf39b
17 changed files with 983 additions and 47 deletions
|
|
@ -13,6 +13,7 @@ import {
|
||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
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 }) => {
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
|
|
||||||
|
|
@ -1344,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={store.mode === "normal"}
|
spellcheck={store.mode === "normal"}
|
||||||
|
inputMode="text"
|
||||||
|
// @ts-expect-error
|
||||||
|
autocomplete="off"
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={handleCompositionStart}
|
onCompositionStart={handleCompositionStart}
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
|
||||||
expect(synthetic).toHaveLength(1)
|
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)", () => {
|
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 }]
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
|
||||||
const fileQuery = (selection: FileSelection | undefined) =>
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
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 isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
|
|
@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
|
|
||||||
if (!comment) return [filePart]
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
|
|
@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
}),
|
}),
|
||||||
} satisfies PromptRequestPart,
|
} satisfies PromptRequestPart,
|
||||||
filePart,
|
filePart,
|
||||||
|
...mentions,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1046,6 +1046,9 @@ export default function Page() {
|
||||||
onLineCommentUpdate={updateCommentInContext}
|
onLineCommentUpdate={updateCommentInContext}
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
onLineCommentDelete={removeCommentFromContext}
|
||||||
lineCommentActions={reviewCommentActions()}
|
lineCommentActions={reviewCommentActions()}
|
||||||
|
commentMentions={{
|
||||||
|
items: file.searchFilesAndDirectories,
|
||||||
|
}}
|
||||||
comments={comments.all()}
|
comments={comments.all()}
|
||||||
focusedComment={comments.focus()}
|
focusedComment={comments.focus()}
|
||||||
onFocusedCommentChange={comments.setFocus}
|
onFocusedCommentChange={comments.setFocus}
|
||||||
|
|
|
||||||
|
|
@ -29,16 +29,20 @@ function Option(props: {
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
|
ref?: (el: HTMLButtonElement) => void
|
||||||
|
onFocus?: VoidFunction
|
||||||
onClick: VoidFunction
|
onClick: VoidFunction
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={props.ref}
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-picked={props.picked}
|
data-picked={props.picked}
|
||||||
role={props.multi ? "checkbox" : "radio"}
|
role={props.multi ? "checkbox" : "radio"}
|
||||||
aria-checked={props.picked}
|
aria-checked={props.picked}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
|
onFocus={props.onFocus}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<Mark multi={props.multi} picked={props.picked} />
|
<Mark multi={props.multi} picked={props.picked} />
|
||||||
|
|
@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
custom: cached?.custom ?? ([] as string[]),
|
custom: cached?.custom ?? ([] as string[]),
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
|
focus: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
|
let customRef: HTMLButtonElement | undefined
|
||||||
|
let optsRef: HTMLButtonElement[] = []
|
||||||
let replied = false
|
let replied = false
|
||||||
|
let focusFrame: number | undefined
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
const question = createMemo(() => questions()[store.tab])
|
||||||
const options = createMemo(() => question()?.options ?? [])
|
const options = createMemo(() => question()?.options ?? [])
|
||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
|
const count = createMemo(() => options().length + 1)
|
||||||
|
|
||||||
const summary = createMemo(() => {
|
const summary = createMemo(() => {
|
||||||
const n = Math.min(store.tab + 1, total())
|
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`)
|
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(() => {
|
onMount(() => {
|
||||||
let raf: number | undefined
|
let raf: number | undefined
|
||||||
const update = () => {
|
const update = () => {
|
||||||
|
|
@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
focus(pickFocus())
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||||
if (replied) return
|
if (replied) return
|
||||||
cache.set(props.request.id, {
|
cache.set(props.request.id, {
|
||||||
tab: store.tab,
|
tab: store.tab,
|
||||||
|
|
@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
|
|
||||||
const customToggle = () => {
|
const customToggle = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
setStore("focus", options().length)
|
||||||
|
|
||||||
if (!multi()) {
|
if (!multi()) {
|
||||||
setStore("customOn", store.tab, true)
|
setStore("customOn", store.tab, true)
|
||||||
|
|
@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
const value = input().trim()
|
const value = input().trim()
|
||||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(options().length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const customOpen = () => {
|
const customOpen = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
setStore("focus", options().length)
|
||||||
if (!on()) setStore("customOn", store.tab, true)
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
customUpdate(input(), 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) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
|
||||||
|
|
@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
const opt = options()[optIndex]
|
const opt = options()[optIndex]
|
||||||
if (!opt) return
|
if (!opt) return
|
||||||
if (multi()) {
|
if (multi()) {
|
||||||
|
setStore("editing", false)
|
||||||
toggle(opt.label)
|
toggle(opt.label)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
const commitCustom = () => {
|
const commitCustom = () => {
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
customUpdate(input())
|
customUpdate(input())
|
||||||
|
focus(options().length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||||
|
|
@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("tab", store.tab + 1)
|
const tab = store.tab + 1
|
||||||
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
if (store.tab <= 0) return
|
if (store.tab <= 0) return
|
||||||
setStore("tab", store.tab - 1)
|
const tab = store.tab - 1
|
||||||
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockPrompt
|
<DockPrompt
|
||||||
kind="question"
|
kind="question"
|
||||||
ref={(el) => (root = el)}
|
ref={(el) => (root = el)}
|
||||||
|
onKeyDown={nav}
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
|
|
@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}
|
}
|
||||||
footer={
|
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")}
|
{language.t("ui.common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<div data-slot="question-footer-actions">
|
<div data-slot="question-footer-actions">
|
||||||
|
|
@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
{language.t("ui.common.back")}
|
{language.t("ui.common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</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")}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
label={opt.label}
|
label={opt.label}
|
||||||
description={opt.description}
|
description={opt.description}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
|
ref={(el) => (optsRef[i()] = el)}
|
||||||
|
onFocus={() => setStore("focus", i())}
|
||||||
onClick={() => selectOption(i())}
|
onClick={() => selectOption(i())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={customRef}
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-custom="true"
|
||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
|
onFocus={() => setStore("focus", options().length)}
|
||||||
onClick={customOpen}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
|
|
@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(options().length)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
commitCustom()
|
commitCustom()
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
comments: fileComments,
|
comments: fileComments,
|
||||||
label: language.t("ui.lineComment.submit"),
|
label: language.t("ui.lineComment.submit"),
|
||||||
draftKey: () => path() ?? props.tab,
|
draftKey: () => path() ?? props.tab,
|
||||||
|
mention: {
|
||||||
|
items: file.searchFilesAndDirectories,
|
||||||
|
},
|
||||||
state: {
|
state: {
|
||||||
opened: () => note.openedComment,
|
opened: () => note.openedComment,
|
||||||
setOpened: (id) => setNote("openedComment", id),
|
setOpened: (id) => setNote("openedComment", id),
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
|
||||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||||
focusedFile?: string
|
focusedFile?: string
|
||||||
onScrollRef?: (el: HTMLDivElement) => void
|
onScrollRef?: (el: HTMLDivElement) => void
|
||||||
|
commentMentions?: {
|
||||||
|
items: (query: string) => string[] | Promise<string[]>
|
||||||
|
}
|
||||||
classes?: {
|
classes?: {
|
||||||
root?: string
|
root?: string
|
||||||
header?: string
|
header?: string
|
||||||
|
|
@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||||
onLineCommentDelete={props.onLineCommentDelete}
|
onLineCommentDelete={props.onLineCommentDelete}
|
||||||
lineCommentActions={props.lineCommentActions}
|
lineCommentActions={props.lineCommentActions}
|
||||||
|
lineCommentMention={props.commentMentions}
|
||||||
comments={props.comments}
|
comments={props.comments}
|
||||||
focusedComment={props.focusedComment}
|
focusedComment={props.focusedComment}
|
||||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { app } from "electron"
|
||||||
import treeKill from "tree-kill"
|
import treeKill from "tree-kill"
|
||||||
|
|
||||||
import { WSL_ENABLED_KEY } from "./constants"
|
import { WSL_ENABLED_KEY } from "./constants"
|
||||||
|
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||||
import { store } from "./store"
|
import { store } from "./store"
|
||||||
|
|
||||||
const CLI_INSTALL_DIR = ".opencode/bin"
|
const CLI_INSTALL_DIR = ".opencode/bin"
|
||||||
|
|
@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||||
const base = Object.fromEntries(
|
const base = Object.fromEntries(
|
||||||
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||||
)
|
)
|
||||||
const envs = {
|
const env = {
|
||||||
...base,
|
...base,
|
||||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||||
|
|
@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||||
XDG_STATE_HOME: app.getPath("userData"),
|
XDG_STATE_HOME: app.getPath("userData"),
|
||||||
...extraEnv,
|
...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(" ")}`)
|
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
||||||
const child = spawn(cmd, cmdArgs, {
|
const child = spawn(cmd, cmdArgs, {
|
||||||
env: envs,
|
env: envs,
|
||||||
|
|
@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||||
return false
|
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()) {
|
if (process.platform === "win32" && isWslEnabled()) {
|
||||||
console.log(`[cli] Using WSL mode`)
|
console.log(`[cli] Using WSL mode`)
|
||||||
const version = app.getVersion()
|
const version = app.getVersion()
|
||||||
|
|
@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidecar = getSidecarPath()
|
const sidecar = getSidecarPath()
|
||||||
const shell = process.env.SHELL || "/bin/sh"
|
const user = shell || getUserShell()
|
||||||
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||||
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
|
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
|
||||||
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
|
return { cmd: user, cmdArgs: ["-l", "-c", line] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function envPrefix(env: Record<string, string>) {
|
function envPrefix(env: Record<string, string>) {
|
||||||
|
|
|
||||||
43
packages/desktop-electron/src/main/shell-env.test.ts
Normal file
43
packages/desktop-electron/src/main/shell-env.test.ts
Normal file
|
|
@ -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
packages/desktop-electron/src/main/shell-env.ts
Normal file
88
packages/desktop-electron/src/main/shell-env.ts
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@ export type Usage = { input: number; output: number }
|
||||||
|
|
||||||
type Line = Record<string, unknown>
|
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 = {
|
type Hit = {
|
||||||
url: URL
|
url: URL
|
||||||
body: Record<string, unknown>
|
body: Record<string, unknown>
|
||||||
|
|
@ -119,6 +126,276 @@ function bytes(input: Iterable<unknown>) {
|
||||||
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
|
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) {
|
function send(item: Sse) {
|
||||||
const head = bytes(item.head)
|
const head = bytes(item.head)
|
||||||
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
|
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
|
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 {
|
namespace TestLLMServer {
|
||||||
export interface Service {
|
export interface Service {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
|
|
@ -342,30 +626,24 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* router.add(
|
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
|
||||||
"POST",
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
"/v1/chat/completions",
|
const next = pull()
|
||||||
Effect.gen(function* () {
|
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||||
const next = pull()
|
hits = [...hits, hit(req.originalUrl, body)]
|
||||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
yield* notify()
|
||||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
if (next.type !== "sse") return fail(next)
|
||||||
hits = [
|
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
||||||
...hits,
|
if (next.reset) {
|
||||||
{
|
yield* reset(next)
|
||||||
url: new URL(req.originalUrl, "http://localhost"),
|
return HttpServerResponse.empty()
|
||||||
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
}
|
||||||
},
|
return send(next)
|
||||||
]
|
})
|
||||||
yield* notify()
|
|
||||||
if (next.type === "sse" && next.reset) {
|
yield* router.add("POST", "/v1/chat/completions", handle("chat"))
|
||||||
yield* reset(next)
|
yield* router.add("POST", "/v1/responses", handle("responses"))
|
||||||
return HttpServerResponse.empty()
|
|
||||||
}
|
|
||||||
if (next.type === "sse") return send(next)
|
|
||||||
return fail(next)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* server.serve(router.asHttpEffect())
|
yield* server.serve(router.asHttpEffect())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@ export function DockPrompt(props: {
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
footer: JSX.Element
|
footer: JSX.Element
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
|
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||||
}) {
|
}) {
|
||||||
const slot = (name: string) => `${props.kind}-${name}`
|
const slot = (name: string) => `${props.kind}-${name}`
|
||||||
|
|
||||||
return (
|
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")}>
|
<DockShell data-slot={slot("body")}>
|
||||||
<div data-slot={slot("header")}>{props.header}</div>
|
<div data-slot={slot("header")}>{props.header}</div>
|
||||||
<div data-slot={slot("content")}>{props.children}</div>
|
<div data-slot={slot("content")}>{props.children}</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
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> =
|
export type LineCommentAnnotationMeta<T> =
|
||||||
| { kind: "comment"; key: string; comment: T }
|
| { kind: "comment"; key: string; comment: T }
|
||||||
|
|
@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
|
||||||
comments: Accessor<T[]>
|
comments: Accessor<T[]>
|
||||||
draftKey: Accessor<string>
|
draftKey: Accessor<string>
|
||||||
label: string
|
label: string
|
||||||
|
mention?: LineCommentEditorProps["mention"]
|
||||||
state: LineCommentStateProps<string>
|
state: LineCommentStateProps<string>
|
||||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||||
|
|
@ -85,6 +86,7 @@ type CommentProps = {
|
||||||
type DraftProps = {
|
type DraftProps = {
|
||||||
value: string
|
value: string
|
||||||
selection: JSX.Element
|
selection: JSX.Element
|
||||||
|
mention?: LineCommentEditorProps["mention"]
|
||||||
onInput: (value: string) => void
|
onInput: (value: string) => void
|
||||||
onCancel: VoidFunction
|
onCancel: VoidFunction
|
||||||
onSubmit: (value: string) => void
|
onSubmit: (value: string) => void
|
||||||
|
|
@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
||||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||||
cancelLabel={view().editor!.cancelLabel}
|
cancelLabel={view().editor!.cancelLabel}
|
||||||
submitLabel={view().editor!.submitLabel}
|
submitLabel={view().editor!.submitLabel}
|
||||||
|
mention={view().editor!.mention}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
||||||
onCancel={view().onCancel}
|
onCancel={view().onCancel}
|
||||||
onSubmit={view().onSubmit}
|
onSubmit={view().onSubmit}
|
||||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||||
|
mention={view().mention}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}, host)
|
}, host)
|
||||||
|
|
@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||||
return note.draft()
|
return note.draft()
|
||||||
},
|
},
|
||||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||||
|
mention: props.mention,
|
||||||
onInput: note.setDraft,
|
onInput: note.setDraft,
|
||||||
onCancel: note.cancelDraft,
|
onCancel: note.cancelDraft,
|
||||||
onSubmit: (value: string) => {
|
onSubmit: (value: string) => {
|
||||||
|
|
@ -415,6 +420,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||||
return note.draft()
|
return note.draft()
|
||||||
},
|
},
|
||||||
selection: formatSelectedLineLabel(range, i18n.t),
|
selection: formatSelectedLineLabel(range, i18n.t),
|
||||||
|
mention: props.mention,
|
||||||
onInput: note.setDraft,
|
onInput: note.setDraft,
|
||||||
onCancel: note.cancelDraft,
|
onCancel: note.cancelDraft,
|
||||||
onSubmit: (comment) => {
|
onSubmit: (comment) => {
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,58 @@ export const lineCommentStyles = `
|
||||||
box-shadow: var(--shadow-xs-border-select);
|
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"] {
|
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -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 { Button } from "./button"
|
||||||
|
import { FileIcon } from "./file-icon"
|
||||||
import { Icon } from "./icon"
|
import { Icon } from "./icon"
|
||||||
import { installLineCommentStyles } from "./line-comment-styles"
|
import { installLineCommentStyles } from "./line-comment-styles"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
|
|
@ -183,6 +186,9 @@ export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "
|
||||||
autofocus?: boolean
|
autofocus?: boolean
|
||||||
cancelLabel?: string
|
cancelLabel?: string
|
||||||
submitLabel?: string
|
submitLabel?: string
|
||||||
|
mention?: {
|
||||||
|
items: (query: string) => string[] | Promise<string[]>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||||
|
|
@ -198,12 +204,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||||
"autofocus",
|
"autofocus",
|
||||||
"cancelLabel",
|
"cancelLabel",
|
||||||
"submitLabel",
|
"submitLabel",
|
||||||
|
"mention",
|
||||||
])
|
])
|
||||||
|
|
||||||
const refs = {
|
const refs = {
|
||||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||||
}
|
}
|
||||||
const [text, setText] = createSignal(split.value)
|
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 focus = () => refs.textarea?.focus()
|
||||||
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
|
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
|
||||||
|
|
@ -221,6 +261,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||||
setText(split.value)
|
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 submit = () => {
|
||||||
const value = text().trim()
|
const value = text().trim()
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
@ -247,11 +327,38 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||||
setText(value)
|
setText(value)
|
||||||
split.onInput(value)
|
split.onInput(value)
|
||||||
|
syncMention()
|
||||||
}}
|
}}
|
||||||
|
on:click={() => syncMention()}
|
||||||
|
on:select={() => syncMention()}
|
||||||
on:keydown={(e) => {
|
on:keydown={(e) => {
|
||||||
const event = e as KeyboardEvent
|
const event = e as KeyboardEvent
|
||||||
if (event.isComposing || event.keyCode === 229) return
|
if (event.isComposing || event.keyCode === 229) return
|
||||||
event.stopPropagation()
|
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") {
|
if (e.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
e.currentTarget.blur()
|
e.currentTarget.blur()
|
||||||
|
|
@ -264,6 +371,34 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||||
submit()
|
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-actions">
|
||||||
<div data-slot="line-comment-editor-label">
|
<div data-slot="line-comment-editor-label">
|
||||||
{i18n.t("ui.lineComment.editorLabel.prefix")}
|
{i18n.t("ui.lineComment.editorLabel.prefix")}
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
|
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||||
import { onCleanup } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||||
|
|
@ -23,8 +22,10 @@ import { Dynamic } from "solid-js/web"
|
||||||
import { mediaKindFromPath } from "../pierre/media"
|
import { mediaKindFromPath } from "../pierre/media"
|
||||||
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
||||||
import { createLineCommentController } from "./line-comment-annotations"
|
import { createLineCommentController } from "./line-comment-annotations"
|
||||||
|
import type { LineCommentEditorProps } from "./line-comment"
|
||||||
|
|
||||||
const MAX_DIFF_CHANGED_LINES = 500
|
const MAX_DIFF_CHANGED_LINES = 500
|
||||||
|
const REVIEW_MOUNT_MARGIN = 300
|
||||||
|
|
||||||
export type SessionReviewDiffStyle = "unified" | "split"
|
export type SessionReviewDiffStyle = "unified" | "split"
|
||||||
|
|
||||||
|
|
@ -68,7 +69,7 @@ export interface SessionReviewProps {
|
||||||
split?: boolean
|
split?: boolean
|
||||||
diffStyle?: SessionReviewDiffStyle
|
diffStyle?: SessionReviewDiffStyle
|
||||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||||
onDiffRendered?: () => void
|
onDiffRendered?: VoidFunction
|
||||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||||
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
||||||
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
||||||
|
|
@ -88,6 +89,7 @@ export interface SessionReviewProps {
|
||||||
diffs: ReviewDiff[]
|
diffs: ReviewDiff[]
|
||||||
onViewFile?: (file: string) => void
|
onViewFile?: (file: string) => void
|
||||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||||
|
lineCommentMention?: LineCommentEditorProps["mention"]
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewCommentMenu(props: {
|
function ReviewCommentMenu(props: {
|
||||||
|
|
@ -135,11 +137,14 @@ type SessionReviewSelection = {
|
||||||
export const SessionReview = (props: SessionReviewProps) => {
|
export const SessionReview = (props: SessionReviewProps) => {
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let focusToken = 0
|
let focusToken = 0
|
||||||
|
let frame: number | undefined
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const fileComponent = useFileComponent()
|
const fileComponent = useFileComponent()
|
||||||
const anchors = new Map<string, HTMLElement>()
|
const anchors = new Map<string, HTMLElement>()
|
||||||
|
const nodes = new Map<string, HTMLDivElement>()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
open: [] as string[],
|
open: [] as string[],
|
||||||
|
visible: {} as Record<string, boolean>,
|
||||||
force: {} as Record<string, boolean>,
|
force: {} as Record<string, boolean>,
|
||||||
selection: null as SessionReviewSelection | null,
|
selection: null as SessionReviewSelection | null,
|
||||||
commenting: 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 open = () => props.open ?? store.open
|
||||||
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||||
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
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 diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||||
const hasDiffs = () => files().length > 0
|
const hasDiffs = () => files().length > 0
|
||||||
|
|
||||||
const handleChange = (open: string[]) => {
|
const syncVisible = () => {
|
||||||
props.onOpenChange?.(open)
|
frame = undefined
|
||||||
if (props.open !== undefined) return
|
if (!scroll) return
|
||||||
setStore("open", open)
|
|
||||||
|
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 = () => {
|
const handleExpandOrCollapseAll = () => {
|
||||||
|
|
@ -272,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
viewportRef={(el) => {
|
viewportRef={(el) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
props.scrollRef?.(el)
|
props.scrollRef?.(el)
|
||||||
|
queue()
|
||||||
}}
|
}}
|
||||||
onScroll={props.onScroll as any}
|
onScroll={handleScroll}
|
||||||
classList={{
|
classList={{
|
||||||
[props.classes?.root ?? ""]: !!props.classes?.root,
|
[props.classes?.root ?? ""]: !!props.classes?.root,
|
||||||
}}
|
}}
|
||||||
|
|
@ -289,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
const item = createMemo(() => diffs().get(file)!)
|
const item = createMemo(() => diffs().get(file)!)
|
||||||
|
|
||||||
const expanded = createMemo(() => open().includes(file))
|
const expanded = createMemo(() => open().includes(file))
|
||||||
|
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||||
const force = () => !!store.force[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 commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||||
|
|
||||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||||
|
|
@ -327,6 +405,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
comments,
|
comments,
|
||||||
label: i18n.t("ui.lineComment.submit"),
|
label: i18n.t("ui.lineComment.submit"),
|
||||||
draftKey: () => file,
|
draftKey: () => file,
|
||||||
|
mention: props.lineCommentMention,
|
||||||
state: {
|
state: {
|
||||||
opened: () => {
|
opened: () => {
|
||||||
const current = opened()
|
const current = opened()
|
||||||
|
|
@ -378,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
anchors.delete(file)
|
anchors.delete(file)
|
||||||
|
nodes.delete(file)
|
||||||
|
queue()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||||
|
|
@ -462,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
wrapper = el
|
wrapper = el
|
||||||
anchors.set(file, el)
|
anchors.set(file, el)
|
||||||
|
nodes.set(file, el)
|
||||||
|
queue()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={expanded()}>
|
<Show when={expanded()}>
|
||||||
<Switch>
|
<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()}>
|
<Match when={tooLarge()}>
|
||||||
<div data-slot="session-review-large-diff">
|
<div data-slot="session-review-large-diff">
|
||||||
<div data-slot="session-review-large-diff-title">
|
<div data-slot="session-review-large-diff-title">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue