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]
|
[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 consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||||
const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft);
|
|
||||||
const hasDrafts = draftCount > 0;
|
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.
|
// User message history for up/down arrow navigation.
|
||||||
// Keep this on a narrow hook instead of full session message records.
|
// Keep this on a narrow hook instead of full session message records.
|
||||||
@@ -2661,16 +2614,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
<AutoReviewBanner />
|
<AutoReviewBanner />
|
||||||
{hasDrafts ? (
|
{hasDrafts ? (
|
||||||
<ComposerContextChips
|
<ComposerContextChips
|
||||||
terminalDrafts={terminalContextDrafts}
|
|
||||||
reviewCount={reviewCount}
|
|
||||||
prCommentCount={prCommentCount}
|
|
||||||
prCheckCount={prCheckCount}
|
|
||||||
previewAnnotationCount={previewAnnotationCount}
|
|
||||||
chatQuoteCount={chatQuoteCount}
|
|
||||||
draftTarget={inlineDraftTarget}
|
draftTarget={inlineDraftTarget}
|
||||||
onRemoveDraft={removeInlineCommentDraft}
|
|
||||||
onRemoveReviewDrafts={removeReviewDrafts}
|
|
||||||
onRemovePreviewDrafts={removePreviewDrafts}
|
|
||||||
colors={currentTheme.colors}
|
colors={currentTheme.colors}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,165 +2,375 @@
|
|||||||
* Context chips above the composer.
|
* Context chips above the composer.
|
||||||
*
|
*
|
||||||
* Each chip stands for context that will be attached to the next message but
|
* 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
|
* is not part of its text: review comments left in a diff, preview
|
||||||
* logs, preview annotations, terminal selections. They are shown so the user
|
* annotations, terminal selections, PR context, chat quotes. Hovering (or
|
||||||
* knows what is riding along and can drop any of it before sending.
|
* 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 React from 'react';
|
||||||
|
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
import type { IconName } from '@/components/icon/icons';
|
||||||
import { useI18n } from '@/lib/i18n';
|
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';
|
import type { Theme } from '@/types/theme';
|
||||||
|
|
||||||
export interface ComposerContextChipsProps {
|
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;
|
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'];
|
colors: Theme['colors'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A chip showing how many items of one kind are attached, with a clear action. */
|
/** Chip groups: every terminal selection is its own chip; the rest group by kind. */
|
||||||
function CountChip(props: {
|
type ChipGroup = {
|
||||||
|
key: string;
|
||||||
|
icon: IconName;
|
||||||
|
iconClassName?: string;
|
||||||
label: string;
|
label: string;
|
||||||
count: number;
|
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;
|
onRemove: () => void;
|
||||||
colors: Theme['colors'];
|
onSaveComment: ((text: string) => void) | null;
|
||||||
icon?: React.ReactNode;
|
}> = ({ draft, index, title, editing, onStartEdit, onEndEdit, onRemove, onSaveComment }) => {
|
||||||
}) {
|
|
||||||
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) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const {
|
const [editText, setEditText] = React.useState(draft.text);
|
||||||
terminalDrafts,
|
const editRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
reviewCount,
|
|
||||||
prCommentCount,
|
React.useEffect(() => {
|
||||||
prCheckCount,
|
if (!editing) return;
|
||||||
previewAnnotationCount,
|
setEditText(draft.text);
|
||||||
chatQuoteCount,
|
queueMicrotask(() => {
|
||||||
draftTarget,
|
const element = editRef.current;
|
||||||
onRemoveDraft,
|
if (element) {
|
||||||
onRemoveReviewDrafts,
|
element.focus();
|
||||||
onRemovePreviewDrafts,
|
element.setSelectionRange(element.value.length, element.value.length);
|
||||||
colors,
|
}
|
||||||
} = props;
|
});
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
<div>
|
||||||
{terminalDrafts.map((draft) => (
|
<div className="flex items-center gap-1.5 px-3 py-1.5"
|
||||||
<div
|
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-mutedForeground) 8%, transparent)' }}>
|
||||||
key={draft.id}
|
<span className="text-xs font-medium text-[var(--surface-mutedForeground)]">{index + 1}.</span>
|
||||||
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"
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--surface-foreground)]" title={title}>
|
||||||
title={draft.code}
|
{title}
|
||||||
>
|
</span>
|
||||||
<Icon name="terminal" className="h-3.5 w-3.5" />
|
{onSaveComment ? (
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
type="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)]"
|
className={ENTRY_ACTION_CLASS}
|
||||||
onClick={() => draftTarget && onRemoveDraft(draftTarget, draft.id)}
|
style={{ minHeight: 0, minWidth: 0 }}
|
||||||
aria-label={t('chat.chatInput.terminalContextRemove')}
|
onPointerDown={keepEditorFocus}
|
||||||
title={t('chat.chatInput.terminalContextRemove')}
|
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>
|
</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>
|
</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}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2909,6 +2909,10 @@ export const dict = {
|
|||||||
'chat.message.context.chatQuote': 'Zitat aus einer früheren Nachricht',
|
'chat.message.context.chatQuote': 'Zitat aus einer früheren Nachricht',
|
||||||
'chat.chatInput.chatQuoteContext': 'Chat-Zitate',
|
'chat.chatInput.chatQuoteContext': 'Chat-Zitate',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'Chat-Zitate entfernen',
|
'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.browserAnnotation': 'Browser-Anmerkung ({page})',
|
||||||
'chat.message.context.prComment': 'GitHub-PR-Kommentar ({label})',
|
'chat.message.context.prComment': 'GitHub-PR-Kommentar ({label})',
|
||||||
'chat.message.context.prCheck': 'Fehlgeschlagener GitHub-PR-Check ({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.message.context.chatQuote': 'Quoted from an earlier message',
|
||||||
'chat.chatInput.chatQuoteContext': 'Chat quotes',
|
'chat.chatInput.chatQuoteContext': 'Chat quotes',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'Remove 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.browserAnnotation': 'Browser annotation ({page})',
|
||||||
'chat.message.context.prComment': 'GitHub PR comment ({label})',
|
'chat.message.context.prComment': 'GitHub PR comment ({label})',
|
||||||
'chat.message.context.prCheck': 'Failed GitHub PR check ({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.message.context.chatQuote': 'Cita de un mensaje anterior',
|
||||||
'chat.chatInput.chatQuoteContext': 'Citas del chat',
|
'chat.chatInput.chatQuoteContext': 'Citas del chat',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'Quitar 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.browserAnnotation': 'Anotación del navegador ({page})',
|
||||||
'chat.message.context.prComment': 'Comentario de PR de GitHub ({label})',
|
'chat.message.context.prComment': 'Comentario de PR de GitHub ({label})',
|
||||||
'chat.message.context.prCheck': 'Verificación de PR de GitHub fallida ({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.message.context.chatQuote': 'Citation d’un message précédent',
|
||||||
'chat.chatInput.chatQuoteContext': 'Citations du chat',
|
'chat.chatInput.chatQuoteContext': 'Citations du chat',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'Supprimer les 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.browserAnnotation': 'Annotation du navigateur ({page})',
|
||||||
'chat.message.context.prComment': 'Commentaire de PR GitHub ({label})',
|
'chat.message.context.prComment': 'Commentaire de PR GitHub ({label})',
|
||||||
'chat.message.context.prCheck': 'Vérification de PR GitHub échouée ({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.message.context.chatQuote': '以前のメッセージからの引用',
|
||||||
'chat.chatInput.chatQuoteContext': 'チャット引用',
|
'chat.chatInput.chatQuoteContext': 'チャット引用',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'チャット引用を削除',
|
'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.browserAnnotation': 'ブラウザ注釈({page})',
|
||||||
'chat.message.context.prComment': 'GitHub PR コメント({label})',
|
'chat.message.context.prComment': 'GitHub PR コメント({label})',
|
||||||
'chat.message.context.prCheck': '失敗した 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.message.context.chatQuote': '이전 메시지에서 인용',
|
||||||
'chat.chatInput.chatQuoteContext': '채팅 인용',
|
'chat.chatInput.chatQuoteContext': '채팅 인용',
|
||||||
'chat.chatInput.chatQuoteContextRemove': '채팅 인용 제거',
|
'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.browserAnnotation': '브라우저 주석 ({page})',
|
||||||
'chat.message.context.prComment': 'GitHub PR 댓글 ({label})',
|
'chat.message.context.prComment': 'GitHub PR 댓글 ({label})',
|
||||||
'chat.message.context.prCheck': '실패한 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.message.context.chatQuote': 'Cytat z wcześniejszej wiadomości',
|
||||||
'chat.chatInput.chatQuoteContext': 'Cytaty z czatu',
|
'chat.chatInput.chatQuoteContext': 'Cytaty z czatu',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'Usuń 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.browserAnnotation': 'Adnotacja przeglądarki ({page})',
|
||||||
'chat.message.context.prComment': 'Komentarz PR GitHub ({label})',
|
'chat.message.context.prComment': 'Komentarz PR GitHub ({label})',
|
||||||
'chat.message.context.prCheck': 'Nieudane sprawdzenie 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.message.context.chatQuote': 'Citação de uma mensagem anterior',
|
||||||
'chat.chatInput.chatQuoteContext': 'Citações do chat',
|
'chat.chatInput.chatQuoteContext': 'Citações do chat',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'Remover 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.browserAnnotation': 'Anotação do navegador ({page})',
|
||||||
'chat.message.context.prComment': 'Comentário de PR do GitHub ({label})',
|
'chat.message.context.prComment': 'Comentário de PR do GitHub ({label})',
|
||||||
'chat.message.context.prCheck': 'Verificação de PR do GitHub com falha ({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.message.context.chatQuote': 'Цитата з попереднього повідомлення',
|
||||||
'chat.chatInput.chatQuoteContext': 'Цитати з чату',
|
'chat.chatInput.chatQuoteContext': 'Цитати з чату',
|
||||||
'chat.chatInput.chatQuoteContextRemove': 'Прибрати цитати з чату',
|
'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.browserAnnotation': 'Анотація браузера ({page})',
|
||||||
'chat.message.context.prComment': 'Коментар PR GitHub ({label})',
|
'chat.message.context.prComment': 'Коментар PR GitHub ({label})',
|
||||||
'chat.message.context.prCheck': 'Невдала перевірка 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.message.context.chatQuote': '引用自先前的消息',
|
||||||
'chat.chatInput.chatQuoteContext': '聊天引用',
|
'chat.chatInput.chatQuoteContext': '聊天引用',
|
||||||
'chat.chatInput.chatQuoteContextRemove': '移除聊天引用',
|
'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.browserAnnotation': '浏览器标注({page})',
|
||||||
'chat.message.context.prComment': 'GitHub PR 评论({label})',
|
'chat.message.context.prComment': 'GitHub PR 评论({label})',
|
||||||
'chat.message.context.prCheck': '失败的 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.message.context.chatQuote': '引用自先前的訊息',
|
||||||
'chat.chatInput.chatQuoteContext': '聊天引用',
|
'chat.chatInput.chatQuoteContext': '聊天引用',
|
||||||
'chat.chatInput.chatQuoteContextRemove': '移除聊天引用',
|
'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.browserAnnotation': '瀏覽器標註({page})',
|
||||||
'chat.message.context.prComment': 'GitHub PR 留言({label})',
|
'chat.message.context.prComment': 'GitHub PR 留言({label})',
|
||||||
'chat.message.context.prCheck': '失敗的 GitHub PR 檢查({label})',
|
'chat.message.context.prCheck': '失敗的 GitHub PR 檢查({label})',
|
||||||
|
|||||||
Reference in New Issue
Block a user