diff --git a/CHANGELOG.md b/CHANGELOG.md index aa36456d..931a48f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file. ### Added +- Added session-persistent user-authored inline notes with `c` to draft/save notes and `hunk session note ...` commands for agent readback. + ### Changed ### Fixed diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 0e752027..3d233bef 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -301,6 +301,79 @@ describe("parseCli", () => { }); }); + test("parses session review with live notes included", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "review", + "session-1", + "--include-notes", + "--json", + ]); + + expect(parsed).toMatchObject({ + kind: "session", + action: "review", + selector: { sessionId: "session-1" }, + output: "json", + includePatch: false, + includeNotes: true, + }); + }); + + test("parses session note commands", async () => { + await expect( + parseCli([ + "bun", + "hunk", + "session", + "note", + "list", + "--repo", + ".", + "--file", + "README.md", + "--source", + "user", + "--json", + ]), + ).resolves.toEqual({ + kind: "session", + action: "note-list", + selector: { repoRoot: process.cwd() }, + filePath: "README.md", + source: "user", + output: "json", + }); + + await expect( + parseCli(["bun", "hunk", "session", "note", "get", "session-1", "user:1"]), + ).resolves.toEqual({ + kind: "session", + action: "note-get", + selector: { sessionId: "session-1" }, + noteId: "user:1", + output: "text", + }); + + await expect( + parseCli(["bun", "hunk", "session", "note", "rm", "--repo", ".", "user:1"]), + ).resolves.toEqual({ + kind: "session", + action: "note-rm", + selector: { repoRoot: process.cwd() }, + noteId: "user:1", + output: "text", + }); + }); + + test("rejects session note list with an unsupported source", async () => { + await expect( + parseCli(["bun", "hunk", "session", "note", "list", "session-1", "--source", "robot"]), + ).rejects.toThrow("Note source must be one of ai, agent, or user."); + }); + test("parses session navigate by hunk number", async () => { const parsed = await parseCli([ "bun", diff --git a/src/core/cli.ts b/src/core/cli.ts index 0b94564c..4d49bc0f 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -8,6 +8,7 @@ import type { LayoutMode, PagerCommandInput, ParsedCliInput, + ReviewNoteSource, SessionCommentApplyItemInput, } from "./types"; import { resolveBundledHunkReviewSkillPath } from "./paths"; @@ -596,8 +597,8 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session get --repo ", " hunk session context ", " hunk session context --repo ", - " hunk session review [--include-patch]", - " hunk session review --repo [--include-patch]", + " hunk session review [--include-patch] [--include-notes]", + " hunk session review --repo [--include-patch] [--include-notes]", " hunk session navigate ( | --repo ) --file (--hunk | --old-line | --new-line )", " hunk session navigate ( | --repo ) (--next-comment | --prev-comment)", " hunk session reload ( | --repo | --session-path ) [--source ] -- diff [ref] [-- ]", @@ -607,6 +608,9 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session comment list ( | --repo )", " hunk session comment rm ( | --repo ) ", " hunk session comment clear ( | --repo ) --yes", + " hunk session note list ( | --repo ) [--source ]", + " hunk session note get ( | --repo ) ", + " hunk session note rm ( | --repo ) ", ].join("\n") + "\n", }; } @@ -647,19 +651,23 @@ async function parseSessionCommand(tokens: string[]): Promise { .option("--json", "emit structured JSON"); if (subcommand === "review") { - command.option( - "--include-patch", - "include raw unified diff text for each file in review output", - ); + command + .option("--include-patch", "include raw unified diff text for each file in review output") + .option("--include-notes", "include live review notes in review output"); } let parsedSessionId: string | undefined; - let parsedOptions: { repo?: string; includePatch?: boolean; json?: boolean } = {}; + let parsedOptions: { + repo?: string; + includePatch?: boolean; + includeNotes?: boolean; + json?: boolean; + } = {}; command.action( ( sessionId: string | undefined, - options: { repo?: string; includePatch?: boolean; json?: boolean }, + options: { repo?: string; includePatch?: boolean; includeNotes?: boolean; json?: boolean }, ) => { parsedSessionId = sessionId; parsedOptions = options; @@ -678,6 +686,7 @@ async function parseSessionCommand(tokens: string[]): Promise { output: resolveJsonOutput(parsedOptions), selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), includePatch: parsedOptions.includePatch ?? false, + includeNotes: parsedOptions.includeNotes ?? false, }; } @@ -1162,6 +1171,109 @@ async function parseSessionCommand(tokens: string[]): Promise { throw new Error("Supported comment subcommands are add, apply, list, rm, and clear."); } + if (subcommand === "note") { + const [noteSubcommand, ...noteRest] = rest; + if (!noteSubcommand || noteSubcommand === "--help" || noteSubcommand === "-h") { + return { + kind: "help", + text: + [ + "Usage:", + " hunk session note list ( | --repo ) [--file ] [--source ]", + " hunk session note get ( | --repo ) ", + " hunk session note rm ( | --repo ) ", + ].join("\n") + "\n", + }; + } + + if (noteSubcommand === "list") { + const command = new Command("session note list") + .description("list inline review notes") + .argument("[sessionId]") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--file ", "filter notes to one diff file") + .option("--source ", "filter to ai, agent, or user notes") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { repo?: string; file?: string; source?: string; json?: boolean } = {}; + command.action((sessionId: string | undefined, options) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); + + if (noteRest.includes("--help") || noteRest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, noteRest); + if ( + parsedOptions.source !== undefined && + parsedOptions.source !== "ai" && + parsedOptions.source !== "agent" && + parsedOptions.source !== "user" + ) { + throw new Error("Note source must be one of ai, agent, or user."); + } + + return { + kind: "session", + action: "note-list", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + source: parsedOptions.source as ReviewNoteSource | undefined, + }; + } + + if (noteSubcommand === "get" || noteSubcommand === "rm") { + const command = new Command(`session note ${noteSubcommand}`) + .description( + noteSubcommand === "get" + ? "show one inline review note" + : "remove one user-authored inline review note", + ) + .argument("[sessionIdOrNoteId]") + .argument("[noteId]") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--json", "emit structured JSON"); + + let parsedSessionIdOrNoteId: string | undefined; + let parsedNoteId: string | undefined; + let parsedOptions: { repo?: string; json?: boolean } = {}; + command.action( + (sessionIdOrNoteId: string | undefined, noteId: string | undefined, options) => { + parsedSessionIdOrNoteId = sessionIdOrNoteId; + parsedNoteId = noteId; + parsedOptions = options; + }, + ); + + if (noteRest.includes("--help") || noteRest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, noteRest); + const resolvedNoteId = parsedOptions.repo + ? (parsedNoteId ?? parsedSessionIdOrNoteId) + : parsedNoteId; + const resolvedSessionId = parsedOptions.repo ? undefined : parsedSessionIdOrNoteId; + if (!resolvedNoteId) { + throw new Error(`Pass a note id to session note ${noteSubcommand}.`); + } + + return { + kind: "session", + action: noteSubcommand === "get" ? "note-get" : "note-rm", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(resolvedSessionId, parsedOptions.repo), + noteId: resolvedNoteId, + }; + } + + throw new Error("Supported note subcommands are list, get, and rm."); + } + throw new Error(`Unknown session command: ${subcommand}`); } diff --git a/src/core/types.ts b/src/core/types.ts index b79fa395..f1b33b2a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3,6 +3,23 @@ import type { FileDiffMetadata } from "@pierre/diffs"; export type LayoutMode = "auto" | "split" | "stack"; export type VcsMode = "git" | "jj"; +export type ReviewNoteSource = "ai" | "agent" | "user"; + +export interface ReviewNote { + id: string; + source: ReviewNoteSource; + filePath: string; + hunkIndex?: number; + oldRange?: [number, number]; + newRange?: [number, number]; + body: string; + title?: string; + author?: string; + createdAt: string; + updatedAt?: string; + editable: boolean; +} + export interface AgentAnnotation { id?: string; oldRange?: [number, number]; @@ -12,8 +29,11 @@ export interface AgentAnnotation { tags?: string[]; confidence?: "low" | "medium" | "high"; source?: string; + title?: string; author?: string; createdAt?: string; + updatedAt?: string; + editable?: boolean; } export interface AgentFileContext { @@ -119,6 +139,7 @@ export interface SessionReviewCommandInput { output: SessionCommandOutput; selector: SessionSelectorInput; includePatch: boolean; + includeNotes?: boolean; } export interface SessionNavigateCommandInput { @@ -200,6 +221,31 @@ export interface SessionCommentClearCommandInput { confirmed: boolean; } +export interface SessionNoteListCommandInput { + kind: "session"; + action: "note-list"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + filePath?: string; + source?: ReviewNoteSource; +} + +export interface SessionNoteGetCommandInput { + kind: "session"; + action: "note-get"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + noteId: string; +} + +export interface SessionNoteRemoveCommandInput { + kind: "session"; + action: "note-rm"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + noteId: string; +} + export type SessionCommandInput = | SessionListCommandInput | SessionGetCommandInput @@ -210,7 +256,10 @@ export type SessionCommandInput = | SessionCommentApplyCommandInput | SessionCommentListCommandInput | SessionCommentRemoveCommandInput - | SessionCommentClearCommandInput; + | SessionCommentClearCommandInput + | SessionNoteListCommandInput + | SessionNoteGetCommandInput + | SessionNoteRemoveCommandInput; export interface VcsCommandInput { kind: "vcs"; diff --git a/src/hunk-session/bridge.ts b/src/hunk-session/bridge.ts index 17ff9984..cebdd3f5 100644 --- a/src/hunk-session/bridge.ts +++ b/src/hunk-session/bridge.ts @@ -7,6 +7,7 @@ import type { NavigatedSelectionResult, ReloadedSessionResult, RemovedCommentResult, + RemovedUserNoteResult, } from "./types"; export interface HunkSessionBridgeHandlers { @@ -33,6 +34,7 @@ export interface HunkSessionBridgeHandlers { options?: { sourcePath?: string }, ) => Promise; removeLiveComment: (commentId: string) => RemovedCommentResult; + removeUserNote?: (noteId: string) => RemovedUserNoteResult; } /** Build the app-facing bridge handler the generic broker client calls into for Hunk commands. */ @@ -72,6 +74,11 @@ export function createHunkSessionBridge(handlers: HunkSessionBridgeHandlers) { }); case "remove_comment": return handlers.removeLiveComment(message.input.commentId); + case "remove_user_note": + if (!handlers.removeUserNote) { + throw new Error("This Hunk session cannot remove user notes."); + } + return handlers.removeUserNote(message.input.noteId); case "clear_comments": return handlers.clearLiveComments(message.input.filePath); } diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index b647b7a0..9d2b7ad7 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -14,9 +14,11 @@ import type { NavigatedSelectionResult, ReloadedSessionResult, RemovedCommentResult, + RemovedUserNoteResult, SelectedSessionContext, SessionLiveCommentSummary, SessionReview, + SessionReviewNoteSummary, } from "./types"; import type { SessionCommentAddCommandInput, @@ -25,6 +27,9 @@ import type { SessionCommentListCommandInput, SessionCommentRemoveCommandInput, SessionNavigateCommandInput, + SessionNoteGetCommandInput, + SessionNoteListCommandInput, + SessionNoteRemoveCommandInput, SessionReloadCommandInput, SessionReviewCommandInput, SessionSelectorInput, @@ -44,6 +49,9 @@ export interface HunkSessionCliClient { listComments(input: SessionCommentListCommandInput): Promise; removeComment(input: SessionCommentRemoveCommandInput): Promise; clearComments(input: SessionCommentClearCommandInput): Promise; + listNotes?: (input: SessionNoteListCommandInput) => Promise; + getNote?: (input: SessionNoteGetCommandInput) => Promise; + removeNote?: (input: SessionNoteRemoveCommandInput) => Promise; } async function extractResponseError(response: Response) { @@ -102,6 +110,7 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { action: "review", selector: input.selector, includePatch: input.includePatch, + includeNotes: input.includeNotes, }) ).review; } @@ -187,6 +196,37 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { }) ).result; } + + async listNotes(input: SessionNoteListCommandInput) { + return ( + await this.request<{ notes: SessionReviewNoteSummary[] }>({ + action: "note-list", + selector: input.selector, + filePath: input.filePath, + source: input.source, + }) + ).notes; + } + + async getNote(input: SessionNoteGetCommandInput) { + return ( + await this.request<{ note: SessionReviewNoteSummary }>({ + action: "note-get", + selector: input.selector, + noteId: input.noteId, + }) + ).note; + } + + async removeNote(input: SessionNoteRemoveCommandInput) { + return ( + await this.request<{ result: RemovedUserNoteResult }>({ + action: "note-rm", + selector: input.selector, + noteId: input.noteId, + }) + ).result; + } } /** Create the concrete Hunk session CLI client that speaks to the broker-backed HTTP API. */ @@ -350,6 +390,15 @@ export function formatReviewOutput(review: SessionReview) { `Selected hunk: ${hunkNumber}`, `Agent notes visible: ${review.showAgentNotes ? "yes" : "no"}`, `Live comments: ${review.liveCommentCount}`, + `Review notes: ${review.reviewNoteCount ?? review.reviewNotes?.length ?? 0}`, + ...(review.reviewNotes + ? [ + "Notes:", + ...review.reviewNotes.map( + (note) => ` - ${note.noteId} [${note.source}] ${note.filePath}: ${note.body}`, + ), + ] + : []), "Files:", ...review.files.flatMap((file) => [ ` - ${file.path} (+${file.additions} -${file.deletions}, hunks: ${file.hunkCount})`, @@ -422,6 +471,43 @@ export function formatRemoveCommentOutput( return `Removed live comment ${result.commentId} from ${describeSessionSelector(selector)}. Remaining comments: ${result.remainingCommentCount}.\n`; } +export function formatNoteListOutput( + selector: SessionSelectorInput, + notes: SessionReviewNoteSummary[], +) { + if (notes.length === 0) { + return `No review notes for ${describeSessionSelector(selector)}.\n`; + } + + return `${notes + .map((note) => + [ + `${note.noteId} ${note.filePath} [${note.source}]`, + ...(note.hunkIndex !== undefined ? [` hunk: ${note.hunkIndex + 1}`] : []), + ` body: ${note.body}`, + ...(note.author ? [` author: ${note.author}`] : []), + ].join("\n"), + ) + .join("\n\n")}\n`; +} + +export function formatNoteGetOutput(note: SessionReviewNoteSummary) { + return `${[ + `${note.noteId} ${note.filePath} [${note.source}]`, + ...(note.hunkIndex !== undefined ? [`hunk: ${note.hunkIndex + 1}`] : []), + `body: ${note.body}`, + ...(note.author ? [`author: ${note.author}`] : []), + "", + ].join("\n")}`; +} + +export function formatRemoveNoteOutput( + selector: SessionSelectorInput, + result: RemovedUserNoteResult, +) { + return `Removed user note ${result.noteId} from ${describeSessionSelector(selector)}. Remaining notes: ${result.remainingNoteCount}.\n`; +} + export function formatClearCommentsOutput( selector: SessionSelectorInput, result: ClearedCommentsResult, diff --git a/src/hunk-session/projections.test.ts b/src/hunk-session/projections.test.ts index de0cf72a..289ec90a 100644 --- a/src/hunk-session/projections.test.ts +++ b/src/hunk-session/projections.test.ts @@ -8,7 +8,9 @@ import { buildHunkSessionReview, buildListedHunkSession, buildSelectedHunkSessionContext, + getHunkSessionNote, listHunkSessionComments, + listHunkSessionNotes, } from "./projections"; function createEntry() { @@ -71,6 +73,31 @@ describe("hunk session projections", () => { expect(withPatch.files[0]).toEqual(expect.objectContaining({ patch: "@@ -1,1 +1,1 @@" })); }); + test("buildHunkSessionReview can include live review notes on demand", () => { + const entry = { + registration: createTestSessionRegistration(), + snapshot: createTestSessionSnapshot({ + reviewNoteCount: 1, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "src/example.ts", + body: "Please cover this case.", + author: "user", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + ], + }), + }; + + expect(buildHunkSessionReview(entry).reviewNotes).toBeUndefined(); + expect(buildHunkSessionReview(entry, { includeNotes: true }).reviewNotes).toEqual([ + expect.objectContaining({ noteId: "user:1", source: "user" }), + ]); + }); + test("listHunkSessionComments returns live comments and honors file filters", () => { const session = buildListedHunkSession({ registration: createTestSessionRegistration(), @@ -93,4 +120,41 @@ describe("hunk session projections", () => { expect.objectContaining({ commentId: "comment-1" }), ]); }); + + test("listHunkSessionNotes filters by file and source and getHunkSessionNote finds one id", () => { + const session = buildListedHunkSession({ + registration: createTestSessionRegistration(), + snapshot: createTestSessionSnapshot({ + reviewNoteCount: 2, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "src/example.ts", + body: "Human note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + { + noteId: "agent:1", + source: "agent", + filePath: "src/other.ts", + body: "Agent note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: false, + }, + ], + }), + }); + + expect(listHunkSessionNotes(session, { source: "user" })).toEqual([ + expect.objectContaining({ noteId: "user:1" }), + ]); + expect(listHunkSessionNotes(session, { filePath: "src/other.ts" })).toEqual([ + expect.objectContaining({ noteId: "agent:1" }), + ]); + expect(getHunkSessionNote(session, "user:1")).toEqual( + expect.objectContaining({ body: "Human note" }), + ); + }); }); diff --git a/src/hunk-session/projections.ts b/src/hunk-session/projections.ts index 2da4f777..302ecf3b 100644 --- a/src/hunk-session/projections.ts +++ b/src/hunk-session/projections.ts @@ -6,6 +6,7 @@ import type { SessionFileSummary, SessionLiveCommentSummary, SessionReview, + SessionReviewNoteSummary, SessionReviewFile, } from "./types"; @@ -107,7 +108,7 @@ export function buildSelectedHunkSessionContext(session: ListedSession): Selecte /** Project one raw broker entry into the Hunk review export used by `hunk session review`. */ export function buildHunkSessionReview( entry: HunkSessionEntryLike, - options: { includePatch?: boolean } = {}, + options: { includePatch?: boolean; includeNotes?: boolean } = {}, ): SessionReview { const selectedFile = findSelectedReviewFile(entry); const includePatch = options.includePatch ?? false; @@ -125,6 +126,9 @@ export function buildHunkSessionReview( : null, showAgentNotes: entry.snapshot.state.showAgentNotes, liveCommentCount: entry.snapshot.state.liveCommentCount, + reviewNoteCount: + entry.snapshot.state.reviewNoteCount ?? entry.snapshot.state.reviewNotes?.length ?? 0, + reviewNotes: options.includeNotes ? (entry.snapshot.state.reviewNotes ?? []) : undefined, files: entry.registration.info.files.map((file) => serializeReviewFile(file, includePatch)), }; } @@ -142,3 +146,26 @@ export function listHunkSessionComments( (comment) => comment.filePath === filter.filePath, ); } + +/** Return review notes for one Hunk session, optionally filtered to a file and source. */ +export function listHunkSessionNotes( + session: ListedSession, + filter: { filePath?: string; source?: SessionReviewNoteSummary["source"] } = {}, +): SessionReviewNoteSummary[] { + return (session.snapshot.state.reviewNotes ?? []).filter((note) => { + if (filter.filePath && note.filePath !== filter.filePath) { + return false; + } + + if (filter.source && note.source !== filter.source) { + return false; + } + + return true; + }); +} + +/** Find one review note in a Hunk session snapshot by id. */ +export function getHunkSessionNote(session: ListedSession, noteId: string) { + return (session.snapshot.state.reviewNotes ?? []).find((note) => note.noteId === noteId) ?? null; +} diff --git a/src/hunk-session/sessionRegistration.ts b/src/hunk-session/sessionRegistration.ts index 44549fe8..958d93cc 100644 --- a/src/hunk-session/sessionRegistration.ts +++ b/src/hunk-session/sessionRegistration.ts @@ -107,6 +107,8 @@ export function createInitialSessionSnapshot(bootstrap: AppBootstrap): HunkSessi showAgentNotes: bootstrap.initialShowAgentNotes ?? false, liveCommentCount: 0, liveComments: [], + reviewNoteCount: 0, + reviewNotes: [], }, }; } diff --git a/src/hunk-session/types.ts b/src/hunk-session/types.ts index 8d3ae1e7..b86d870c 100644 --- a/src/hunk-session/types.ts +++ b/src/hunk-session/types.ts @@ -1,4 +1,4 @@ -import type { AgentAnnotation, CliInput } from "../core/types"; +import type { AgentAnnotation, CliInput, ReviewNoteSource } from "../core/types"; import type { SessionBrokerClient } from "../session-broker/brokerClient"; import type { SessionClientMessage, @@ -56,6 +56,8 @@ export interface HunkSessionState { showAgentNotes: boolean; liveCommentCount: number; liveComments: SessionLiveCommentSummary[]; + reviewNoteCount?: number; + reviewNotes?: SessionReviewNoteSummary[]; } export type HunkSessionRegistration = SessionRegistration; @@ -103,6 +105,10 @@ export interface RemoveCommentToolInput extends SessionTargetInput { commentId: string; } +export interface RemoveUserNoteToolInput extends SessionTargetInput { + noteId: string; +} + export interface ClearCommentsToolInput extends SessionTargetInput { filePath?: string; } @@ -130,6 +136,21 @@ export interface SessionLiveCommentSummary { createdAt: string; } +export interface SessionReviewNoteSummary { + noteId: string; + source: ReviewNoteSource; + filePath: string; + hunkIndex?: number; + oldRange?: [number, number]; + newRange?: [number, number]; + body: string; + title?: string; + author?: string; + createdAt: string; + updatedAt?: string; + editable: boolean; +} + export interface AppliedCommentResult { commentId: string; fileId: string; @@ -156,6 +177,12 @@ export interface RemovedCommentResult { remainingCommentCount: number; } +export interface RemovedUserNoteResult { + noteId: string; + removed: boolean; + remainingNoteCount: number; +} + export interface ClearedCommentsResult { removedCount: number; remainingCommentCount: number; @@ -211,6 +238,8 @@ export interface SessionReview { selectedHunk: SessionReviewHunk | null; showAgentNotes: boolean; liveCommentCount: number; + reviewNoteCount?: number; + reviewNotes?: SessionReviewNoteSummary[]; files: SessionReviewFile[]; } @@ -219,6 +248,7 @@ export type HunkSessionCommandResult = | AppliedCommentBatchResult | NavigatedSelectionResult | RemovedCommentResult + | RemovedUserNoteResult | ClearedCommentsResult | ReloadedSessionResult; @@ -241,4 +271,5 @@ export type HunkSessionServerMessage = | SessionServerMessage<"navigate_to_hunk", NavigateToHunkToolInput> | SessionServerMessage<"reload_session", ReloadSessionToolInput> | SessionServerMessage<"remove_comment", RemoveCommentToolInput> + | SessionServerMessage<"remove_user_note", RemoveUserNoteToolInput> | SessionServerMessage<"clear_comments", ClearCommentsToolInput>; diff --git a/src/hunk-session/wire.ts b/src/hunk-session/wire.ts index 184abf28..e25f20d8 100644 --- a/src/hunk-session/wire.ts +++ b/src/hunk-session/wire.ts @@ -9,6 +9,7 @@ import type { HunkSessionInfo, HunkSessionState, SessionLiveCommentSummary, + SessionReviewNoteSummary, SessionReviewFile, SessionReviewHunk, } from "./types"; @@ -138,6 +139,47 @@ function parseSessionLiveCommentSummary(value: unknown): SessionLiveCommentSumma }; } +/** Parse one review note summary from the app-owned snapshot payload. */ +function parseSessionReviewNoteSummary(value: unknown): SessionReviewNoteSummary | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const noteId = brokerWireParsers.parseRequiredString(record.noteId); + const filePath = brokerWireParsers.parseRequiredString(record.filePath); + const body = brokerWireParsers.parseRequiredString(record.body); + const createdAt = brokerWireParsers.parseRequiredString(record.createdAt); + const source = + record.source === "ai" || record.source === "agent" || record.source === "user" + ? record.source + : null; + if ( + noteId === null || + filePath === null || + body === null || + createdAt === null || + source === null + ) { + return null; + } + + return { + noteId, + source, + filePath, + hunkIndex: brokerWireParsers.parseNonNegativeInt(record.hunkIndex) ?? undefined, + oldRange: parseOptionalRange(record.oldRange), + newRange: parseOptionalRange(record.newRange), + body, + title: brokerWireParsers.parseOptionalString(record.title), + author: brokerWireParsers.parseOptionalString(record.author), + createdAt, + updatedAt: brokerWireParsers.parseOptionalString(record.updatedAt), + editable: typeof record.editable === "boolean" ? record.editable : source === "user", + }; +} + /** Parse the app-owned registration info embedded inside one broker registration envelope. */ function parseHunkSessionInfo(value: unknown): HunkSessionInfo | null { const record = brokerWireParsers.asRecord(value); @@ -181,6 +223,9 @@ function parseHunkSessionState(value: unknown): HunkSessionState | null { const liveComments = record.liveComments .map(parseSessionLiveCommentSummary) .filter((comment): comment is SessionLiveCommentSummary => comment !== null); + const reviewNotes = (Array.isArray(record.reviewNotes) ? record.reviewNotes : []) + .map(parseSessionReviewNoteSummary) + .filter((note): note is SessionReviewNoteSummary => note !== null); return { selectedFileId: brokerWireParsers.parseOptionalString(record.selectedFileId), @@ -191,6 +236,8 @@ function parseHunkSessionState(value: unknown): HunkSessionState | null { showAgentNotes, liveCommentCount: liveComments.length, liveComments, + reviewNoteCount: reviewNotes.length, + reviewNotes, }; } diff --git a/src/session-broker/brokerServer.test.ts b/src/session-broker/brokerServer.test.ts index e262802c..6d56365f 100644 --- a/src/session-broker/brokerServer.test.ts +++ b/src/session-broker/brokerServer.test.ts @@ -116,7 +116,11 @@ async function openSessionSocket(port: number) { return socket; } -async function openRegisteredSession(port: number, sessionId = "session-1") { +async function openRegisteredSession( + port: number, + sessionId = "session-1", + snapshotOverrides: Parameters[0] = {}, +) { const socket = await openSessionSocket(port); socket.send( @@ -127,7 +131,10 @@ async function openRegisteredSession(port: number, sessionId = "session-1") { pid: process.pid, sessionId, }), - snapshot: createTestSessionSnapshot({ updatedAt: "2026-03-24T00:00:00.000Z" }), + snapshot: createTestSessionSnapshot({ + updatedAt: "2026-03-24T00:00:00.000Z", + ...snapshotOverrides, + }), }), ); @@ -213,7 +220,7 @@ describe("Hunk session daemon server", () => { expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({ version: 1, - daemonVersion: 2, + daemonVersion: 3, actions: [ "list", "get", @@ -226,6 +233,9 @@ describe("Hunk session daemon server", () => { "comment-list", "comment-rm", "comment-clear", + "note-list", + "note-get", + "note-rm", ], }); @@ -572,6 +582,110 @@ describe("Hunk session daemon server", () => { } }); + test("serves review notes through the session API", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = serveSessionBrokerDaemon(); + const socket = await openRegisteredSession(port, "session-1", { + reviewNoteCount: 2, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "src/example.ts", + hunkIndex: 0, + body: "Human note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + { + noteId: "agent:1", + source: "agent", + filePath: "src/other.ts", + body: "Agent note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: false, + }, + ], + }); + + try { + const listResponse = await fetch(`http://127.0.0.1:${port}/session-api`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "note-list", + selector: { sessionId: "session-1" }, + source: "user", + }), + }); + + expect(listResponse.status).toBe(200); + await expect(listResponse.json()).resolves.toMatchObject({ + notes: [{ noteId: "user:1", body: "Human note" }], + }); + + const getResponse = await fetch(`http://127.0.0.1:${port}/session-api`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "note-get", + selector: { sessionId: "session-1" }, + noteId: "agent:1", + }), + }); + + expect(getResponse.status).toBe(200); + await expect(getResponse.json()).resolves.toMatchObject({ + note: { noteId: "agent:1", body: "Agent note" }, + }); + } finally { + socket.close(); + server.stop(true); + } + }); + + test("forwards user note removal through the session API", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const original = SessionBrokerState.prototype.dispatchCommand; + SessionBrokerState.prototype.dispatchCommand = (({ command, input }: any) => { + expect(command).toBe("remove_user_note"); + expect(input).toMatchObject({ + sessionId: "session-1", + noteId: "user:1", + }); + + return Promise.resolve({ noteId: "user:1", removed: true, remainingNoteCount: 0 }); + }) as SessionBrokerState["dispatchCommand"]; + + const server = serveSessionBrokerDaemon(); + + try { + const response = await fetch(`http://127.0.0.1:${port}/session-api`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "note-rm", + selector: { sessionId: "session-1" }, + noteId: "user:1", + }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + result: { noteId: "user:1", removed: true, remainingNoteCount: 0 }, + }); + } finally { + SessionBrokerState.prototype.dispatchCommand = original; + server.stop(true); + } + }); + test("forwards comment batches through the session API", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; diff --git a/src/session-broker/brokerServer.ts b/src/session-broker/brokerServer.ts index fa3a600e..fb3d0eba 100644 --- a/src/session-broker/brokerServer.ts +++ b/src/session-broker/brokerServer.ts @@ -21,7 +21,9 @@ import type { NavigatedSelectionResult, ReloadedSessionResult, RemovedCommentResult, + RemovedUserNoteResult, } from "../hunk-session/types"; +import { getHunkSessionNote, listHunkSessionNotes } from "../hunk-session/projections"; import { HUNK_SESSION_API_PATH, HUNK_SESSION_API_VERSION, @@ -49,6 +51,9 @@ const SUPPORTED_SESSION_ACTIONS: SessionDaemonAction[] = [ "comment-list", "comment-rm", "comment-clear", + "note-list", + "note-get", + "note-rm", ]; export interface ServeSessionBrokerDaemonOptions { @@ -115,11 +120,16 @@ async function handleSessionApiRequest(state: HunkSessionBrokerState, request: R case "context": response = { context: state.getSelectedContext(input.selector) }; break; - case "review": - response = { - review: state.getSessionReview(input.selector, { includePatch: input.includePatch }), - }; + case "review": { + const review = state.getSessionReview(input.selector, { includePatch: input.includePatch }); + if (input.includeNotes) { + const session = state.getSession(input.selector); + review.reviewNotes = session.snapshot.state.reviewNotes ?? []; + review.reviewNoteCount = review.reviewNotes.length; + } + response = { review }; break; + } case "navigate": { if ( !input.commentDirection && @@ -234,6 +244,35 @@ async function handleSessionApiRequest(state: HunkSessionBrokerState, request: R }), }; break; + case "note-list": + response = { + notes: listHunkSessionNotes(state.getSession(input.selector), { + filePath: input.filePath, + source: input.source, + }), + }; + break; + case "note-get": { + const note = getHunkSessionNote(state.getSession(input.selector), input.noteId); + if (!note) { + throw new Error(`No review note matches id ${input.noteId}.`); + } + response = { note }; + break; + } + case "note-rm": + response = { + result: await state.dispatchCommand({ + selector: input.selector, + command: "remove_user_note", + input: { + ...input.selector, + noteId: input.noteId, + }, + timeoutMessage: "Timed out waiting for the session to remove the requested note.", + }), + }; + break; default: throw new Error("Unknown session API action."); } diff --git a/src/session/commands.test.ts b/src/session/commands.test.ts index 6afdba4c..212c1fa0 100644 --- a/src/session/commands.test.ts +++ b/src/session/commands.test.ts @@ -71,6 +71,9 @@ function createClient(overrides: Partial): HunkDaemonCliCli "comment-list", "comment-rm", "comment-clear", + "note-list", + "note-get", + "note-rm", ], }), listSessions: async () => [], @@ -366,6 +369,7 @@ describe("session command compatibility checks", () => { getSessionReview: async (input) => { expect(input.selector).toEqual({ sessionId: "session-1" }); expect(input.includePatch).toBe(false); + expect(input.includeNotes).toBe(false); return { sessionId: "session-1", @@ -425,6 +429,7 @@ describe("session command compatibility checks", () => { selector: { sessionId: "session-1" }, output: "json", includePatch: false, + includeNotes: false, } satisfies SessionCommandInput); expect(JSON.parse(output)).toEqual({ @@ -485,6 +490,7 @@ describe("session command compatibility checks", () => { getSessionReview: async (input) => { expect(input.selector).toEqual({ sessionId: "session-1" }); expect(input.includePatch).toBe(true); + expect(input.includeNotes).toBe(false); return { sessionId: "session-1", @@ -546,6 +552,7 @@ describe("session command compatibility checks", () => { selector: { sessionId: "session-1" }, output: "json", includePatch: true, + includeNotes: false, } satisfies SessionCommandInput); expect(JSON.parse(output)).toEqual({ @@ -601,6 +608,129 @@ describe("session command compatibility checks", () => { }); }); + test("runs review commands through the daemon with notes when requested", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getSessionReview: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.includePatch).toBe(false); + expect(input.includeNotes).toBe(true); + + return { + ...createTestSessionReview(false), + reviewNoteCount: 1, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "README.md", + body: "Please simplify this.", + author: "user", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + ], + }; + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "review", + selector: { sessionId: "session-1" }, + output: "json", + includePatch: false, + includeNotes: true, + } satisfies SessionCommandInput); + + expect(JSON.parse(output)).toMatchObject({ + review: { + reviewNoteCount: 1, + reviewNotes: [{ noteId: "user:1", body: "Please simplify this." }], + }, + }); + }); + + test("runs note commands through the daemon", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listNotes: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.filePath).toBe("README.md"); + expect(input.source).toBe("user"); + return [ + { + noteId: "user:1", + source: "user", + filePath: "README.md", + hunkIndex: 0, + body: "Human note", + author: "user", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + ]; + }, + getNote: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.noteId).toBe("user:1"); + return { + noteId: "user:1", + source: "user", + filePath: "README.md", + body: "Human note", + author: "user", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }; + }, + removeNote: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.noteId).toBe("user:1"); + return { noteId: "user:1", removed: true, remainingNoteCount: 0 }; + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const listOutput = await runSessionCommand({ + kind: "session", + action: "note-list", + selector: { sessionId: "session-1" }, + filePath: "README.md", + source: "user", + output: "json", + } satisfies SessionCommandInput); + expect(JSON.parse(listOutput)).toMatchObject({ + notes: [{ noteId: "user:1", body: "Human note" }], + }); + + const getOutput = await runSessionCommand({ + kind: "session", + action: "note-get", + selector: { sessionId: "session-1" }, + noteId: "user:1", + output: "text", + } satisfies SessionCommandInput); + expect(getOutput).toContain("user:1 README.md [user]"); + expect(getOutput).toContain("body: Human note"); + + const removeOutput = await runSessionCommand({ + kind: "session", + action: "note-rm", + selector: { sessionId: "session-1" }, + noteId: "user:1", + output: "text", + } satisfies SessionCommandInput); + expect(removeOutput).toBe( + "Removed user note user:1 from session session-1. Remaining notes: 0.\n", + ); + }); + test("runs reload commands through the daemon and returns the replacement session summary", async () => { setSessionCommandTestHooks({ createClient: () => diff --git a/src/session/commands.ts b/src/session/commands.ts index b76d0c94..c4590706 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -21,8 +21,11 @@ import { formatContextOutput, formatListOutput, formatNavigationOutput, + formatNoteGetOutput, + formatNoteListOutput, formatReloadOutput, formatRemoveCommentOutput, + formatRemoveNoteOutput, formatReviewOutput, formatSessionOutput, stringifyJson, @@ -43,6 +46,9 @@ const REQUIRED_ACTION_BY_COMMAND: Record + formatNoteListOutput(input.selector, notes), + ); + } + case "note-get": { + if (!client.getNote) { + throw new Error("The active Hunk session client does not support note-get."); + } + const note = await client.getNote({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { note }, () => formatNoteGetOutput(note)); + } + case "note-rm": { + if (!client.removeNote) { + throw new Error("The active Hunk session client does not support note-rm."); + } + const result = await client.removeNote({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { result }, () => + formatRemoveNoteOutput(input.selector, result), + ); + } } } diff --git a/src/session/protocol.ts b/src/session/protocol.ts index 2814cd55..0057a41b 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -5,6 +5,9 @@ import type { SessionCommentListCommandInput, SessionCommentRemoveCommandInput, SessionNavigateCommandInput, + SessionNoteGetCommandInput, + SessionNoteListCommandInput, + SessionNoteRemoveCommandInput, SessionReloadCommandInput, SessionReviewCommandInput, SessionSelectorInput, @@ -17,9 +20,11 @@ import type { NavigatedSelectionResult, ReloadedSessionResult, RemovedCommentResult, + RemovedUserNoteResult, SelectedSessionContext, SessionLiveCommentSummary, SessionReview, + SessionReviewNoteSummary, } from "../hunk-session/types"; export const HUNK_SESSION_API_PATH = "/session-api"; @@ -30,7 +35,7 @@ export const HUNK_SESSION_API_VERSION = 1; * Version daemon/session compatibility separately from the HTTP action surface so newer Hunk * builds can refresh an older daemon even when it still exposes the same API endpoints. */ -export const HUNK_SESSION_DAEMON_VERSION = 2; +export const HUNK_SESSION_DAEMON_VERSION = 3; export type SessionDaemonAction = | "list" @@ -43,7 +48,10 @@ export type SessionDaemonAction = | "comment-apply" | "comment-list" | "comment-rm" - | "comment-clear"; + | "comment-clear" + | "note-list" + | "note-get" + | "note-rm"; export interface SessionDaemonCapabilities { version: number; @@ -67,6 +75,7 @@ export type SessionDaemonRequest = action: "review"; selector: SessionSelectorInput; includePatch: SessionReviewCommandInput["includePatch"]; + includeNotes: SessionReviewCommandInput["includeNotes"]; } | { action: "navigate"; @@ -114,6 +123,22 @@ export type SessionDaemonRequest = action: "comment-clear"; selector: SessionCommentClearCommandInput["selector"]; filePath?: string; + } + | { + action: "note-list"; + selector: SessionNoteListCommandInput["selector"]; + filePath?: string; + source?: SessionNoteListCommandInput["source"]; + } + | { + action: "note-get"; + selector: SessionNoteGetCommandInput["selector"]; + noteId: string; + } + | { + action: "note-rm"; + selector: SessionNoteRemoveCommandInput["selector"]; + noteId: string; }; export type SessionDaemonResponse = @@ -126,5 +151,8 @@ export type SessionDaemonResponse = | { result: AppliedCommentResult } | { result: AppliedCommentBatchResult } | { comments: SessionLiveCommentSummary[] } + | { notes: SessionReviewNoteSummary[] } + | { note: SessionReviewNoteSummary } | { result: RemovedCommentResult } + | { result: RemovedUserNoteResult } | { result: ClearedCommentsResult }; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 56b71523..01e5cca0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -21,14 +21,14 @@ import { import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; -import { useReviewController } from "./hooks/useReviewController"; +import { useReviewController, type UserNoteLineTarget } from "./hooks/useReviewController"; import { buildAppMenus } from "./lib/appMenus"; import { fileRowId } from "./lib/ids"; import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; import { resolveTheme, THEMES } from "./themes"; -type FocusArea = "files" | "filter"; +type FocusArea = "files" | "filter" | "note"; const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8; @@ -150,6 +150,9 @@ export function App({ openAgentNotes, reloadSession: onReloadSession, removeLiveComment: review.removeLiveComment, + removeUserNote: review.removeUserNote, + reviewNoteCount: review.reviewNoteCount, + reviewNoteSummaries: review.reviewNoteSummaries, selectedFile, selectedHunk: review.selectedHunk, selectedHunkIndex, @@ -460,6 +463,29 @@ export function App({ setFocusArea((current) => (current === "files" ? "filter" : "files")); }, []); + /** Start a user-authored inline note and move keyboard focus into it. */ + const startUserNote = useCallback( + (fileId?: string, hunkIndex?: number, target?: UserNoteLineTarget) => { + const draft = review.startUserNote(fileId, hunkIndex, target); + if (draft) { + setFocusArea("note"); + } + }, + [review.startUserNote], + ); + + /** Save the active draft note and return focus to review navigation. */ + const saveDraftNote = useCallback(() => { + review.saveDraftNote(); + setFocusArea("files"); + }, [review.saveDraftNote]); + + /** Cancel the active draft note and return focus to review navigation. */ + const cancelDraftNote = useCallback(() => { + review.cancelDraftNote(); + setFocusArea("files"); + }, [review.cancelDraftNote]); + /** Cycle through the available built-in themes. */ const cycleTheme = useCallback(() => { const currentIndex = THEMES.findIndex((theme) => theme.id === activeTheme.id); @@ -545,6 +571,7 @@ export function App({ closeHelp, closeMenu, cycleTheme, + cancelDraftNote, focusArea, focusFilter, moveToAnnotatedHunk, @@ -554,9 +581,11 @@ export function App({ pagerMode, requestQuit, scrollCodeHorizontally, + saveDraftNote, scrollDiff, selectLayoutMode, showHelp, + startUserNote: () => startUserNote(), switchMenu, toggleAgentNotes, toggleFocusArea, @@ -709,6 +738,7 @@ export function App({ selectedFileId={selectedFile?.id} selectedHunkIndex={selectedHunkIndex} scrollToNote={review.scrollToNote} + draftNote={review.draftNote} separatorWidth={diffSeparatorWidth} showAgentNotes={showAgentNotes} showLineNumbers={showLineNumbers} @@ -722,6 +752,11 @@ export function App({ theme={activeTheme} width={diffPaneWidth} onOpenAgentNotesAtHunk={openAgentNotesAtHunk} + onRemoveUserNote={review.removeUserNote} + onSaveDraftNote={saveDraftNote} + onStartUserNoteAtHunk={startUserNote} + onUpdateDraftNote={review.updateDraftNote} + onCancelDraftNote={cancelDraftNote} onScrollCodeHorizontally={(delta) => { scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS); }} diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 82dc9e45..75e793e3 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -990,12 +990,12 @@ describe("App interactions", () => { await flush(setup); const frame = setup.captureCharFrame(); - expect(frame).toContain("AI note · ▶ new 2"); + expect(frame).toContain("AI note - prefs.ts R2"); expect(frame).toContain("Annotation for prefs.ts"); expect(frame).toContain("Why prefs.ts changed"); expect(frame).not.toContain("@@ -1,1 +1,2 @@"); expect(frame).not.toContain("1 - export const message"); - expect(frame.indexOf("AI note · ▶ new 2")).toBeLessThan( + expect(frame.indexOf("AI note - prefs.ts R2")).toBeLessThan( frame.indexOf("export const added = true;"), ); } finally { diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 1ed1f595..2d55c22d 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -51,6 +51,7 @@ export function HelpDialog({ title: "Review", items: [ ["/", "focus file filter"], + ["c", "create review note"], ["Tab", "toggle files/filter focus"], ["F10", "open menus"], [canRefresh ? "r / q" : "q", canRefresh ? "reload / quit" : "quit"], diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index a14be446..9550bec5 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,11 +1,21 @@ -import type { AgentAnnotation, LayoutMode } from "../../../core/types"; +import type { TextareaRenderable } from "@opentui/core"; +import { flushSync } from "@opentui/react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; +import { isEscapeKey } from "../../lib/keyboard"; import { wrapText } from "../../lib/agentPopover"; -import { annotationRangeLabel } from "../../lib/agentAnnotations"; +import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; -function inlineNoteTitle(noteIndex: number, noteCount: number) { - return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note"; +function inlineNoteTitle(annotation: AgentAnnotation, noteIndex: number, noteCount: number) { + if (annotation.source === "user-draft") { + return "Draft note"; + } + + const source = reviewNoteSource(annotation); + const label = source === "user" ? "Your note" : source === "agent" ? "Agent note" : "AI note"; + return noteCount > 1 ? `${label} ${noteIndex + 1}/${noteCount}` : label; } interface AgentInlineNoteLine { @@ -17,6 +27,26 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } +function draftLineCount(text: string) { + return Math.max(1, text.split("\n").length); +} + +function isNewlineKey(key: { ctrl?: boolean; name?: string; sequence?: string }) { + return ( + key.name === "return" || + key.name === "enter" || + key.name === "linefeed" || + key.sequence === "\r" || + key.sequence === "\n" || + (key.ctrl && key.name === "j") + ); +} + +/** Wrap text while preserving author-entered line breaks in review notes. */ +function wrapNoteText(text: string, width: number) { + return text.split("\n").flatMap((line) => wrapText(line, width)); +} + function splitColumnWidths(width: number) { const markerWidth = 1; const separatorWidth = 1; @@ -48,16 +78,26 @@ export function measureAgentInlineNoteHeight({ const boxWidth = clamp(preferredDockWidth, 28, Math.max(28, width - 4)); const innerWidth = Math.max(1, boxWidth - 2); const bodyWidth = innerWidth; + const contentWidth = Math.max(1, bodyWidth - 2); const lines: AgentInlineNoteLine[] = [ - ...wrapText(annotation.summary, bodyWidth).map((text) => ({ kind: "summary" as const, text })), + ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ + kind: "summary" as const, + text, + })), ...(annotation.rationale - ? wrapText(annotation.rationale, bodyWidth).map((text) => ({ + ? wrapNoteText(annotation.rationale, contentWidth).map((text) => ({ kind: "rationale" as const, text, })) : []), ]; + if (annotation.source === "user-draft") { + const draftBodyRows = Math.max(3, draftLineCount(annotation.summary) + 2); + // Title border + expandable body + button footer. + return 1 + draftBodyRows + 3; + } + // top border + title row + body lines + bottom border return 3 + lines.length; } @@ -66,24 +106,72 @@ export function measureAgentInlineNoteHeight({ export function AgentInlineNote({ annotation, anchorSide, + file, layout, noteCount = 1, noteIndex = 0, + draft, onClose, theme, width, }: { annotation: AgentAnnotation; anchorSide?: "old" | "new"; + file?: DiffFile; layout: Exclude; noteCount?: number; noteIndex?: number; + draft?: { + body: string; + focused: boolean; + onCancel: () => void; + onInput: (value: string) => void; + onSave: () => void; + }; onClose?: () => void; theme: AppTheme; width: number; }) { + const textareaRef = useRef(null); + const [draftLineCountHint, setDraftLineCountHint] = useState(() => + draftLineCount(draft?.body ?? ""), + ); + + useEffect(() => { + setDraftLineCountHint(draftLineCount(draft?.body ?? "")); + }, [draft?.body]); + + const draftVisibleRows = draft ? Math.max(draftLineCountHint, draftLineCount(draft.body)) : 0; + + useLayoutEffect(() => { + if (!draft || draftVisibleRows <= 0) { + return; + } + + const textarea = textareaRef.current; + if (!textarea) { + return; + } + + const viewport = textarea.editorView.getViewport(); + if (viewport.offsetY === 0 && viewport.height === draftVisibleRows) { + return; + } + + // The textarea follows the cursor after Enter while its old one-line viewport is still active. + // Once the composer grows to fit the new line, reset the viewport so previous lines stay visible. + textarea.editorView.setViewport(viewport.offsetX, 0, viewport.width, draftVisibleRows, false); + textarea.requestRender(); + }, [draft, draftVisibleRows]); + + const updateDraftLineCountHint = (nextLineCount: number) => { + flushSync(() => { + setDraftLineCountHint(nextLineCount); + }); + }; + const closeText = onClose ? "[x]" : ""; - const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`; + const titleText = `${inlineNoteTitle(annotation, noteIndex, noteCount)} - ${annotationRangeLabel(annotation, file)}`; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; @@ -99,138 +187,332 @@ export function AgentInlineNote({ ? 0 : Math.min(4, Math.max(0, width - boxWidth)); const innerWidth = Math.max(1, boxWidth - 2); - const titleWidth = Math.max(1, innerWidth - (closeText ? closeText.length + 1 : 0)); + const closeGapWidth = closeText ? 1 : 0; + const closeWidth = closeText.length; const bodyWidth = innerWidth; + const contentWidth = Math.max(1, bodyWidth - 2); const lines: AgentInlineNoteLine[] = [ - ...wrapText(annotation.summary, bodyWidth).map((text) => ({ kind: "summary" as const, text })), + ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ + kind: "summary" as const, + text, + })), ...(annotation.rationale - ? wrapText(annotation.rationale, bodyWidth).map((text) => ({ + ? wrapNoteText(annotation.rationale, contentWidth).map((text) => ({ kind: "rationale" as const, text, })) : []), ]; - const topBorder = `┌${"─".repeat(Math.max(0, boxWidth - 2))}┐`; - const bottomBorder = - anchorSide === "new" && canDockRight - ? `└${"─".repeat(Math.max(0, boxWidth - 2))}┤` - : anchorSide === "old" && canDockLeft - ? `├${"─".repeat(Math.max(0, boxWidth - 2))}┘` - : `└${"─".repeat(Math.max(0, boxWidth - 2))}┘`; + const savedTitleText = fitText( + ` ${titleText} `, + Math.max(0, boxWidth - 4 - closeGapWidth - closeWidth), + ); + const savedTopBorderSuffixWidth = Math.max( + 0, + boxWidth - 3 - savedTitleText.length - closeGapWidth - closeWidth, + ); + const savedTopPrefixWidth = 2 + savedTitleText.length + savedTopBorderSuffixWidth; + const bottomBorder = `╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`; - return ( - - - - {" ".repeat(boxLeft)} - - - - {topBorder} - + if (draft) { + const draftVisibleLineCount = draftVisibleRows; + const draftTitleText = fitText(` ${titleText} `, Math.max(0, boxWidth - 4)); + const draftInnerWidth = Math.max(1, boxWidth - 2); + const draftContentWidth = Math.max(1, draftInnerWidth - 2); + const saveInnerWidth = 6; + const cancelInnerWidth = 8; + const footerRemainderWidth = Math.max(0, boxWidth - saveInnerWidth - cancelInnerWidth - 4); + const draftTopBorderSuffix = `${"─".repeat(Math.max(0, boxWidth - 3 - draftTitleText.length))}╮`; + const footerButtonWidth = 1 + saveInnerWidth + 1 + cancelInnerWidth + 1; + const footerButtonLeft = boxLeft + footerRemainderWidth + 1; + const draftActionBorder = `╰${"─".repeat(footerRemainderWidth)}┬${"─".repeat(saveInnerWidth)}┬${"─".repeat(cancelInnerWidth)}┤`; + const draftButtonBottom = `╰${"─".repeat(saveInnerWidth)}┴${"─".repeat(cancelInnerWidth)}╯`; + const draftTextareaRows = draftVisibleLineCount; + const draftTopPaddingRows = 1; + const draftBottomPaddingRows = 1; + const renderDraftBodyPaddingRows = (keyPrefix: string, rowCount: number) => + Array.from({ length: rowCount }, (_, rowIndex) => ( + + + {" ".repeat(boxLeft)} + + + + │ + + + + + {" ".repeat(draftContentWidth)} + + + + + │ + + - + )); - - - {" ".repeat(boxLeft)} - - - - │ - - - - - {padText(fitText(titleText, titleWidth), titleWidth)} - + return ( + + + + {" ".repeat(boxLeft)} + + + + + ╭─ + + + {draftTitleText} + + + {draftTopBorderSuffix} + + + - {closeText ? ( + + {renderDraftBodyPaddingRows("draft-body-top-padding", draftTopPaddingRows)} + + + - {` ${closeText}`} + {Array.from({ length: draftTextareaRows }, (_, rowIndex) => ( + + │ + + ))} + + +