diff --git a/.agency/do.md b/.agency/do.md index e51de5a16..1e42ba9f5 100644 --- a/.agency/do.md +++ b/.agency/do.md @@ -24,6 +24,7 @@ Keep these docs in sync: - **`README.md`** (top-level) — user-facing changes, architecture prose, transport-resilience description. - **`packages/surface/README.md`** — the `@kolu/surface` framework reference. The "How Kolu uses this framework" section is a concrete inventory of every cell, collection, and stream descriptor plus the raw-oRPC procedures that stay outside the framework. Update it whenever a new descriptor lands or whenever a contract entry's classification changes (added mutation, retired stream, …). +- **`website/src/pages/index.astro`** — the kolu.dev landing page. Its hand-maintained `features` array (and the `NN features` count chip) must mirror the top-level `README.md`'s feature sections: when you add a `### NewThing` section to the README's Features region, add a corresponding tile to `features` and bump the chip count. The website is deployed independently (its own flake under `website/`), but treating the README and the landing page as one set keeps the public face from drifting behind the project's own description. ## PR evidence diff --git a/README.md b/README.md index 59fa32845..ada12bed0 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,20 @@ Detects [OpenCode](https://github.com/anomalyco/opencode) sessions and shows the - Ctrl+V pastes images into any agent that accepts paste-as-file-path (Claude Code, codex, …) — the server saves the browser's clipboard image and bracketed-pastes its path into the PTY +### Comments + +Annotate any file the agent wrote (or any file at all — code, markdown, configs) directly from the Code tab and flush the queue to your clipboard. Paste the resulting block into whichever agent terminal makes sense; no per-agent wiring, no `kolu artifact` CLI, no server-side delivery. + +- **Four ways to start** — 💬 chip in the Code-tab toolbar, Cmd/Ctrl+Shift+/ keybind, command palette entry, or right-click any selected line and pick *Add comment on `path:Lrange`*. The chrome bar also surfaces a `💬 N` count badge whenever the active worktree has queued comments, so you don't have to remember the queue is there +- **Inline composer at the line** — click a line in comment mode and a tiny composer pops up anchored next to that line (GitHub-PR style). Enter submits, Shift+Enter inserts a newline, Esc dismisses. No mouse trip to a bottom textbox; the popover comes to you +- **Tray = read-only roll-up** — bottom of the Code tab lists queued comments with click-line-ref-to-jump, ✎ to edit (re-opens the inline composer prefilled), and 🗑 to drop one. The header bar paints itself with an accent fill whenever the queue is non-empty so you don't accidentally close the tab on unsynced notes +- **File-tree decoration** — files with queued comments get a `●` badge in the Code-tab file tree (via Pierre's `renderRowDecoration`), so the queue is discoverable from the top of the tree, not just from the bottom tray +- **Pinpoint targets** — Pierre's existing line-selection (click a line, drag for a range) drives the popover's target chip; works uniformly across browse mode and the local/branch diff modes, so `.md`, `.ts`, and any other text file all accept comments without per-kind logic +- **Cross-file accumulation** — comments collect across every file you visit; the tray sorts by (path, startLine) so a paste reads as a coherent walk through the repo, not click order +- **Plain Markdown clipboard payload** — flushed text is a Markdown bullet list with a code-spanned line ref per entry: `` - `path:start-end` — note ``. Agent-native format (matches `grep -n`, ripgrep, vim, VS Code, and what `claude` / `codex` / `opencode` parse for their `Read` tool) — no `L` prefix. No envelope or wrapper either: paste it raw, or prefix your own framing (`"Apply these comments:"`, `"## Review notes"`) before pasting +- **Copy-and-clear** — "Copy to clipboard" is destructive by design (issue [#878](https://github.com/juspay/kolu/issues/878)): users want the tray empty for the next review pass once feedback has been pasted into an agent prompt +- **Per-worktree persistence** — the in-progress queue is keyed by `repoRoot` in localStorage via `makePersisted`, so an accidental reload doesn't lose work and switching between worktree terminals shows each worktree's own tray + ### Screen recording Record the Kolu tab — whole canvas or a single maximized terminal — with microphone and optional webcam PiP, straight to a local `.webm` file. Chromium-only (uses the File System Access API). diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 4e88ccb7e..0cc3f8b86 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -43,6 +43,7 @@ import MobileTileView from "./MobileTileView"; import { useRecorder } from "./recorder/useRecorder"; import WebcamOverlay from "./recorder/WebcamOverlay"; import RightPanelLayout from "./right-panel/RightPanelLayout"; +import { toggleCommentMode } from "./right-panel/useCommentMode"; import { useRightPanel } from "./right-panel/useRightPanel"; import { client } from "./wire"; import { serverProcessId, wsStatus } from "./rpc/rpc"; @@ -214,6 +215,14 @@ const App: Component = () => { handleScreenshotTerminal: () => handleScreenshotTerminal(), toggleRightPanel: rightPanel.togglePanel, toggleRecordingPause: () => useRecorder().togglePause(), + // Opening the right panel + Code tab + ensuring the tray appears even + // when comments is empty puts the user one click from the composer — + // the keybind/palette flow doesn't make sense if mode toggles on but + // the tray is hidden behind the inspector panel. + toggleCommentMode: () => { + rightPanel.openCodeBrowser(); + toggleCommentMode(); + }, }; useShortcuts(actionContext); @@ -468,6 +477,7 @@ const App: Component = () => { openPalette()} + activeRepoRoot={() => store.activeMeta()?.git?.repoRoot ?? null} workspaceSwitcher={ string | null; }> = (props) => { const rightPanel = useRightPanel(); const posture = useViewPosture(); @@ -122,6 +127,7 @@ const ChromeBar: Component<{ * area covered when the cluster overlaps the right panel pass * clicks through; each button re-enables pointer-events-auto. */}
+ { ] : []), actionPaletteCommand("toggleRightPanel", deps), + actionPaletteCommand("toggleCommentMode", deps, { + description: + "Open the Code tab and toggle annotation mode for clipboard-flush feedback", + }), ...(!deps.isMobile() ? [ actionPaletteCommand("openWorkspaceSwitcher", deps), diff --git a/packages/client/src/input/actions.ts b/packages/client/src/input/actions.ts index d23a33c5b..51ece1353 100644 --- a/packages/client/src/input/actions.ts +++ b/packages/client/src/input/actions.ts @@ -42,6 +42,7 @@ export interface ActionContext { handleScreenshotTerminal: () => void; toggleRightPanel: () => void; toggleRecordingPause: () => void; + toggleCommentMode: () => void; } interface AppActionBase { @@ -239,6 +240,14 @@ const _ACTIONS = { keybind: { key: ".", code: "Period", mod: true, shift: true }, handler: (ctx) => ctx.toggleRecordingPause(), }, + toggleCommentMode: { + label: "Toggle comment mode", + // Mod+Shift+/ — slash is the universal "?" key, and Shift+? reads + // as "query / note?" which is the right mnemonic for "annotate." + // Avoids the Mod+/ chord that ShortcutsHelp owns. + keybind: { key: "?", code: "Slash", mod: true, shift: true }, + handler: (ctx) => ctx.toggleCommentMode(), + }, } satisfies Record; export type ActionId = keyof typeof _ACTIONS; diff --git a/packages/client/src/right-panel/BrowseFileView.tsx b/packages/client/src/right-panel/BrowseFileView.tsx index 303a48afc..dc3b3b339 100644 --- a/packages/client/src/right-panel/BrowseFileView.tsx +++ b/packages/client/src/right-panel/BrowseFileView.tsx @@ -1,30 +1,22 @@ /** File content viewer for the Code tab's browse mode. Subscribes to the * server's live file-content stream so editor saves and branch checkouts - * reflect without a manual refresh. The wrapper around `@kolu/solid-pierre`'s - * `FileView` provides shiki-powered syntax highlighting; equality-gating - * the snapshot via `reconcile` (inside `useStream`'s underlying primitive) - * avoids stomping scroll position on no-op ticks. */ + * reflect without a manual refresh. The `LineSelection` controller is + * owned one level up so this view and the diff view share a single + * `CodeMenuFrame` wrap. */ -import { - FileView, - type SelectedLineRange, - Virtualizer, -} from "@kolu/solid-pierre"; +import { FileView, Virtualizer } from "@kolu/solid-pierre"; import { type Component, Match, Show, Switch } from "solid-js"; import { toast } from "solid-sonner"; import { pierreDiffsStyle } from "../ui/pierreTheme"; +import type { LineSelection } from "../ui/useLineSelection"; import { app } from "../wire"; -import CodeMenuFrame from "./CodeMenuFrame"; export type BrowseFileViewProps = { repoPath: string; filePath: string; theme: "light" | "dark"; - /** Initial line range to highlight (and scroll to). Set when the - * caller opens the file at a specific range — e.g. a terminal - * `path:line` click. Goes through the line-selection controller - * so the right-click "Copy path:N" menu reflects the highlight. */ - initialSelectedLines?: SelectedLineRange | null; + /** Line-selection controller owned by the parent `CodeMenuFrame`. */ + selection: LineSelection; }; const BrowseFileView: Component = (props) => { @@ -46,39 +38,32 @@ const BrowseFileView: Component = (props) => { {(fc) => ( <> -
+
File truncated (exceeds 1 MB)
- ` upgrades `` to Pierre's + * `VirtualizedFile` for very large files + * (#809 / #514 Phase 8). Without it, `` uses + * the vanilla `File` class — same behavior as before. */} + - {(selection) => ( - // `` upgrades `` to Pierre's - // `VirtualizedFile` for very large files - // (#809 / #514 Phase 8). Without it, `` uses - // the vanilla `File` class — same behavior as before. - - - toast.error(`File render failed: ${err.message}`) - } - class="w-full" - /> - - )} - + + toast.error(`File render failed: ${err.message}`) + } + class="w-full" + /> + )} diff --git a/packages/client/src/right-panel/CodeMenuFrame.tsx b/packages/client/src/right-panel/CodeMenuFrame.tsx index 7accc9b30..1e0a5609e 100644 --- a/packages/client/src/right-panel/CodeMenuFrame.tsx +++ b/packages/client/src/right-panel/CodeMenuFrame.tsx @@ -10,6 +10,7 @@ import type { Component, JSX } from "solid-js"; import { CodeContextMenu, type CodeContextMenuController, + type CodeContextMenuItem, } from "../ui/CodeContextMenu"; import { type LineSelection, useLineSelection } from "../ui/useLineSelection"; @@ -25,12 +26,21 @@ export type CodeMenuFrameProps = { * controller so a terminal `path:line` click drives both the * Pierre highlight AND the right-click menu's "Copy path:N" item. */ initialSelectedLines?: SelectedLineRange | null; + /** Forward selection changes to a parent (e.g. CodeTab's comments + * tray composer). Fires on every commit, including null. */ + onSelectionChange?: (range: SelectedLineRange | null) => void; + /** Extra context-menu items contributed by the caller — appended + * after the built-in "Copy path" / "Copy path:N" entries. Receives + * the current selection range (null when nothing is selected). */ + extraMenuItems?: (range: SelectedLineRange | null) => CodeContextMenuItem[]; }; export const CodeMenuFrame: Component = (props) => { let menuCtrl: CodeContextMenuController | undefined; const selection = useLineSelection(() => props.path, { initialRange: () => props.initialSelectedLines, + onChange: (range) => props.onSelectionChange?.(range), + extraItems: (range) => props.extraMenuItems?.(range) ?? [], }); return (
= (props) => { // carry interactive JSX props — the inner Pierre canvas is the // actual interactive surface; the host is layout only. ref={(el) => el.addEventListener("contextmenu", (e) => menuCtrl?.open(e))} - class="h-full w-full" + class="flex flex-col h-full w-full" > {props.children(selection)} = { local: "No local changes", @@ -76,6 +97,58 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => { const rightPanel = useRightPanel(); const [selectedPath, setSelectedPath] = createSignal(null); + // Comment state — all wiring lives in `useCommentInteraction`. One + // discriminated `intent` carries "open new composer", "edit existing", + // and "jump only" so seed-and-target derive from one source. The hook + // also owns orphan-clear effects (mode toggle, tab switch, repoRoot + // change). Bubble visibility and the file-row decoration are + // composed below from the hook's outputs. + const comments = useCommentInteraction({ + repoRoot: () => props.meta?.git?.repoRoot ?? null, + selectedPath, + setSelectedPath, + activeTabKind: () => rightPanel.activeTab().kind, + }); + // Viewer-root ref — scoped target for `querySelector` lookups so a + // future second viewer in the same DOM doesn't poach the search. + let viewerEl: HTMLDivElement | undefined; + // The OR's second arm keeps the tray visible on reload when the user has + // queued comments but never toggled mode back on. + const trayVisible = () => + commentModeEnabled() || comments.api.comments().length > 0; + + // Per-file bubble list, memoized so a no-op `comments()`/`selectedPath()` + // tick doesn't churn the `` and remount existing markers. + const fileBubbles = createMemo(() => { + const p = selectedPath(); + if (!p) return []; + return comments.api.comments().filter((c) => c.path === p); + }); + + const renderFileRowDecoration = createMemo(() => { + const set = comments.commentedPaths(); + if (set.size === 0) return undefined; + return (ctx: { row: { path: string } }) => + set.has(ctx.row.path) + ? { text: "●", title: "Has comments queued" } + : null; + }); + + // Right-click "Add comment on path:Lrange" — generic `extraMenuItems` + // contract on `CodeMenuFrame`, so neither `useLineSelection` nor the + // frame knows about comments. CodeTab supplies the comment-specific + // entry here. + const commentMenuItems = + (path: string) => (range: SelectedLineRange | null) => { + if (!range) return []; + return [ + { + label: `Add comment on ${formatLPathRef(path, range.start, range.end)}`, + onClick: () => comments.handleAddComment(range), + }, + ]; + }; + // Read `codeMode` directly rather than projecting it from `activeTab`. // CodeTab now stays mounted across the Inspector tab toggle (#818); a // projection-with-fallback (`activeTab.kind === "code" ? mode : "local"`) @@ -275,6 +348,14 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => { start: number; end: number; } | null>(() => { + // Comment-intent-driven navigation (jump / edit pencil / bubble + // edit) wins over the terminal-click flow — it's the more recent + // user intent. Scoped to path so a stale seed doesn't smear into a + // later-opened file. + const seed = comments.navSeed(); + if (seed && seed.path === selectedPath()) { + return { start: seed.start, end: seed.end }; + } const req = pendingCodeOpen(); if (!req) return null; const h = handled(); @@ -429,6 +510,18 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => { modes={modeOptions()} /> +
= (props) => { triggerMode: "both", render: renderTreeContextMenu, }} + renderRowDecoration={renderFileRowDecoration()} onError={(err) => toast.error(`File tree render failed: ${err.message}`) } @@ -483,7 +577,12 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => {
-
+
= (props) => { {(d) => ( - + {(selection) => ( // `` is the scroll container — // `` consumes its context and @@ -556,6 +660,7 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => { theme={diffTheme()} enableLineSelection onLineSelected={selection.handleSelect} + selectedLines={selection.range()} onError={(err) => toast.error( `Diff render failed: ${err.message}`, @@ -575,12 +680,21 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => { const repo = repoPath(); if (repo === null) return null; return ( - + onSelectionChange={comments.handleSelectionChange} + extraMenuItems={commentMenuItems(path)} + > + {(selection) => ( + + )} + ); })()} @@ -588,6 +702,88 @@ const CodeTab: Component<{ meta: TerminalMetadata | null }> = (props) => { )}
+ + + + viewerEl ?? null} + target={comments.composerTarget} + onSubmit={comments.handlePopoverSubmit} + onClose={comments.handlePopoverClose} + /> + {/* "+" bubble at the selected line — comment-mode discoverable + affordance. Visible only while: comment-mode is on, no + composer is already open, the Code tab is the active + right-panel tab, a file is selected with a non-null range, + and the selected line doesn't already carry a comment (in + that case the "💬" bubble from the For loop below takes + over). Encoding all guards into the `active` accessor makes + the bubble disappear reactively the instant any flips. */} + viewerEl ?? null} + active={() => { + if (!commentModeEnabled()) return false; + if (comments.composerTarget() !== null) return false; + if (rightPanel.activeTab().kind !== "code") return false; + if (!repoPath()) return false; + const r = comments.currentRange(); + const p = selectedPath(); + if (!r || !p) return false; + const existing = comments.api + .comments() + .some( + (c) => + c.path === p && c.startLine <= r.start && c.endLine >= r.end, + ); + return !existing; + }} + resolveLine={() => + viewerEl + ? deepQuerySelector(viewerEl, "[data-selected-line]") + : null + } + label="+" + title="Add comment on this line" + testid="inline-add-bubble" + onClick={comments.handleBubbleAddNew} + /> + {/* "💬" bubbles for each comment in the currently-shown file. + Visible regardless of comment mode so the user always sees + "this line has notes". Pierre virtualizes lines off-screen, + so the resolver may return null for scrolled-out comments; + the marker hides itself in that case. */} + + {(c) => ( + viewerEl ?? null} + active={() => + rightPanel.activeTab().kind === "code" && + !!repoPath() && + selectedPath() === c.path + } + resolveLine={() => + // Pierre's `data-line` attribute holds the actual file + // line number; `data-line-index` is its internal + // render-position index (0-based, skips diff context), + // which only matches line numbers by coincidence in + // browse mode and never in diff mode. Use `data-line` + // — works uniformly across browse / local / branch. + viewerEl + ? deepQuerySelector(viewerEl, `[data-line="${c.startLine}"]`) + : null + } + label="💬" + title={`Edit comment: ${c.text}`} + testid="inline-comment-bubble" + onClick={() => comments.handleBubbleEdit(c)} + /> + )} +
); diff --git a/packages/client/src/right-panel/CommentComposer.tsx b/packages/client/src/right-panel/CommentComposer.tsx new file mode 100644 index 000000000..099ebe29d --- /dev/null +++ b/packages/client/src/right-panel/CommentComposer.tsx @@ -0,0 +1,115 @@ +/** Inline composer used both by the line-anchored popover (new comment) + * and the edit affordance from the tray (existing comment). One widget, + * two modes — distinguished by whether `initialText` is set. Submit + * shortcut is Enter (Shift+Enter for newline); Cmd+Enter is reserved + * for the "New terminal" global keybind so we don't want to collide. */ + +import { type Component, createSignal, onMount } from "solid-js"; +import { formatLineRef } from "../ui/lineRef"; + +export const composerTestIds = { + root: "comment-composer", + textarea: "comment-composer-textarea", + submit: "comment-composer-submit", + cancel: "comment-composer-cancel", +} as const; + +export type CommentComposerProps = { + /** Target line range — drives the `path:Lrange` chip at the top. */ + path: string; + startLine: number; + endLine: number; + /** Text to pre-fill (edit mode). Empty string for the new-comment + * case. */ + initialText: string; + /** Submit handler — receives the trimmed text. The composer does not + * reset on submit; the parent unmounts (or swaps `initialText`) for + * the next operation. */ + onSubmit: (text: string) => void; + /** Cancel handler — fires on Esc, blur-without-text in new mode, or + * the explicit cancel button. */ + onCancel: () => void; + /** Optional ref handle for the parent to focus the textarea (e.g. + * when re-mounted at a new range). */ + ref?: (api: { focus: () => void }) => void; +}; + +const CommentComposer: Component = (props) => { + const [draft, setDraft] = createSignal(props.initialText); + let textarea: HTMLTextAreaElement | undefined; + + // Expose focus() so the parent can refocus when re-anchoring without + // remounting (e.g. user re-selects another line while popover open). + onMount(() => { + props.ref?.({ focus: () => textarea?.focus() }); + textarea?.focus(); + // Place caret at the end so edit-mode prefilled text is immediately + // appendable, not selected-and-overwritable. + const len = textarea?.value.length ?? 0; + textarea?.setSelectionRange(len, len); + }); + + const submit = () => { + const text = draft().trim(); + if (text.length === 0) { + props.onCancel(); + return; + } + props.onSubmit(text); + }; + + return ( +
+
+ {formatLineRef(props.path, props.startLine, props.endLine)} +
+