diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index f083bf3597..c560793375 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -13,6 +13,7 @@ import { sessionComposerDockSelector, sessionTodoToggleButtonSelector, } from "../selectors" +import { modKey } from "../utils" type Sdk = Parameters[0] type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } @@ -310,6 +311,73 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess }) }) +test("blocked question flow supports keyboard shortcuts", async ({ page, sdk, gotoSession }) => { + await withDockSession(sdk, "e2e composer dock question keyboard", async (session) => { + await withDockSeed(sdk, session.id, async () => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Need input", + question: "Pick one option", + options: [ + { label: "Continue", description: "Continue now" }, + { label: "Stop", description: "Stop here" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + const second = dock.locator('[data-slot="question-option"]').nth(1) + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("ArrowDown") + await expect(second).toBeFocused() + + await page.keyboard.press("Space") + await page.keyboard.press(`${modKey}+Enter`) + await expectQuestionOpen(page) + }) + }) +}) + +test("blocked question flow supports escape dismiss", async ({ page, sdk, gotoSession }) => { + await withDockSession(sdk, "e2e composer dock question escape", async (session) => { + await withDockSeed(sdk, session.id, async () => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Need input", + question: "Pick one option", + options: [ + { label: "Continue", description: "Continue now" }, + { label: "Stop", description: "Stop here" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + const first = dock.locator('[data-slot="question-option"]').first() + + await expectQuestionBlocked(page) + await expect(first).toBeFocused() + + await page.keyboard.press("Escape") + await expectQuestionOpen(page) + }) + }) +}) + test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock permission once", async (session) => { await gotoSession(session.id) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c8f72b8d2f..338b04ba65 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1344,6 +1344,9 @@ export const PromptInput: Component = (props) => { autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocorrect={store.mode === "normal" ? "on" : "off"} spellcheck={store.mode === "normal"} + inputMode="text" + // @ts-expect-error + autocomplete="off" onInput={handleInput} onPaste={handlePaste} onCompositionStart={handleCompositionStart} diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index ce09ae9217..06c3773310 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/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 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 4146fb4847..a1076e60ca 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/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, ] }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 917de35b1f..18bae6e2d0 100644 --- a/packages/app/src/pages/session.tsx +++ b/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} diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index ef1e52d264..38974b2465 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -29,16 +29,20 @@ function Option(props: { label: string description?: string disabled: boolean + ref?: (el: HTMLButtonElement) => void + onFocus?: VoidFunction onClick: VoidFunction }) { return (
@@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit {language.t("ui.common.back")} -
@@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit label={opt.label} description={opt.description} disabled={sending()} + ref={(el) => (optsRef[i()] = el)} + onFocus={() => setStore("focus", i())} onClick={() => selectOption(i())} /> )} @@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit fallback={ + ) + }} + + +
{i18n.t("ui.lineComment.editorLabel.prefix")} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83d2980f61..1040aa2921 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js" -import { onCleanup } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -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 + 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() + const nodes = new Map() const [store, setStore] = createStore({ open: [] as string[], + visible: {} as Record, force: {} as Record, selection: null as SessionReviewSelection | null, commenting: null as SessionReviewSelection | null, @@ -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() + for (const comment of props.comments ?? []) { + const list = next.get(comment.file) + if (list) { + list.push(comment) + continue + } + next.set(comment.file, [comment]) + } + return next + }) const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const hasDiffs = () => files().length > 0 - const handleChange = (open: string[]) => { - props.onOpenChange?.(open) - if (props.open !== undefined) return - setStore("open", open) + const syncVisible = () => { + frame = undefined + if (!scroll) return + + const root = scroll.getBoundingClientRect() + const top = root.top - REVIEW_MOUNT_MARGIN + const bottom = root.bottom + REVIEW_MOUNT_MARGIN + const openSet = new Set(open()) + const next: Record = {} + + for (const [file, el] of nodes) { + if (!openSet.has(file)) continue + const rect = el.getBoundingClientRect() + if (rect.bottom < top || rect.top > bottom) continue + next[file] = true + } + + const prev = untrack(() => store.visible) + const prevKeys = Object.keys(prev) + const nextKeys = Object.keys(next) + if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return + setStore("visible", next) + } + + const queue = () => { + if (frame !== undefined) return + frame = requestAnimationFrame(syncVisible) + } + + const pinned = (file: string) => + props.focusedComment?.file === file || + props.focusedFile === file || + selection()?.file === file || + commenting()?.file === file || + opened()?.file === file + + const handleScroll: JSX.EventHandler = (event) => { + queue() + const next = props.onScroll + if (!next) return + if (Array.isArray(next)) { + const [fn, data] = next as [(data: unknown, event: Event) => void, unknown] + fn(data, event) + return + } + ;(next as JSX.EventHandler)(event) + } + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + createEffect(() => { + props.open + files() + queue() + }) + + const handleChange = (next: string[]) => { + props.onOpenChange?.(next) + if (props.open === undefined) setStore("open", next) + queue() } const handleExpandOrCollapseAll = () => { @@ -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() }} > + +
+