feat: refine codex live chat context flows

This commit is contained in:
2026-05-08 21:15:51 +09:00
parent 82c0d8a197
commit 442879313f
92 changed files with 14815 additions and 7314 deletions

View File

@@ -20,7 +20,6 @@ import {
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
startTransition,
useEffect,
useMemo,
useRef,
@@ -45,7 +44,9 @@ import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
import { ChatActivityChecklist } from './ChatActivityChecklist';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { buildPromptResponseText, ChatPromptCard, type PromptDraftSelection } from './ChatPromptCard';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
import { extractChatMessageParts } from './messageParts';
@@ -109,6 +110,11 @@ type PendingComposerUpload = {
reason?: string;
};
type PendingPromptSelection = PromptDraftSelection & {
promptTitle: string;
target: Extract<ChatMessagePart, { type: 'prompt' }>;
};
type PreviewFetchError = Error & {
status?: number;
};
@@ -128,6 +134,7 @@ type MessageRenderPayload = {
diffBlocks: string[];
rankedLinkTargets: RankedLinkPreviewTarget[];
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
};
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
@@ -172,6 +179,11 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
return 'file';
}
function isHtmlPreviewUrl(url: string) {
const pathname = url.toLowerCase().split('?')[0] ?? '';
return pathname.endsWith('.html') || pathname.endsWith('.htm');
}
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
if (typeof document === 'undefined') {
return;
@@ -453,20 +465,23 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
}
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: InlinePreviewTarget[] = [];
for (const matchedUrl of matches) {
const pushTarget = (matchedUrl: string, options?: { allowHtml?: boolean }) => {
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
const kind = classifyInlinePreviewKind(normalizedUrl);
if (kind === 'file') {
continue;
return;
}
// Plain HTML artifact paths should stay as text unless the reply explicitly opts into preview rendering.
if (!options?.allowHtml && isHtmlPreviewUrl(normalizedUrl)) {
return;
}
if (seen.has(normalizedUrl)) {
continue;
return;
}
seen.add(normalizedUrl);
@@ -475,7 +490,14 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
label: buildInlinePreviewLabel(normalizedUrl),
kind,
});
}
};
extractAutoDetectedPreviewUrls(text).forEach((matchedUrl) => {
pushTarget(matchedUrl);
});
extractHiddenPreviewUrls(text).forEach((matchedUrl) => {
pushTarget(matchedUrl, { allowHtml: true });
});
return targets;
}
@@ -558,6 +580,18 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
const promptTargets = [
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
].filter(
(part, index, collection) =>
collection.findIndex(
(candidate) =>
candidate.title === part.title &&
candidate.options.map((option) => `${option.value}:${option.label}`).join(',') ===
part.options.map((option) => `${option.value}:${option.label}`).join(','),
) === index,
);
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
@@ -572,6 +606,7 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload
diffBlocks,
rankedLinkTargets,
linkCardTargets,
promptTargets,
};
}
@@ -977,6 +1012,7 @@ type ChatConversationViewProps = {
showScrollToBottom: boolean;
copiedMessageId: number | null;
draft: string;
draftVersion: number;
composerAttachments: ChatComposerAttachment[];
requestStateMap: Map<string, ChatConversationRequest>;
isConversationLoading: boolean;
@@ -1015,6 +1051,7 @@ type ChatConversationViewProps = {
onCancelMessage: (message: ChatMessage) => void;
onDeleteRequest: (message: ChatMessage) => void;
onRemoveQueuedRequest: (requestId: string) => void;
onSubmitPrompt: (payload: { text: string; mode: 'queue' | 'direct' }) => Promise<boolean>;
};
export function ChatConversationView({
@@ -1026,6 +1063,7 @@ export function ChatConversationView({
showScrollToBottom,
copiedMessageId,
draft,
draftVersion,
composerAttachments,
requestStateMap,
isConversationLoading,
@@ -1064,7 +1102,10 @@ export function ChatConversationView({
onCancelMessage,
onDeleteRequest,
onRemoveQueuedRequest,
onSubmitPrompt,
}: ChatConversationViewProps) {
const [composerDraft, setComposerDraft] = useState(draft);
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingPromptSelection>>({});
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
@@ -1073,37 +1114,41 @@ export function ChatConversationView({
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
const [composerDraft, setComposerDraft] = useState(draft);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
const lastReportedDraftRef = useRef(draft);
useEffect(() => {
if (draft === lastReportedDraftRef.current) {
return;
}
setComposerDraft(draft);
}, [draft]);
}, [draft, draftVersion]);
useEffect(() => {
if (composerDraft === lastReportedDraftRef.current) {
return;
const pendingPromptSelectionEntries = useMemo(
() => Object.entries(pendingPromptSelections).sort(([left], [right]) => left.localeCompare(right)),
[pendingPromptSelections],
);
const pendingPromptSelectionCount = pendingPromptSelectionEntries.length;
const buildComposerOutboundText = (draftText: string) => {
const trimmedDraftText = draftText.trim();
if (pendingPromptSelectionEntries.length === 0) {
return draftText;
}
const timeoutId = window.setTimeout(() => {
lastReportedDraftRef.current = composerDraft;
startTransition(() => {
onDraftChange(composerDraft);
});
}, 120);
const promptTexts = pendingPromptSelectionEntries.map(([, selection], index) => {
const mergedFreeText = [selection.freeText.trim(), index === pendingPromptSelectionEntries.length - 1 ? trimmedDraftText : '']
.filter(Boolean)
.join('\n\n');
return () => {
window.clearTimeout(timeoutId);
};
}, [composerDraft, onDraftChange]);
return buildPromptResponseText(selection.target, {
...selection,
freeText: mergedFreeText,
});
});
return promptTexts.join('\n\n');
};
const orderedMessages = useMemo(() => {
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
@@ -1178,6 +1223,39 @@ export function ChatConversationView({
return [...ordered, ...orphanActivityMessages];
}, [requestStateMap, visibleMessages]);
const lastNonSystemMessageId = useMemo(() => {
for (let index = orderedMessages.length - 1; index >= 0; index -= 1) {
const message = orderedMessages[index];
if (message.author !== 'system') {
return message.id;
}
}
return null;
}, [orderedMessages]);
useEffect(() => {
const activePromptKeys = new Set<string>();
orderedMessages.forEach((message) => {
const { promptTargets } = extractMessageRenderPayload(message);
promptTargets.forEach((target, index) => {
activePromptKeys.add(`${message.id}:${index}:${target.title}`);
});
});
setPendingPromptSelections((current) => {
const nextEntries = Object.entries(current).filter(([key]) => activePromptKeys.has(key));
if (nextEntries.length === Object.keys(current).length) {
return current;
}
return Object.fromEntries(nextEntries);
});
}, [orderedMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
const isChatTypeReadonly = isChatTypeSelectionLocked;
const visiblePreviewItems = useMemo(() => {
@@ -1546,8 +1624,12 @@ export function ChatConversationView({
const composerPlaceholder = isComposerDisabled
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
: isMobileViewport
? '메시지를 입력하세요.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
? pendingPromptSelectionCount > 0
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다.'
: '메시지를 입력하세요.'
: pendingPromptSelectionCount > 0
? '메시지를 입력하면 선택한 prompt와 함께 전송됩니다. Ctrl+Enter로 바로 전송할 수 있습니다.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
const renderActivityCard = (message: ChatMessage) => {
const requestId = message.clientRequestId?.trim() || String(message.id);
@@ -1555,6 +1637,7 @@ export function ChatConversationView({
const lines = extractActivityLines(message);
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
const activityCountLabel = `${lines.length}개 로그`;
const request = requestStateMap.get(requestId);
return (
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
@@ -1601,9 +1684,12 @@ export function ChatConversationView({
</Button>
</div>
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
<span className="app-chat-activity-card__summary-label"> </span>
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
<div className="app-chat-activity-card__summary-grid">
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
<span className="app-chat-activity-card__summary-label"> </span>
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
</div>
<ChatActivityChecklist lines={lines} request={request} chatTypeId={selectedChatTypeId} />
</div>
</div>
{isExpanded ? (
@@ -1748,7 +1834,8 @@ export function ChatConversationView({
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
}`;
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets, promptTargets } =
extractMessageRenderPayload(message);
const renderedText = isRecoveredMissingRequest
? getMissingRequestMessageText(message)
: isRecoveredExecutionFailure
@@ -1761,9 +1848,14 @@ export function ChatConversationView({
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards =
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
diffBlocks.length > 0 ||
inlinePreviewTargets.length > 0 ||
rankedLinkTargets.length > 0 ||
linkCardTargets.length > 0 ||
promptTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const isPromptReadOnly = message.id !== lastNonSystemMessageId;
const stackClassName = [
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
@@ -1909,6 +2001,42 @@ export function ChatConversationView({
{linkCardTargets.map((target) => (
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
{promptTargets.map((target, index) => (
(() => {
const selectionKey = `${message.id}:${index}:${target.title}`;
return (
<ChatPromptCard
key={`${message.id}-prompt-${index}-${target.title}`}
target={target}
onSubmit={onSubmitPrompt}
readOnly={isPromptReadOnly}
onSelectionChange={(selection) => {
setPendingPromptSelections((current) => {
if (!selection) {
if (!(selectionKey in current)) {
return current;
}
const next = { ...current };
delete next[selectionKey];
return next;
}
return {
...current,
[selectionKey]: {
...selection,
promptTitle: target.title,
target,
},
};
});
}}
/>
);
})()
))}
{rankedLinkTargets.map((target) => (
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
@@ -2064,9 +2192,7 @@ export function ChatConversationView({
icon={<ThunderboltOutlined />}
aria-label="즉시 요청"
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSendImmediate(composerDraft);
onSendImmediate(buildComposerOutboundText(composerDraft));
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
@@ -2075,9 +2201,7 @@ export function ChatConversationView({
icon={<SendOutlined />}
aria-label="큐로 보내기"
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSend(composerDraft);
onSend(buildComposerOutboundText(composerDraft));
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
@@ -2096,6 +2220,27 @@ export function ChatConversationView({
{composerAttachmentStrip}
{pendingPromptSelectionCount > 0 ? (
<div className="app-chat-panel__composer-prompt-strip" aria-live="polite">
{pendingPromptSelectionEntries.map(([selectionKey, selection]) => {
const selectionLabel = selection.target.options
.filter((option) => selection.selectedValues.includes(option.value))
.map((option) => option.label)
.join(', ');
return (
<div key={selectionKey} className="app-chat-panel__composer-prompt-chip">
<span className="app-chat-panel__composer-prompt-chip-title">{selection.promptTitle}</span>
<span className="app-chat-panel__composer-prompt-chip-value">
{selection.summaryText || selectionLabel || selection.selectedValues.join(', ')}
</span>
<span className="app-chat-panel__composer-prompt-chip-meta"> </span>
</div>
);
})}
</div>
) : null}
<div
className={`app-chat-panel__composer-input-shell${
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
@@ -2138,7 +2283,9 @@ export function ChatConversationView({
placeholder={composerPlaceholder}
disabled={isComposerDisabled}
onChange={(event) => {
setComposerDraft(event.target.value);
const nextValue = event.target.value;
setComposerDraft(nextValue);
onDraftChange(nextValue);
}}
onPaste={handleComposerPaste}
onKeyDown={(event) => {
@@ -2158,9 +2305,7 @@ export function ChatConversationView({
event.preventDefault();
event.stopPropagation();
lastReportedDraftRef.current = event.currentTarget.value;
onDraftChange(event.currentTarget.value);
onSend(event.currentTarget.value);
onSend(buildComposerOutboundText(composerDraft));
}}
/>
<Button