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.
This commit is contained in:
@@ -752,55 +752,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
[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<ChatInputProps> = ({
|
||||
<AutoReviewBanner />
|
||||
{hasDrafts ? (
|
||||
<ComposerContextChips
|
||||
terminalDrafts={terminalContextDrafts}
|
||||
reviewCount={reviewCount}
|
||||
prCommentCount={prCommentCount}
|
||||
prCheckCount={prCheckCount}
|
||||
previewAnnotationCount={previewAnnotationCount}
|
||||
chatQuoteCount={chatQuoteCount}
|
||||
draftTarget={inlineDraftTarget}
|
||||
onRemoveDraft={removeInlineCommentDraft}
|
||||
onRemoveReviewDrafts={removeReviewDrafts}
|
||||
onRemovePreviewDrafts={removePreviewDrafts}
|
||||
colors={currentTheme.colors}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1"
|
||||
style={{
|
||||
backgroundColor: props.colors?.surface?.elevated,
|
||||
borderColor: props.colors?.interactive?.border,
|
||||
}}
|
||||
>
|
||||
{props.icon}
|
||||
<span className="text-xs font-medium text-muted-foreground">{props.label}</span>
|
||||
<span className="text-xs font-semibold" style={{ color: props.colors?.status?.info }}>
|
||||
{props.count}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-interactive-hover hover:text-foreground"
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
onClick={props.onRemove}
|
||||
aria-label={props.removeLabel}
|
||||
title={props.removeLabel}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLTextAreaElement>(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 (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{terminalDrafts.map((draft) => (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1"
|
||||
title={draft.code}
|
||||
>
|
||||
<Icon name="terminal" className="h-3.5 w-3.5" />
|
||||
<span className="truncate text-xs font-medium text-[var(--surface-mutedForeground)]">
|
||||
{t('chat.chatInput.terminalContext', {
|
||||
terminal: draft.fileLabel,
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
})}
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5"
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}>
|
||||
<span className="text-xs font-medium text-[var(--surface-mutedForeground)]">{index + 1}.</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--surface-foreground)]" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
{onSaveComment ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
||||
onClick={() => draftTarget && onRemoveDraft(draftTarget, draft.id)}
|
||||
aria-label={t('chat.chatInput.terminalContextRemove')}
|
||||
title={t('chat.chatInput.terminalContextRemove')}
|
||||
className={ENTRY_ACTION_CLASS}
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
onPointerDown={keepEditorFocus}
|
||||
onClick={editing ? commitEdit : onStartEdit}
|
||||
aria-label={t('chat.chatInput.contextPreview.edit')}
|
||||
title={t('chat.chatInput.contextPreview.edit')}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
<Icon name={editing ? 'check' : 'pencil'} className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={ENTRY_ACTION_CLASS}
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
onPointerDown={keepEditorFocus}
|
||||
onClick={editing ? cancelEdit : onRemove}
|
||||
aria-label={t('chat.chatInput.contextPreview.remove')}
|
||||
title={t('chat.chatInput.contextPreview.remove')}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
{draft.code.trim() ? (
|
||||
<div>
|
||||
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.selectedLabel')}</div>
|
||||
<div
|
||||
className={
|
||||
monoSource(draft.source)
|
||||
? 'mt-0.5 whitespace-pre-wrap break-words font-mono text-xs text-[var(--surface-foreground)]'
|
||||
: 'mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]'
|
||||
}
|
||||
>
|
||||
{draft.code}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{onSaveComment && (editing || draft.text.trim()) ? (
|
||||
<div>
|
||||
<div className={ENTRY_LABEL_CLASS}>{t('chat.chatInput.contextPreview.commentLabel')}</div>
|
||||
{editing ? (
|
||||
<textarea
|
||||
ref={editRef}
|
||||
rows={2}
|
||||
value={editText}
|
||||
onChange={(event) => setEditText(event.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
commitEdit();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setEditText(draft.text);
|
||||
onEndEdit();
|
||||
}
|
||||
}}
|
||||
placeholder={t('chat.textSelection.comment.placeholder')}
|
||||
className="mt-0.5 w-full resize-none rounded-md border border-[var(--interactive-border)] bg-[var(--surface-background)] px-2 py-1 text-sm text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)]"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-0.5 whitespace-pre-wrap break-words text-sm text-[var(--surface-foreground)]">{draft.text}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function ComposerContextChips({ draftTarget, colors }: ComposerContextChipsProps) {
|
||||
const { t } = useI18n();
|
||||
const draftKey = draftTarget
|
||||
? getInlineCommentDraftKey(getRuntimeKey(), draftTarget.directory, draftTarget.sessionKey)
|
||||
: null;
|
||||
const drafts = useInlineCommentDraftStore(
|
||||
React.useCallback(
|
||||
(state) => (draftKey ? state.drafts[draftKey] ?? EMPTY_INLINE_COMMENT_DRAFTS : EMPTY_INLINE_COMMENT_DRAFTS),
|
||||
[draftKey],
|
||||
),
|
||||
);
|
||||
const removeDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
||||
const updateDraft = useInlineCommentDraftStore((state) => state.updateDraft);
|
||||
|
||||
const [openGroupKey, setOpenGroupKey] = React.useState<string | null>(null);
|
||||
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
||||
const editingRef = React.useRef<string | null>(null);
|
||||
editingRef.current = editingDraftId;
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const closeTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
const cancelClose = React.useCallback(() => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
// Hover-away close. Suspended while a comment is being edited: entering or
|
||||
// leaving edit mode reflows the panel under the pointer, and a synthetic
|
||||
// mouseleave from that reflow must not tear the editor down.
|
||||
const scheduleClose = React.useCallback(() => {
|
||||
if (editingRef.current) return;
|
||||
cancelClose();
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null;
|
||||
setOpenGroupKey(null);
|
||||
}, 150);
|
||||
}, [cancelClose]);
|
||||
React.useEffect(() => cancelClose, [cancelClose]);
|
||||
|
||||
// Clicking outside the chips + panel closes the preview even when a reflow
|
||||
// swallowed the mouseleave (e.g. right after finishing an edit).
|
||||
React.useEffect(() => {
|
||||
if (!openGroupKey) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
// SAFETY: a pointer event target inside the document is always a
|
||||
// Node; `contains` only needs that.
|
||||
if (containerRef.current?.contains(event.target as Node)) return;
|
||||
setOpenGroupKey(null);
|
||||
setEditingDraftId(null);
|
||||
};
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, [openGroupKey]);
|
||||
|
||||
const titleFor = React.useCallback((draft: InlineCommentDraft): string => {
|
||||
switch (draft.source) {
|
||||
case 'terminal':
|
||||
return t('chat.chatInput.terminalContext', {
|
||||
terminal: draft.fileLabel,
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
});
|
||||
case 'preview-annotation':
|
||||
return t('chat.message.context.browserAnnotation', { page: draft.fileLabel });
|
||||
case 'pr-comment':
|
||||
return t('chat.message.context.prComment', { label: draft.fileLabel });
|
||||
case 'pr-check':
|
||||
return t('chat.message.context.prCheck', { label: draft.fileLabel });
|
||||
case 'chat-quote':
|
||||
return t('chat.message.context.chatQuote');
|
||||
default:
|
||||
return draft.startLine === draft.endLine
|
||||
? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine })
|
||||
: t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine });
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const groups = React.useMemo<ChipGroup[]>(() => {
|
||||
const result: ChipGroup[] = [];
|
||||
const byKind = (
|
||||
key: string,
|
||||
icon: IconName,
|
||||
label: string,
|
||||
match: (draft: InlineCommentDraft) => boolean,
|
||||
iconClassName?: string,
|
||||
) => {
|
||||
const matched = drafts.filter(match);
|
||||
if (matched.length > 0) {
|
||||
result.push({ key, icon, iconClassName, label, count: matched.length, drafts: matched });
|
||||
}
|
||||
};
|
||||
for (const draft of drafts) {
|
||||
if (draft.source !== 'terminal') continue;
|
||||
result.push({
|
||||
key: `terminal-${draft.id}`,
|
||||
icon: 'terminal',
|
||||
label: t('chat.chatInput.terminalContext', {
|
||||
terminal: draft.fileLabel,
|
||||
start: draft.startLine,
|
||||
end: draft.endLine,
|
||||
}),
|
||||
count: 0,
|
||||
drafts: [draft],
|
||||
});
|
||||
}
|
||||
byKind('review', 'chat-1', t('chat.chatInput.reviewComments'), (draft) => REVIEW_SOURCES.includes(draft.source));
|
||||
byKind('pr-comment', 'git-pull-request', t('chat.chatInput.prCommentContext'), (draft) => draft.source === 'pr-comment');
|
||||
byKind('pr-check', 'close-circle', t('chat.chatInput.prCheckContext'), (draft) => draft.source === 'pr-check', 'text-[var(--status-error)]');
|
||||
byKind('chat-quote', 'chat-1', t('chat.chatInput.chatQuoteContext'), (draft) => draft.source === 'chat-quote');
|
||||
byKind('annotation', 'global', t('chat.chatInput.previewAnnotations'), (draft) => draft.source === 'preview-annotation');
|
||||
return result;
|
||||
}, [drafts, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (openGroupKey && !groups.some((group) => group.key === openGroupKey)) {
|
||||
setOpenGroupKey(null);
|
||||
setEditingDraftId(null);
|
||||
}
|
||||
}, [groups, openGroupKey]);
|
||||
|
||||
if (!draftTarget || drafts.length === 0) return null;
|
||||
|
||||
const openGroup = openGroupKey ? groups.find((group) => group.key === openGroupKey) ?? null : null;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{openGroup ? (
|
||||
<div
|
||||
className="absolute bottom-full left-0 z-30 mb-1.5 w-full max-w-[480px] overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]"
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<div className="max-h-[min(50vh,420px)] divide-y divide-[var(--interactive-border)] overflow-y-auto">
|
||||
{openGroup.drafts.map((draft, index) => (
|
||||
<DraftPreviewEntry
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
index={index}
|
||||
title={titleFor(draft)}
|
||||
editing={editingDraftId === draft.id}
|
||||
onStartEdit={() => setEditingDraftId(draft.id)}
|
||||
onEndEdit={() => setEditingDraftId((current) => (current === draft.id ? null : current))}
|
||||
onRemove={() => removeDraft(draftTarget, draft.id)}
|
||||
onSaveComment={editableSource(draft.source)
|
||||
? (text) => updateDraft(draftTarget, draft.id, { text })
|
||||
: null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{reviewCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.reviewComments')}
|
||||
count={reviewCount}
|
||||
removeLabel={t('chat.chatInput.reviewCommentsRemove')}
|
||||
onRemove={onRemoveReviewDrafts}
|
||||
colors={colors}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{prCommentCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.prCommentContext')}
|
||||
count={prCommentCount}
|
||||
removeLabel={t('chat.chatInput.prCommentContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('pr-comment')}
|
||||
colors={colors}
|
||||
icon={<Icon name="git-pull-request" className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{prCheckCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.prCheckContext')}
|
||||
count={prCheckCount}
|
||||
removeLabel={t('chat.chatInput.prCheckContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('pr-check')}
|
||||
colors={colors}
|
||||
icon={<Icon name="close-circle" className="h-3.5 w-3.5 text-[var(--status-error)]" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{chatQuoteCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.chatQuoteContext')}
|
||||
count={chatQuoteCount}
|
||||
removeLabel={t('chat.chatInput.chatQuoteContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('chat-quote')}
|
||||
colors={colors}
|
||||
icon={<Icon name="chat-1" className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewAnnotationCount > 0 ? (
|
||||
<CountChip
|
||||
label={t('chat.chatInput.previewAnnotations')}
|
||||
count={previewAnnotationCount}
|
||||
removeLabel={t('chat.chatInput.previewContextRemove')}
|
||||
onRemove={() => onRemovePreviewDrafts('preview-annotation')}
|
||||
colors={colors}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.key}
|
||||
type="button"
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border px-2.5 py-1 text-left"
|
||||
style={{
|
||||
backgroundColor: colors?.surface?.elevated,
|
||||
borderColor: colors?.interactive?.border,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
cancelClose();
|
||||
setOpenGroupKey(group.key);
|
||||
}}
|
||||
onMouseLeave={scheduleClose}
|
||||
onClick={() => {
|
||||
if (editingRef.current) return;
|
||||
setOpenGroupKey((current) => (current === group.key ? null : group.key));
|
||||
}}
|
||||
aria-expanded={openGroupKey === group.key}
|
||||
>
|
||||
<Icon name={group.icon} className={`h-3.5 w-3.5 shrink-0 text-muted-foreground ${group.iconClassName ?? ''}`} />
|
||||
<span className="truncate text-xs font-medium text-muted-foreground">{group.label}</span>
|
||||
{group.count > 0 ? (
|
||||
<span className="text-xs font-semibold" style={{ color: colors?.status?.info }}>
|
||||
{group.count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2909,6 +2909,10 @@ export const dict = {
|
||||
'chat.message.context.chatQuote': 'Zitat aus einer früheren Nachricht',
|
||||
'chat.chatInput.chatQuoteContext': 'Chat-Zitate',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Chat-Zitate entfernen',
|
||||
'chat.chatInput.contextPreview.selectedLabel': 'Ausgewählter Text',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'Nutzerkommentar',
|
||||
'chat.chatInput.contextPreview.edit': 'Kommentar bearbeiten',
|
||||
'chat.chatInput.contextPreview.remove': 'Entfernen',
|
||||
'chat.message.context.browserAnnotation': 'Browser-Anmerkung ({page})',
|
||||
'chat.message.context.prComment': 'GitHub-PR-Kommentar ({label})',
|
||||
'chat.message.context.prCheck': 'Fehlgeschlagener GitHub-PR-Check ({label})',
|
||||
|
||||
@@ -10,6 +10,10 @@ export const dict = {
|
||||
'chat.message.context.chatQuote': 'Quoted from an earlier message',
|
||||
'chat.chatInput.chatQuoteContext': 'Chat quotes',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Remove chat quotes',
|
||||
'chat.chatInput.contextPreview.selectedLabel': 'Selected text',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'User comment',
|
||||
'chat.chatInput.contextPreview.edit': 'Edit comment',
|
||||
'chat.chatInput.contextPreview.remove': 'Remove',
|
||||
'chat.message.context.browserAnnotation': 'Browser annotation ({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR comment ({label})',
|
||||
'chat.message.context.prCheck': 'Failed GitHub PR check ({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': 'Cita de un mensaje anterior',
|
||||
'chat.chatInput.chatQuoteContext': 'Citas del chat',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Quitar citas del chat',
|
||||
'chat.chatInput.contextPreview.selectedLabel': 'Texto seleccionado',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'Comentario del usuario',
|
||||
'chat.chatInput.contextPreview.edit': 'Editar comentario',
|
||||
'chat.chatInput.contextPreview.remove': 'Quitar',
|
||||
'chat.message.context.browserAnnotation': 'Anotación del navegador ({page})',
|
||||
'chat.message.context.prComment': 'Comentario de PR de GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Verificación de PR de GitHub fallida ({label})',
|
||||
|
||||
@@ -10,6 +10,10 @@ export const dict = {
|
||||
'chat.message.context.chatQuote': 'Citation d’un message précédent',
|
||||
'chat.chatInput.chatQuoteContext': 'Citations du chat',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Supprimer les citations du chat',
|
||||
'chat.chatInput.contextPreview.selectedLabel': 'Texte sélectionné',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'Commentaire de l’utilisateur',
|
||||
'chat.chatInput.contextPreview.edit': 'Modifier le commentaire',
|
||||
'chat.chatInput.contextPreview.remove': 'Supprimer',
|
||||
'chat.message.context.browserAnnotation': 'Annotation du navigateur ({page})',
|
||||
'chat.message.context.prComment': 'Commentaire de PR GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Vérification de PR GitHub échouée ({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': '以前のメッセージからの引用',
|
||||
'chat.chatInput.chatQuoteContext': 'チャット引用',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'チャット引用を削除',
|
||||
'chat.chatInput.contextPreview.selectedLabel': '選択したテキスト',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'ユーザーのコメント',
|
||||
'chat.chatInput.contextPreview.edit': 'コメントを編集',
|
||||
'chat.chatInput.contextPreview.remove': '削除',
|
||||
'chat.message.context.browserAnnotation': 'ブラウザ注釈({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR コメント({label})',
|
||||
'chat.message.context.prCheck': '失敗した GitHub PR チェック({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': '이전 메시지에서 인용',
|
||||
'chat.chatInput.chatQuoteContext': '채팅 인용',
|
||||
'chat.chatInput.chatQuoteContextRemove': '채팅 인용 제거',
|
||||
'chat.chatInput.contextPreview.selectedLabel': '선택한 텍스트',
|
||||
'chat.chatInput.contextPreview.commentLabel': '사용자 댓글',
|
||||
'chat.chatInput.contextPreview.edit': '댓글 편집',
|
||||
'chat.chatInput.contextPreview.remove': '제거',
|
||||
'chat.message.context.browserAnnotation': '브라우저 주석 ({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR 댓글 ({label})',
|
||||
'chat.message.context.prCheck': '실패한 GitHub PR 검사 ({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': 'Cytat z wcześniejszej wiadomości',
|
||||
'chat.chatInput.chatQuoteContext': 'Cytaty z czatu',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Usuń cytaty z czatu',
|
||||
'chat.chatInput.contextPreview.selectedLabel': 'Zaznaczony tekst',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'Komentarz użytkownika',
|
||||
'chat.chatInput.contextPreview.edit': 'Edytuj komentarz',
|
||||
'chat.chatInput.contextPreview.remove': 'Usuń',
|
||||
'chat.message.context.browserAnnotation': 'Adnotacja przeglądarki ({page})',
|
||||
'chat.message.context.prComment': 'Komentarz PR GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Nieudane sprawdzenie PR GitHub ({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': 'Citação de uma mensagem anterior',
|
||||
'chat.chatInput.chatQuoteContext': 'Citações do chat',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Remover citações do chat',
|
||||
'chat.chatInput.contextPreview.selectedLabel': 'Texto selecionado',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'Comentário do usuário',
|
||||
'chat.chatInput.contextPreview.edit': 'Editar comentário',
|
||||
'chat.chatInput.contextPreview.remove': 'Remover',
|
||||
'chat.message.context.browserAnnotation': 'Anotação do navegador ({page})',
|
||||
'chat.message.context.prComment': 'Comentário de PR do GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Verificação de PR do GitHub com falha ({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': 'Цитата з попереднього повідомлення',
|
||||
'chat.chatInput.chatQuoteContext': 'Цитати з чату',
|
||||
'chat.chatInput.chatQuoteContextRemove': 'Прибрати цитати з чату',
|
||||
'chat.chatInput.contextPreview.selectedLabel': 'Виділений текст',
|
||||
'chat.chatInput.contextPreview.commentLabel': 'Коментар користувача',
|
||||
'chat.chatInput.contextPreview.edit': 'Редагувати коментар',
|
||||
'chat.chatInput.contextPreview.remove': 'Прибрати',
|
||||
'chat.message.context.browserAnnotation': 'Анотація браузера ({page})',
|
||||
'chat.message.context.prComment': 'Коментар PR GitHub ({label})',
|
||||
'chat.message.context.prCheck': 'Невдала перевірка PR GitHub ({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': '引用自先前的消息',
|
||||
'chat.chatInput.chatQuoteContext': '聊天引用',
|
||||
'chat.chatInput.chatQuoteContextRemove': '移除聊天引用',
|
||||
'chat.chatInput.contextPreview.selectedLabel': '所选文本',
|
||||
'chat.chatInput.contextPreview.commentLabel': '用户评论',
|
||||
'chat.chatInput.contextPreview.edit': '编辑评论',
|
||||
'chat.chatInput.contextPreview.remove': '移除',
|
||||
'chat.message.context.browserAnnotation': '浏览器标注({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR 评论({label})',
|
||||
'chat.message.context.prCheck': '失败的 GitHub PR 检查({label})',
|
||||
|
||||
@@ -11,6 +11,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.message.context.chatQuote': '引用自先前的訊息',
|
||||
'chat.chatInput.chatQuoteContext': '聊天引用',
|
||||
'chat.chatInput.chatQuoteContextRemove': '移除聊天引用',
|
||||
'chat.chatInput.contextPreview.selectedLabel': '所選文字',
|
||||
'chat.chatInput.contextPreview.commentLabel': '使用者留言',
|
||||
'chat.chatInput.contextPreview.edit': '編輯留言',
|
||||
'chat.chatInput.contextPreview.remove': '移除',
|
||||
'chat.message.context.browserAnnotation': '瀏覽器標註({page})',
|
||||
'chat.message.context.prComment': 'GitHub PR 留言({label})',
|
||||
'chat.message.context.prCheck': '失敗的 GitHub PR 檢查({label})',
|
||||
|
||||
Reference in New Issue
Block a user