From 914245d14128fc8d40a192348ff59bf9b535395e Mon Sep 17 00:00:00 2001 From: Destin Date: Thu, 24 Sep 2026 01:17:25 -0700 Subject: [PATCH 001/121] =?UTF-8?q?mockup(a):=20Margin=20=E2=80=94=20doc?= =?UTF-8?q?=20comments=20+=20ask-about=20(UI=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google Docs/Word-style comments: commented spans get a soft accent highlight, a margin rail holds cards aligned to their highlights (or, below 640px of the viewer's OWN width, small markers that open a popover), a review bar shows the count and sends open comments to the assistant as a batch. Comments are durable records — author, timestamp, reply threads (including assistant replies), resolve/reopen by either side, "Show resolved" reveals a faded history. "Ask about this" is redesigned onto the same primitive: it attaches a quote reference chip above the composer instead of scaffold text in the textarea, and the same chip renders on the sent bubble. Comment state and the composer's held references live in renderer memory only (a shared useSyncExternalStore store + two window CustomEvents) — no IPC, no persistence, no main-process change. Co-Authored-By: Claude Opus 5.5 (1M context) --- desktop/src/renderer/components/InputBar.tsx | 115 +++++-- .../src/renderer/components/UserMessage.tsx | 27 +- .../artifact-views/ActiveArtifactView.tsx | 77 +++-- .../artifact-views/MarkdownView.tsx | 50 ++- .../artifact-views/RendererRegistry.ts | 8 + .../components/comments/CodeCommentsRail.tsx | 43 +++ .../components/comments/CommentCard.tsx | 144 +++++++++ .../components/comments/CommentsMargin.tsx | 253 +++++++++++++++ .../components/comments/CommentsReviewBar.tsx | 65 ++++ .../comments/QuoteReferenceChip.tsx | 50 +++ .../context-menu/build-menu-cm6.test.tsx | 18 +- .../context-menu/build-menu.test.tsx | 78 ++--- .../components/context-menu/build-menu.ts | 88 ++++-- .../components/context-menu/menu-icons.tsx | 5 + .../dev/workbench/fixtures/artifacts.ts | 69 ++++ .../renderer/hooks/use-container-narrow.ts | 36 +++ desktop/src/renderer/state/chat-reducer.ts | 3 + desktop/src/renderer/state/chat-types.ts | 5 +- .../src/renderer/state/doc-comments-store.ts | 294 ++++++++++++++++++ desktop/src/shared/chat-references.ts | 8 + desktop/src/shared/types.ts | 12 + 21 files changed, 1311 insertions(+), 137 deletions(-) create mode 100644 desktop/src/renderer/components/comments/CodeCommentsRail.tsx create mode 100644 desktop/src/renderer/components/comments/CommentCard.tsx create mode 100644 desktop/src/renderer/components/comments/CommentsMargin.tsx create mode 100644 desktop/src/renderer/components/comments/CommentsReviewBar.tsx create mode 100644 desktop/src/renderer/components/comments/QuoteReferenceChip.tsx create mode 100644 desktop/src/renderer/hooks/use-container-narrow.ts create mode 100644 desktop/src/renderer/state/doc-comments-store.ts create mode 100644 desktop/src/shared/chat-references.ts diff --git a/desktop/src/renderer/components/InputBar.tsx b/desktop/src/renderer/components/InputBar.tsx index 00a598b61..afe8b7cfe 100644 --- a/desktop/src/renderer/components/InputBar.tsx +++ b/desktop/src/renderer/components/InputBar.tsx @@ -4,6 +4,7 @@ import QuickChips, { QuickChip } from './QuickChips'; import TerminalToolbar from './TerminalToolbar'; import { Button } from './ui'; import { AttachmentChip } from './AttachmentChip'; +import { QuoteReferenceChip } from './comments/QuoteReferenceChip'; import { AttachIcon, CompassIcon } from './Icons'; import { VoiceButton, VoiceMeter, VoiceStyleContext } from './VoiceButton'; import { StatusStrip } from './ui/StatusStrip'; @@ -20,6 +21,7 @@ import { runNativeSlashAction, routeSlashResult } from '../state/native-slash-ac import type { UsageSnapshot } from '../state/chat-types'; import { hasPendingInteraction, pendingInteractionKind, pendingInteractionRefusalCopy } from '../state/pty-input-gate'; import { buildOutgoingMessage } from './outgoing-message'; +import { referenceToken } from '../../shared/chat-references'; import { sendChatMessage } from './native-send'; import type { NativeSendResult } from '../../shared/types'; import type { ClaudeAlias } from '../../shared/model-ids'; @@ -109,6 +111,19 @@ interface Attachment { isImage: boolean; } +// Doc comments (mockup, Style A "Margin"): a reference attached to the +// composer — "Ask about this" produces one, the review bar's "Send to +// assistant" produces several at once. Same family as Attachment, but there +// is no file on disk to preview — QuoteReferenceChip shows the quote itself. +interface QuoteRef { + id: string; + quote: string; + sourceLabel: string; + /** A held comment's own note — shown on the chip when a batch send + * carries it (an "Ask about this" reference never has one). */ + note?: string; +} + const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']; function isImagePath(p: string): boolean { @@ -164,6 +179,7 @@ function sendFailureCopy(result: NativeSendResult | undefined): string { const InputBar = forwardRef(function InputBar({ sessionId, disabled, sendBlocked, minimal, compact, view, onOpenDrawer, onCloseDrawer, onDrawerSearch, onResumeCommand, getUsageSnapshot, onOpenPreferences, onToast, onSendBlocked, getSessionState, onOpenModelPicker, onModelSwitchCommand, initialInput, initialAttachments, provider }, ref) { const [text, setText] = useState(''); const [attachments, setAttachments] = useState([]); + const [references, setReferences] = useState([]); // Voice prompting (deck 2026-09-05). The draft stays the one source of truth: // `text` holds what was typed plus the words the engine has SETTLED on; @@ -561,35 +577,58 @@ const InputBar = forwardRef(function InputBar({ sessionId return () => window.removeEventListener('youcoded:composer-paste-image', listener); }, [addFiles]); - // External "insert into composer" entry point — the chat right-click menu's - // "Ask about this" action dispatches this window CustomEvent with a pre-built - // quote + follow-up scaffold. Mirrors buddy:attach-file so no prop threading - // is needed. We PREPEND the scaffold and drop the caret right after it, so any - // draft the user was already typing survives as the follow-up text. + // External "attach a reference" entry point — the right-click menu's "Ask + // about this" (on a file selection or a chat message) dispatches this + // window CustomEvent with a quote + compact source label. Mirrors + // buddy:attach-file so no prop threading is needed. Redesigned from the + // old compose-insert (which prepended scaffold TEXT into the box, visible + // and editable): the quote now shows as a QuoteReferenceChip above the + // composer — the box itself stays empty and focused for the user's own + // words, never holding text they didn't type. useEffect(() => { const listener = (e: Event) => { - const insert = (e as CustomEvent<{ text?: string }>).detail?.text; - if (!insert) return; - setText((prev) => insert + prev); - requestAnimationFrame(() => { - const el = inputRef.current; - if (!el) return; - el.focus(); - el.setSelectionRange(insert.length, insert.length); - }); + const detail = (e as CustomEvent<{ quote?: string; sourceLabel?: string }>).detail; + if (!detail?.quote) return; + setReferences((prev) => [...prev, { id: `ref-${Date.now()}-${prev.length}`, quote: detail.quote!, sourceLabel: detail.sourceLabel ?? '' }]); + requestAnimationFrame(() => inputRef.current?.focus()); }; - window.addEventListener('youcoded:compose-insert', listener); - return () => window.removeEventListener('youcoded:compose-insert', listener); + window.addEventListener('youcoded:compose-add-reference', listener); + return () => window.removeEventListener('youcoded:compose-add-reference', listener); + }, []); + + // The doc-comments review bar's "Send to assistant" — ONE system with + // "Ask about this" above (same QuoteRef shape, same chip), but this one + // calls the composer's normal send path itself rather than waiting for a + // click: the comments already carry their own note text, so there is + // nothing left for the user to type. + useEffect(() => { + const listener = (e: Event) => { + const detail = (e as CustomEvent<{ lead?: string; refs?: Array<{ quote: string; sourceLabel: string; note: string }> }>).detail; + if (!detail?.refs?.length) return; + const refs: QuoteRef[] = detail.refs.map((r, i) => ({ id: `batch-${Date.now()}-${i}`, quote: r.quote, sourceLabel: r.sourceLabel, note: r.note })); + setReferences(refs); + setText(detail.lead ?? ''); + // A React state update isn't visible to the DOM textarea until the next + // paint — send() deliberately reads inputRef.current.value (see its own + // WHY) to dodge stale-closure races, so it must run AFTER that paint. + requestAnimationFrame(() => sendRef.current(false)); + }; + window.addEventListener('youcoded:compose-send-comments', listener); + return () => window.removeEventListener('youcoded:compose-send-comments', listener); }, []); const removeAttachment = useCallback((path: string) => { setAttachments((prev) => prev.filter((a) => a.path !== path)); }, []); + const removeReference = useCallback((id: string) => { + setReferences((prev) => prev.filter((r) => r.id !== id)); + }, []); + // Returns true when the message was consumed (input can clear), false when // the send was refused and the draft should stay in the input bar. const sendMessage = useCallback( - (message: string, files: Attachment[] = [], force = false): boolean => { + (message: string, files: Attachment[] = [], force = false, refs: QuoteRef[] = []): boolean => { // Prompt gate: while a permission request / AskUserQuestion / plan // approval / trust prompt is pending, Claude Code's native Ink select // menu is LIVE in the PTY. Anything we write would be interpreted as @@ -673,12 +712,24 @@ const InputBar = forwardRef(function InputBar({ sessionId // produces, but the compiler can't see that through the route value. const effectiveMessage = (!dispatchResult.handled && dispatchResult.rewritten) || message; + // Doc comments/Ask-about-this references (spec: "must NOT put quoted + // text into the typing box") ride in the SAME sanitized string as the + // typed text, as compact bracket tokens — a real Claude Code session + // gets the quote's context, and UserMessage.tsx strips this exact + // prefix back out to redraw as chips, the same way it already does + // for attachment paths below. + const refTokens = refs.map((r) => referenceToken(r.sourceLabel)).join(' '); + const messageWithRefs = refTokens ? `${refTokens} ${effectiveMessage}`.trim() : effectiveMessage; + // For the bubble: just quote + source, never the whole QuoteRef (an id, + // and a batch comment's note, are composer-only bookkeeping). + const referencesForDispatch = refs.length ? refs.map((r) => ({ quote: r.quote, sourceLabel: r.sourceLabel })) : undefined; + // One sanitized source string for BOTH the optimistic bubble and the PTY // send. The transcript confirms the bubble by EXACT content match, so if // the bubble kept newlines the send stripped, a multiline message could // never be confirmed — `pending` stayed set forever and // useSubmitConfirmation fired a stray recovery \r. See outgoing-message.ts. - const outgoing = buildOutgoingMessage(effectiveMessage, files.map((f) => f.path)); + const outgoing = buildOutgoingMessage(messageWithRefs, files.map((f) => f.path)); if (!outgoing) return true; // nothing to send — treat as consumed if (disabled) return false; @@ -748,6 +799,7 @@ const InputBar = forwardRef(function InputBar({ sessionId content: outgoing.content, timestamp: Date.now(), attachments: files.map((f) => f.path), + references: referencesForDispatch, }); } })(); @@ -762,6 +814,7 @@ const InputBar = forwardRef(function InputBar({ sessionId sessionId, content: outgoing.content, timestamp: Date.now(), + references: referencesForDispatch, // Exact attachment paths so UserMessage can render each as a clickable // pill — file-picker paths routinely contain spaces, which the joined // content string can't be split back out of. @@ -841,7 +894,7 @@ const InputBar = forwardRef(function InputBar({ sessionId // anyway" override, which re-enters here past the gate (see sendMessage). // WHY guard here, not only on the button: Enter, form submit, global keys, // and the pending-prompt retry all reach this path. No override grants freshness. - if (sendBlocked || !sendMessage(currentText, attachments, force)) return; + if (sendBlocked || !sendMessage(currentText, attachments, force, references)) return; // The message has gone, so the dictation behind it goes too. Without this, // sending mid-sentence sent the unsettled GREY words along with it AND left // them in the box, and the next thing the engine said re-typed the whole @@ -854,11 +907,12 @@ const InputBar = forwardRef(function InputBar({ sessionId if (voicePhaseRef.current !== 'idle') void voiceCancelRef.current(); setText(''); setAttachments([]); + setReferences([]); draftsRef.current.delete(sessionId); // Clear stored draft after sending onCloseDrawer?.(); // Reset height after clearing if (inputRef.current) inputRef.current.style.height = 'auto'; - }, [text, attachments, sendMessage, sendBlocked, onCloseDrawer, sessionId]); + }, [text, attachments, references, sendMessage, sendBlocked, onCloseDrawer, sessionId]); // Keep sendRef pointing at the latest send so the global keydown handler // (which can't depend on send without thrashing the listener) stays current @@ -958,6 +1012,25 @@ const InputBar = forwardRef(function InputBar({ sessionId {minimal && } {!minimal && !compact && } + {references.length > 0 && ( +
+ {/* Doc comments (mockup, Style A): "Ask about this" attaches one of + these; the review bar's "Send to assistant" attaches several at + once and sends immediately (see the compose-send-comments + listener above) — this row is what a viewer sees mid-send, and + is the same card the sent bubble shows (UserMessage.tsx). */} + {references.map((ref) => ( + removeReference(ref.id)} + /> + ))} +
+ )} + {attachments.length > 0 && (
{/* Design C (Destin, 2026-08-27, ledger P-19): a 128×96 card with a @@ -1245,7 +1318,7 @@ const InputBar = forwardRef(function InputBar({ sessionId type="submit" size="icon" aria-label="Send message" - disabled={disabled || sendBlocked || (!minimal && !text.trim() && attachments.length === 0)} + disabled={disabled || sendBlocked || (!minimal && !text.trim() && attachments.length === 0 && references.length === 0)} className="shrink-0 disabled:opacity-30" > diff --git a/desktop/src/renderer/components/UserMessage.tsx b/desktop/src/renderer/components/UserMessage.tsx index c40c2b0c6..0221bc900 100644 --- a/desktop/src/renderer/components/UserMessage.tsx +++ b/desktop/src/renderer/components/UserMessage.tsx @@ -1,10 +1,12 @@ import React from 'react'; import { ChatMessage } from '../../shared/types'; +import { referenceToken } from '../../shared/chat-references'; import LinkableText from './LinkableText'; import { splitFlowingKeywords } from './FlowingKeywords'; import { formatBubbleTime } from '../utils/format-time'; import { detectFilepaths } from '../hooks/useInlineFilepathDetector'; import { FilepathToken } from './FilepathToken'; +import { QuoteReferenceChip } from './comments/QuoteReferenceChip'; interface Props { message: ChatMessage; @@ -44,6 +46,22 @@ export default React.memo(function UserMessage({ message, sessionId, showTimesta if (i < attachments.length - 1 || text.length > 0) attachmentPills.push(' '); } + // Doc comments / "Ask about this" (mockup, Style A "Margin"): same + // prefix-strip idiom as attachments above, one level further in — InputBar + // joins [...attachmentPaths, ...refTokens, typedText], so references are + // stripped SECOND. Each chip shows the quote from message.references, not + // the bracket token itself (the token only exists so a real Claude Code + // session reads the same context the chip shows). + const references = message.references ?? []; + const referenceChips: React.ReactNode[] = []; + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const token = referenceToken(ref.sourceLabel); + if (!text.startsWith(token)) break; + text = text.slice(token.length).replace(/^ /, ''); + referenceChips.push(); + } + // Detect filepaths in the (remaining) typed text and render each as a // clickable pill that opens in the artifact viewer, same as assistant // messages. Non-path spans keep the flowing-keyword + URL-link treatment. @@ -68,7 +86,14 @@ export default React.memo(function UserMessage({ message, sessionId, showTimesta body = [...attachmentPills, ...body]; return ( -
+
+ {/* Reference chips sit ABOVE the bubble, not inline in its text — the + same QuoteReferenceChip the composer showed while this was being + written (spec surface 4: sent references render as those same + cards on the user's bubble). */} + {referenceChips.length > 0 && ( +
{referenceChips}
+ )}
{body} {showTimestamps && ( diff --git a/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx b/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx index 70471d353..bbb41b691 100644 --- a/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx +++ b/desktop/src/renderer/components/artifact-views/ActiveArtifactView.tsx @@ -2,7 +2,7 @@ // Extracted from SessionDrawer.tsx (Task 7.2) so both SessionDrawer and ProjectView // can use it identically without duplicating the edit state + conflict-detection logic. import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle, Suspense } from 'react'; -import { getViewer, getEditViewer, rendersFromBytesOnly, isTextContentViewer } from './RendererRegistry'; +import { getViewer, getEditViewer, rendersFromBytesOnly, isTextContentViewer, isCodeEditorViewer } from './RendererRegistry'; import { PartialFileBanner } from './PartialFileBanner'; import { canEditArtifact } from './edit-permission'; import { ViewerErrorBoundary } from './ViewerErrorBoundary'; @@ -14,6 +14,11 @@ import { LoadingState, ErrorState } from '../ui/states'; import { RemoteFileCard } from './RemoteFileCard'; import { describeReadError } from './read-error-copy'; import { isRemoteMode } from '../../platform'; +// Doc comments (mockup, Style A "Margin"): the review bar is common to every +// text-content viewer; the code-file rail is CM6-specific (see its own WHY). +import { CommentsReviewBar } from '../comments/CommentsReviewBar'; +import { CodeCommentsRail } from '../comments/CodeCommentsRail'; +import { useNarrowByRef } from '../../hooks/use-container-narrow'; /** Absolute on-disk path of an artifact — the same join SessionDrawer and * FilesTab make for Copy path, so Download asks the host for the same file. */ @@ -532,6 +537,16 @@ export const ActiveArtifactView = forwardRef
)} -
- {/* Boundary catches lazy chunk-load failures + viewer render crashes - (Suspense alone can't — lazy() THROWS its rejection). Keyed by - artifact so switching files retries with a clean slate. */} - - Loading viewer…
}> - - - + {/* Doc comments (mockup, Style A "Margin"): the review bar is common to + every text file; comments themselves only make sense while reading, + not while a raw textarea draft is on screen mid-edit. */} + {showComments && } +
+
+ {/* Boundary catches lazy chunk-load failures + viewer render crashes + (Suspense alone can't — lazy() THROWS its rejection). Keyed by + artifact so switching files retries with a clean slate. */} + + Loading viewer…
}> + + + +
+ {/* CM6 virtualizes its DOM, so it gets the simpler non-scroll-synced + rail (CodeCommentsRail's own comment has the full WHY) rather than + MarkdownView's inline highlight-and-align margin. */} + {showCodeRail && ( + revealLineIn(rootRef.current, line)} /> + )}
{/* Partial-view notice — floats over the BOTTOM of the doc pane, in the spot the Edit pill would occupy (a file this large is read-only, so diff --git a/desktop/src/renderer/components/artifact-views/MarkdownView.tsx b/desktop/src/renderer/components/artifact-views/MarkdownView.tsx index b056d75e4..6f8048c22 100644 --- a/desktop/src/renderer/components/artifact-views/MarkdownView.tsx +++ b/desktop/src/renderer/components/artifact-views/MarkdownView.tsx @@ -1,8 +1,22 @@ // Task 6.4: MarkdownView is now a fully controlled component. // Edit state (editing, draft) is managed by ActiveArtifactView in SessionDrawer.tsx // so the conflict banner has access to the in-progress draft. +import { useRef } from 'react'; import MarkdownContent from '../MarkdownContent'; import type { ArtifactViewProps } from './types'; +// Doc comments (mockup, Style A "Margin"): the margin rail lives INSIDE this +// same overflow-auto element, as a flex sibling of the text column — that is +// what lets it scroll together with the document for free (CommentsMargin's +// own header comment has the full WHY). Skipped in edit mode: a highlighted +// span over a live textarea draft has nothing to anchor to. +import { CommentsMargin } from '../comments/CommentsMargin'; +import { useContainerNarrow } from '../../hooks/use-container-narrow'; + +// 640px, same NUMBER the app's viewport breakpoint uses, but measuring the +// PANE (see useContainerNarrow's own WHY) — SessionDrawer's fixed ~480px +// pane collapses to markers even in a wide window; ProjectView's full-width +// file tab keeps the margin. +const MARGIN_COLLAPSE_PX = 640; // NOTE: Edit/Save/Cancel live in the HOST's header (SessionDrawer toolbar / // ProjectDetailOverlay tools via the controlsInHeader handle) — this view never @@ -12,6 +26,9 @@ export function MarkdownView({ path, content, editing = false, draft = '', onDraftChange, }: ArtifactViewProps) { + const contentRef = useRef(null); + const [rootRef, narrow] = useContainerNarrow(MARGIN_COLLAPSE_PX); + if (content === null) { // Loading / missing / read-error are rendered by ActiveArtifactView (which // knows WHICH of the three it is — see ArtifactContentState). A null here @@ -41,19 +58,26 @@ export function MarkdownView({ const isMarkdown = path.endsWith('.md') || path.endsWith('.markdown'); return ( -
-
- {isMarkdown - ? - :
{content}
} +
+ {/* The scrolling ancestor is this flex row, not the text column alone — + the margin (or, narrow, its marker rail) is a SIBLING inside it, so + both move together on scroll with no listener of our own. */} +
+
+ {isMarkdown + ? + :
{content}
} +
+
); diff --git a/desktop/src/renderer/components/artifact-views/RendererRegistry.ts b/desktop/src/renderer/components/artifact-views/RendererRegistry.ts index ee6c69b42..e36d8bd04 100644 --- a/desktop/src/renderer/components/artifact-views/RendererRegistry.ts +++ b/desktop/src/renderer/components/artifact-views/RendererRegistry.ts @@ -107,6 +107,14 @@ export function isTextContentViewer(v: unknown): boolean { return TEXT_CONTENT_VIEWERS.has(v as ViewSpec); } +// WHY its own check, not exporting CodeEditorView itself: doc comments (mockup, +// Style A) give code files a simpler, non-scroll-synced comments rail than +// MarkdownView's inline margin — ActiveArtifactView needs to know "is this the +// CM6 viewer" without importing the lazy chunk directly. +export function isCodeEditorViewer(v: unknown): boolean { + return v === CodeEditorView; +} + export function getViewer(path: string, opts?: { textHint?: boolean; binaryHint?: boolean }): ViewSpec { const ext = path.split('.').pop()?.toLowerCase() ?? ''; const hit = REGISTRY[ext]; diff --git a/desktop/src/renderer/components/comments/CodeCommentsRail.tsx b/desktop/src/renderer/components/comments/CodeCommentsRail.tsx new file mode 100644 index 000000000..996986cd8 --- /dev/null +++ b/desktop/src/renderer/components/comments/CodeCommentsRail.tsx @@ -0,0 +1,43 @@ +// CodeCommentsRail — the "can be simpler" code-file version of the margin. +// CodeMirror 6 virtualizes its DOM (only viewport lines exist), so the +// text-node highlight-and-align trick CommentsMargin uses for markdown isn't +// safe here — the same reason describeArtifactSelection in build-menu.ts +// never counts lines from a CM6
. This is a plain, ungrouped list of
+// cards (no scroll-sync, no in-document highlight); each jumps CodeMirror to
+// its line on click.
+import { CommentCard } from './CommentCard';
+import { EmptyState } from '../ui/states';
+import { useDocComments } from '../../state/doc-comments-store';
+
+interface Props {
+  path: string;
+  onJumpToLine: (line: number) => void;
+}
+
+export function CodeCommentsRail({ path, onJumpToLine }: Props) {
+  const { comments, focusId, showResolved, setCommentText, addReply, resolveComment, reopenComment, removeComment } = useDocComments(path);
+  const visible = comments
+    .filter((c) => showResolved || !c.resolved)
+    .sort((a, b) => (a.startLine ?? 0) - (b.startLine ?? 0) || a.createdAt - b.createdAt);
+
+  return (
+    
+ {visible.length === 0 && ( + + )} + {visible.map((c) => ( + setCommentText(c.id, t)} + onReply={(t) => addReply(c.id, 'user', t)} + onResolve={() => resolveComment(c.id, 'user')} + onReopen={() => reopenComment(c.id)} + onDelete={() => removeComment(c.id)} + onJump={c.startLine ? () => onJumpToLine(c.startLine!) : undefined} + /> + ))} +
+ ); +} diff --git a/desktop/src/renderer/components/comments/CommentCard.tsx b/desktop/src/renderer/components/comments/CommentCard.tsx new file mode 100644 index 000000000..de5da5bfe --- /dev/null +++ b/desktop/src/renderer/components/comments/CommentCard.tsx @@ -0,0 +1,144 @@ +// CommentCard — one comment thread: author + timestamp, the anchored quote, +// the note, replies (including an assistant reply), and resolve/reopen. +// Used both in the margin (desktop) and inside a popover (narrow viewport). +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '../ui/Button'; +import { Textarea } from '../ui/Textarea'; +import { CheckIcon } from '../Icons'; +import { formatRelativeTime } from '../../utils/format-time'; +import type { CommentAuthor, DocComment } from '../../state/doc-comments-store'; + +function Avatar({ author }: { author: CommentAuthor }) { + // WHY a letter/glyph, not an accent fill: G-8 reserves the accent colour for + // STATE (selection, focus, primary actions) — a resting avatar is decoration, + // so it stays neutral like every other tag/badge in the app. + return ( + + {author === 'assistant' ? '✳' : 'D'} + + ); +} + +function authorName(author: CommentAuthor): string { + return author === 'assistant' ? 'Claude' : 'You'; +} + +interface Props { + comment: DocComment; + autoFocus?: boolean; + onTextChange: (text: string) => void; + onReply: (text: string) => void; + onResolve: () => void; + onReopen: () => void; + onDelete: () => void; + onJump?: () => void; +} + +export function CommentCard({ comment, autoFocus, onTextChange, onReply, onResolve, onReopen, onDelete, onJump }: Props) { + const [replyText, setReplyText] = useState(''); + const textRef = useRef(null); + + // A freshly added comment (from "Add comment" on a selection) opens with + // its note box already focused — Docs-style, so typing starts immediately + // with no extra click. + useEffect(() => { + if (autoFocus) textRef.current?.focus(); + }, [autoFocus]); + + const isDraft = comment.text.trim() === '' && comment.replies.length === 0 && !comment.resolved; + + if (comment.resolved) { + // Fade/collapse (Docs-style): a one-line sliver naming who resolved it, + // with the only action being to bring it back. + return ( +
+ +
+ + Resolved by {comment.resolvedBy === 'assistant' ? 'Claude' : 'you'} + +
+
+ ); + } + + return ( +
+ + +
+ +
+
+ {authorName(comment.author)} + {formatRelativeTime(comment.createdAt)} +
+ {isDraft ? ( +