feat: refine codex live chat context flows
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user