From 538b532fb11b470d6343118fa6c67bfd39b98a0c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 00:16:26 +0300 Subject: [PATCH] feat(composer): context chip previews with inline comment editing Hovering (or tapping) a context chip opens a stacked preview of its pending items above the composer: numbered entries with a muted header band, the captured selection, and the user's comment, which can be edited in place (save/cancel) or removed before sending. The chips component now subscribes to the draft store itself; the per-kind count plumbing in ChatInput is gone. --- packages/ui/src/components/chat/ChatInput.tsx | 56 -- .../chat/composer/ui/ComposerContextChips.tsx | 478 +++++++++++++----- packages/ui/src/lib/i18n/messages/de.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 + 13 files changed, 388 insertions(+), 190 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 1a5a72f4..f4d3b0a2 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -752,55 +752,8 @@ const ChatInputComponent: React.FC = ({ [inlineDraftKey] ) ); - const draftSourceKey = useInlineCommentDraftStore( - React.useCallback( - (state) => { - const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : []; - let previewAnnotation = 0; - let review = 0; - let terminal = 0; - let prComment = 0; - let prCheck = 0; - let chatQuote = 0; - for (const draft of drafts) { - if (draft.source === 'preview-annotation') previewAnnotation += 1; - else if (draft.source === 'terminal') terminal += 1; - else if (draft.source === 'pr-comment') prComment += 1; - else if (draft.source === 'pr-check') prCheck += 1; - else if (draft.source === 'chat-quote') chatQuote += 1; - else review += 1; - } - return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`; - }, - [inlineDraftKey] - ) - ); const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); - const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; - const [previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount, chatQuoteCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); - const terminalContextDrafts = terminalContextCount > 0 - ? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal') - : []; - const removePreviewDrafts = React.useCallback((source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => { - if (!inlineDraftTarget) return; - const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); - for (const draft of drafts) { - if (draft.source === source) { - removeInlineCommentDraft(inlineDraftTarget, draft.id); - } - } - }, [inlineDraftTarget, removeInlineCommentDraft]); - // Review comments are the inline-comment drafts that aren't preview sources. - const removeReviewDrafts = React.useCallback(() => { - if (!inlineDraftTarget) return; - const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); - for (const draft of drafts) { - if (draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check' && draft.source !== 'chat-quote') { - removeInlineCommentDraft(inlineDraftTarget, draft.id); - } - } - }, [inlineDraftTarget, removeInlineCommentDraft]); // User message history for up/down arrow navigation. // Keep this on a narrow hook instead of full session message records. @@ -2661,16 +2614,7 @@ const ChatInputComponent: React.FC = ({ {hasDrafts ? ( ) : null} diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx index 32373b3f..cdb19baa 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx @@ -2,165 +2,375 @@ * Context chips above the composer. * * Each chip stands for context that will be attached to the next message but - * is not part of its text: review comments left in a diff, captured dev-server - * logs, preview annotations, terminal selections. They are shown so the user - * knows what is riding along and can drop any of it before sending. + * is not part of its text: review comments left in a diff, preview + * annotations, terminal selections, PR context, chat quotes. Hovering (or + * tapping) a chip opens a stacked preview of its items above the composer, + * where a comment the user wrote can be edited in place and any item removed + * before sending. */ import React from 'react'; import { Icon } from '@/components/icon/Icon'; +import type { IconName } from '@/components/icon/icons'; import { useI18n } from '@/lib/i18n'; -import type { InlineCommentDraft, InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { + EMPTY_INLINE_COMMENT_DRAFTS, + getInlineCommentDraftKey, + useInlineCommentDraftStore, + type InlineCommentDraft, + type InlineCommentDraftTarget, + type InlineCommentSource, +} from '@/stores/useInlineCommentDraftStore'; import type { Theme } from '@/types/theme'; export interface ComposerContextChipsProps { - /** Terminal selections, which show their own label and line range. */ - terminalDrafts: readonly InlineCommentDraft[]; - reviewCount: number; - prCommentCount: number; - prCheckCount: number; - previewAnnotationCount: number; - chatQuoteCount: number; draftTarget: InlineCommentDraftTarget | null; - onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void; - onRemoveReviewDrafts: () => void; - onRemovePreviewDrafts: (source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => void; colors: Theme['colors']; } -/** A chip showing how many items of one kind are attached, with a clear action. */ -function CountChip(props: { +/** Chip groups: every terminal selection is its own chip; the rest group by kind. */ +type ChipGroup = { + key: string; + icon: IconName; + iconClassName?: string; label: string; count: number; - removeLabel: string; + drafts: InlineCommentDraft[]; +}; + +const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan']; + +/** Sources whose drafts carry a user-written comment that can be edited. */ +const editableSource = (source: InlineCommentSource): boolean => source !== 'terminal'; + +/** Captured code/output kinds read better monospaced; quoted prose does not. */ +const monoSource = (source: InlineCommentSource): boolean => + source !== 'chat-quote' && source !== 'preview-annotation'; + +const basename = (path: string): string => { + const segments = path.split('/').filter(Boolean); + return segments[segments.length - 1] ?? path; +}; + +const ENTRY_ACTION_CLASS = 'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'; +const ENTRY_LABEL_CLASS = 'text-[10px] font-medium uppercase tracking-wide text-[var(--surface-mutedForeground)] opacity-60'; + +const DraftPreviewEntry: React.FC<{ + draft: InlineCommentDraft; + index: number; + title: string; + editing: boolean; + onStartEdit: () => void; + onEndEdit: () => void; onRemove: () => void; - colors: Theme['colors']; - icon?: React.ReactNode; -}) { - return ( -
- {props.icon} - {props.label} - - {props.count} - - -
- ); -} - -export function ComposerContextChips(props: ComposerContextChipsProps) { + onSaveComment: ((text: string) => void) | null; +}> = ({ draft, index, title, editing, onStartEdit, onEndEdit, onRemove, onSaveComment }) => { const { t } = useI18n(); - const { - terminalDrafts, - reviewCount, - prCommentCount, - prCheckCount, - previewAnnotationCount, - chatQuoteCount, - draftTarget, - onRemoveDraft, - onRemoveReviewDrafts, - onRemovePreviewDrafts, - colors, - } = props; + const [editText, setEditText] = React.useState(draft.text); + const editRef = React.useRef(null); + + React.useEffect(() => { + if (!editing) return; + setEditText(draft.text); + queueMicrotask(() => { + const element = editRef.current; + if (element) { + element.focus(); + element.setSelectionRange(element.value.length, element.value.length); + } + }); + // The draft text at edit start is the baseline; later store updates are + // our own saves. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editing]); + + const commitEdit = () => { + if (onSaveComment && editText !== draft.text) { + onSaveComment(editText); + } + onEndEdit(); + }; + + const cancelEdit = () => { + setEditText(draft.text); + onEndEdit(); + }; + + // Keep focus in the textarea while a header button is pressed: without + // this the textarea's blur commits first, the header re-renders under the + // pointer, and the click lands on the button that replaced the pressed one + // (save punches through to edit, cancel to remove). + const keepEditorFocus = (event: React.PointerEvent) => { + if (editing) event.preventDefault(); + }; return ( -
- {terminalDrafts.map((draft) => ( -
- - - {t('chat.chatInput.terminalContext', { - terminal: draft.fileLabel, - start: draft.startLine, - end: draft.endLine, - })} - +
+
+ {index + 1}. + + {title} + + {onSaveComment ? ( + ) : null} + +
+
+ {draft.code.trim() ? ( +
+
{t('chat.chatInput.contextPreview.selectedLabel')}
+
+ {draft.code} +
+
+ ) : null} + {onSaveComment && (editing || draft.text.trim()) ? ( +
+
{t('chat.chatInput.contextPreview.commentLabel')}
+ {editing ? ( +