feat: integrate learning module
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent as ReactClipboardEvent, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
|
||||
import { useGSAP } from '@gsap/react';
|
||||
import { gsap } from 'gsap';
|
||||
import { ArrowDown, Bot, ChevronDown, ChevronRight, CircleHelp, FileDiff, FileText, FolderOpen, Loader2, LockKeyhole, Mic, Plus, RotateCcw, Send, Settings as SettingsIcon, ShieldCheck, Square, Undo2, X } from 'lucide-react';
|
||||
@@ -43,14 +43,11 @@ import { fetchWorksTokenUsage, transcribeWorksSpeech, WorksSquareApiError } from
|
||||
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
|
||||
import {
|
||||
extractText,
|
||||
extractThinkingSegments,
|
||||
projectAssistantTextForCodex,
|
||||
stripProcessMessagePrefix,
|
||||
} from './message-utils';
|
||||
import { deriveTaskSteps, type TaskStep } from './task-visualization';
|
||||
import {
|
||||
buildSessionTranscriptTimeline,
|
||||
projectAssistantTextWithNativeParts,
|
||||
} from './session-transcript-view-model';
|
||||
import {
|
||||
formatVisibleChatTranscript,
|
||||
@@ -78,7 +75,6 @@ import type {
|
||||
OpencodePermissionRequest,
|
||||
OpencodeSession,
|
||||
OpencodeSessionDiff,
|
||||
OpencodeSessionStatus,
|
||||
OpencodeSessionTranscriptState,
|
||||
} from '@/types/opencode';
|
||||
import { invokeIpc, saveMarkdownTranscript } from '@/lib/api-client';
|
||||
@@ -406,6 +402,57 @@ function isUnauthorizedWorksError(error: unknown): boolean {
|
||||
const chatComposerButtonClassName = 'motion-press border-0 bg-transparent text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]';
|
||||
const chatComposerSendButtonClassName = 'motion-press border-0 bg-foreground text-background shadow-sm hover:bg-foreground/90 active:scale-[0.94]';
|
||||
|
||||
function AgentResponseLoader() {
|
||||
const idPrefix = useId().replace(/:/gu, '');
|
||||
const gradientId = `${idPrefix}-gradient`;
|
||||
const shadowId = `${idPrefix}-shadow`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="agent-response-loader"
|
||||
data-testid="opencode-agent-response-loader"
|
||||
focusable="false"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="14" y1="18" x2="86" y2="82" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#f700a8" />
|
||||
<stop offset="1" stopColor="#ff8000" />
|
||||
</linearGradient>
|
||||
<filter id={shadowId} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" />
|
||||
</filter>
|
||||
</defs>
|
||||
<circle
|
||||
className="agent-response-loader__ring agent-response-loader__ring--shadow"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="35"
|
||||
filter={`url(#${shadowId})`}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeDasharray="180 800"
|
||||
/>
|
||||
<circle
|
||||
className="agent-response-loader__ring agent-response-loader__ring--slow"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="35"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeDasharray="180 800"
|
||||
/>
|
||||
<circle
|
||||
className="agent-response-loader__ring agent-response-loader__ring--fast"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="35"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeDasharray="26 54"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function hasDraggedFiles(event: DragEvent<HTMLElement>): boolean {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
return types.includes('Files') || event.dataTransfer.files.length > 0;
|
||||
@@ -491,35 +538,20 @@ function getNearestUserPrompt(messages: RawMessage[], index: number): string | u
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasAssistantResponseSinceLatestUser(messages: RawMessage[]): boolean {
|
||||
let latestUserIndex = -1;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index]?.role === 'user') {
|
||||
latestUserIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (latestUserIndex < 0) return false;
|
||||
return messages.slice(latestUserIndex + 1).some((message) => (
|
||||
message?.role === 'assistant' && extractText(message).trim().length > 0
|
||||
));
|
||||
}
|
||||
|
||||
function getAssistantTextOverride(
|
||||
message: RawMessage,
|
||||
processSegments: string[],
|
||||
userPrompt: string | undefined,
|
||||
nativeProjection = projectAssistantTextForCodex(message),
|
||||
): string | undefined {
|
||||
if (message.role !== 'assistant') return undefined;
|
||||
const text = extractText(message);
|
||||
if (!text.trim()) return undefined;
|
||||
|
||||
const projectedText = nativeProjection;
|
||||
let visibleText = stripEchoedUserPromptPrefix(
|
||||
projectedText.finalText || (projectedText.processSegments.length > 0 ? '' : text),
|
||||
userPrompt,
|
||||
).trim();
|
||||
// All assistant text blocks are user-facing response content, including a
|
||||
// short acknowledgement or plan that appears immediately before a tool
|
||||
// call. Keep the full text in the body instead of projecting pre-tool text
|
||||
// into the collapsible execution trace.
|
||||
let visibleText = stripEchoedUserPromptPrefix(text, userPrompt).trim();
|
||||
let changed = normalizeProcessText(visibleText) !== normalizeProcessText(text);
|
||||
if (!visibleText) return changed ? '' : undefined;
|
||||
|
||||
@@ -666,7 +698,6 @@ function buildTranscriptExecutionRuns({
|
||||
streamingMessage: runActive ? streamingMessage : null,
|
||||
streamingMessageKey: runActive ? streamingMessageKey : undefined,
|
||||
streamingTools: runActive ? streamingTools : [],
|
||||
omitLastStreamingMessageSegment: Boolean(runActive && streamingMessage),
|
||||
includeThinking: showThinking,
|
||||
nativeTranscript,
|
||||
});
|
||||
@@ -720,7 +751,6 @@ function buildLatestTranscriptExecutionRun({
|
||||
streamingMessage,
|
||||
streamingMessageKey,
|
||||
streamingTools,
|
||||
omitLastStreamingMessageSegment: Boolean(streamingMessage),
|
||||
includeThinking: showThinking,
|
||||
nativeTranscript,
|
||||
});
|
||||
@@ -750,10 +780,6 @@ function getExecutionRunForIndex(
|
||||
}
|
||||
|
||||
const TRANSCRIPT_BOTTOM_THRESHOLD_PX = 48;
|
||||
const SLOW_RESPONSE_THRESHOLD_SECONDS = 5;
|
||||
const LONG_RESPONSE_THRESHOLD_SECONDS = 20;
|
||||
const UPSTREAM_SATURATED_RETRY_NOTICE = '模型服务暂时繁忙,上游通道已满,正在自动重试。可以点击停止,稍后再试或切换模型。';
|
||||
|
||||
function isTranscriptNearBottom(element: HTMLDivElement): boolean {
|
||||
return element.scrollHeight - element.scrollTop - element.clientHeight <= TRANSCRIPT_BOTTOM_THRESHOLD_PX;
|
||||
}
|
||||
@@ -762,14 +788,6 @@ function scrollTranscriptToBottom(element: HTMLDivElement): void {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
function isUpstreamSaturatedRetryMessage(message: string | undefined): boolean {
|
||||
const normalized = message?.trim().toLowerCase() ?? '';
|
||||
if (!normalized) return false;
|
||||
return normalized.includes('当前分组上游负载已饱和')
|
||||
|| normalized.includes('rate_limit_exceeded')
|
||||
|| (normalized.includes('upstream') && normalized.includes('saturat'));
|
||||
}
|
||||
|
||||
function isQuotaExhaustedRetryMessage(message: string | undefined): boolean {
|
||||
return getOpencodeErrorKind(message) === 'quota_exhausted';
|
||||
}
|
||||
@@ -784,23 +802,6 @@ function getAssistantErrorMessage(message: RawMessage | undefined): string | nul
|
||||
return explicitMessage ?? (extractText(message).trim() || null);
|
||||
}
|
||||
|
||||
function getUpstreamSaturatedRetryNotice(status: OpencodeSessionStatus | undefined): string | null {
|
||||
return status?.type === 'retry' && isUpstreamSaturatedRetryMessage(status.message)
|
||||
? UPSTREAM_SATURATED_RETRY_NOTICE
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
function getAssistantWaitingLabel(elapsedSeconds: number): string {
|
||||
if (elapsedSeconds >= LONG_RESPONSE_THRESHOLD_SECONDS) return '等待时间较长';
|
||||
if (elapsedSeconds >= SLOW_RESPONSE_THRESHOLD_SECONDS) return '仍在等待模型';
|
||||
return '正在思考中';
|
||||
}
|
||||
|
||||
function formatElapsedSeconds(elapsedSeconds: number): string {
|
||||
return `${Math.max(0, Math.floor(elapsedSeconds))} 秒`;
|
||||
}
|
||||
|
||||
type OpencodeChatPanelProps = {
|
||||
variant?: 'sidebar' | 'main';
|
||||
navigationDraft?: string;
|
||||
@@ -925,8 +926,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
const [voiceWaveformLevels, setVoiceWaveformLevels] = useState<number[]>(() => [...VOICE_WAVEFORM_IDLE_SCALES]);
|
||||
const [voiceWaveformIsLive, setVoiceWaveformIsLive] = useState(false);
|
||||
const [hasUnreadTranscript, setHasUnreadTranscript] = useState(false);
|
||||
const [responseWaitStartedAt, setResponseWaitStartedAt] = useState<number | null>(null);
|
||||
const [responseWaitNow, setResponseWaitNow] = useState(() => Date.now());
|
||||
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
|
||||
const [modelConfigPromptOpen, setModelConfigPromptOpen] = useState(false);
|
||||
const [quotaUpgradePromptKey, setQuotaUpgradePromptKey] = useState<string | null>(null);
|
||||
@@ -1073,10 +1072,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
&& confirmedQuotaFailureKey === quotaFailureKey;
|
||||
const quotaUpgradePromptOpen = quotaFailureKey !== null
|
||||
&& quotaUpgradePromptKey === quotaFailureKey;
|
||||
const displayError = errorKind === 'quota_exhausted'
|
||||
? (quotaExhaustedErrorActive ? QUOTA_EXHAUSTED_MESSAGE : null)
|
||||
: error;
|
||||
const upstreamSaturatedRetryNotice = getUpstreamSaturatedRetryNotice(selectedSessionStatus);
|
||||
const visibleTranscriptMessages = useMemo(() => {
|
||||
return buildVisibleTranscriptMessages(
|
||||
sessionMessages,
|
||||
@@ -1233,23 +1228,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
executionRuns.map((run) => [run.startIndex, run]),
|
||||
), [executionRuns]);
|
||||
const hasExecutionGraph = executionRuns.length > 0;
|
||||
const hasLiveExecutionActivity = Boolean(
|
||||
activeExecutionRun?.steps.some((step) => step.status === 'running')
|
||||
|| streamingTools.length > 0
|
||||
|| extractText(streamingMessage).trim().length > 0
|
||||
|| extractThinkingSegments(streamingMessage).length > 0,
|
||||
);
|
||||
const awaitingFirstResponse = Boolean(
|
||||
selectedSessionId
|
||||
&& (selectedSessionRunning || selectedSessionStatus?.type === 'busy')
|
||||
&& !hasAssistantResponseSinceLatestUser(visibleTranscriptMessages)
|
||||
&& !hasLiveExecutionActivity
|
||||
&& visiblePendingQuestions.length === 0
|
||||
&& visiblePendingPermissions.length === 0,
|
||||
);
|
||||
const responseWaitElapsedSeconds = responseWaitStartedAt
|
||||
? Math.max(0, Math.floor((responseWaitNow - responseWaitStartedAt) / 1000))
|
||||
: 0;
|
||||
const selectedProjectAgentAvatarSrc = selectedProjectAgent ? getAgentAvatarSrc(selectedProjectAgent.avatarId, selectedProjectAgent.avatarDataUrl) : undefined;
|
||||
const selectedProjectAgentAvatarAlt = selectedProjectAgent ? `${selectedProjectAgent.name || selectedProjectAgent.roleName}头像` : '';
|
||||
const selectedSessionDiff = useMemo(() => {
|
||||
@@ -1416,22 +1394,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
setSelectedDiffPath(null);
|
||||
}, [selectedDiffPath, sessionDiffs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!awaitingFirstResponse) {
|
||||
setResponseWaitStartedAt(null);
|
||||
setResponseWaitNow(Date.now());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setResponseWaitStartedAt((current) => current ?? Date.now());
|
||||
setResponseWaitNow(Date.now());
|
||||
const timer = window.setInterval(() => {
|
||||
setResponseWaitNow(Date.now());
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [awaitingFirstResponse, selectedSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (voiceInputState !== 'recording' || voiceRecordingStartedAt === null) {
|
||||
return undefined;
|
||||
@@ -1460,7 +1422,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
}, [
|
||||
streamingMessage,
|
||||
streamingTools.length,
|
||||
awaitingFirstResponse,
|
||||
visiblePendingQuestions.length,
|
||||
visibleTranscriptMessages,
|
||||
compactionTimelineVersion,
|
||||
@@ -2592,20 +2553,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
compactLayout ? 'p-3' : 'px-0 pb-0',
|
||||
)}
|
||||
>
|
||||
{displayError && (
|
||||
<div className="mx-4 mt-3 shrink-0 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive sm:mx-6">
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
{upstreamSaturatedRetryNotice && (
|
||||
<div
|
||||
data-testid="opencode-upstream-saturated-notice"
|
||||
className="mx-4 mt-3 shrink-0 rounded-md border border-amber-400/40 bg-amber-100 px-3 py-2 text-xs font-medium leading-5 text-amber-900 sm:mx-6"
|
||||
>
|
||||
{upstreamSaturatedRetryNotice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn('relative flex min-h-0 min-w-0 flex-1 gap-0', compactLayout ? 'flex-col' : 'flex-row')}
|
||||
data-testid="opencode-chat-layout"
|
||||
@@ -2654,15 +2601,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4 sm:pb-8">
|
||||
{transcriptTimeline.length === 0 ? (
|
||||
awaitingFirstResponse ? (
|
||||
<div className="space-y-2">
|
||||
<AssistantWaitingPlaceholder elapsedSeconds={responseWaitElapsedSeconds} assistantAvatarSrc={selectedProjectAgentAvatarSrc} assistantAvatarAlt={selectedProjectAgentAvatarAlt} />
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
projectName={projectName}
|
||||
/>
|
||||
)
|
||||
<EmptyState
|
||||
projectName={projectName}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6 sm:space-y-8">
|
||||
{transcriptTimeline.map((timelineItem) => {
|
||||
@@ -2696,8 +2637,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
message,
|
||||
graphScopedAssistant ? messageExecutionRun?.processSegments ?? [] : [],
|
||||
getNearestUserPrompt(visibleTranscriptMessages, index),
|
||||
projectAssistantTextWithNativeParts(message, sessionTranscript)
|
||||
?? projectAssistantTextForCodex(message),
|
||||
);
|
||||
const isGameAssetSelectionRound = hasGameAssetReviewContent(message);
|
||||
const gameAssetReviewInvocation = parseGameAssetReviewInvocation(message);
|
||||
@@ -2739,7 +2678,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
onToolExpandedChange={handleToolExpandedChange}
|
||||
assistantAvatarSrc={selectedProjectAgentAvatarSrc}
|
||||
assistantAvatarAlt={selectedProjectAgentAvatarAlt}
|
||||
assistantAvatarMode={graphScopedAssistant ? 'embedded-trace' : 'visible'}
|
||||
assistantAvatarMode="visible"
|
||||
compactAssistantReply={graphScopedAssistant}
|
||||
textOverride={visibleAssistantText}
|
||||
suppressAssistantText={visibleAssistantText === ''}
|
||||
@@ -2846,9 +2785,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
assistantAvatarAlt={selectedProjectAgentAvatarAlt}
|
||||
/>
|
||||
))}
|
||||
{awaitingFirstResponse && (
|
||||
<AssistantWaitingPlaceholder elapsedSeconds={responseWaitElapsedSeconds} assistantAvatarSrc={selectedProjectAgentAvatarSrc} assistantAvatarAlt={selectedProjectAgentAvatarAlt} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3127,7 +3063,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
|
||||
title="停止当前对话"
|
||||
onClick={() => void handleAbortSession()}
|
||||
>
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
<AgentResponseLoader />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -3274,30 +3210,65 @@ function PendingInteractionsDock({
|
||||
onRejectQuestion: (requestId: string) => Promise<void>;
|
||||
onReplyPermission: (requestId: string, reply: 'once' | 'always' | 'reject', message?: string) => Promise<void>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const contentId = `pending-interactions-${useId().replace(/:/gu, '')}`;
|
||||
const interactionCount = questions.length + permissions.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="opencode-pending-interactions"
|
||||
className="shrink-0 space-y-2 border-t bg-amber-500/5 p-3 border-border/70 sm:p-4"
|
||||
className="shrink-0 rounded-lg border border-amber-500/25 bg-amber-500/5 p-2 sm:p-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-amber-700 text-amber-700">
|
||||
<CircleHelp className="h-3.5 w-3.5" />
|
||||
<span>继续前需要你确认</span>
|
||||
</div>
|
||||
{questions.map((question) => (
|
||||
<PendingQuestionCard
|
||||
key={question.id}
|
||||
request={question}
|
||||
onReply={onReplyQuestion}
|
||||
onReject={onRejectQuestion}
|
||||
/>
|
||||
))}
|
||||
{permissions.map((permission) => (
|
||||
<PendingPermissionCard
|
||||
key={permission.id}
|
||||
request={permission}
|
||||
onReply={onReplyPermission}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="opencode-pending-interactions-toggle"
|
||||
className="flex min-h-9 w-full items-center justify-between gap-3 rounded-md px-1.5 py-1 text-left text-amber-800 transition-colors hover:bg-amber-500/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-600/40"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
aria-label={expanded ? '收起待确认事项' : '展开待确认事项'}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<CircleHelp className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-semibold">继续前需要你确认</span>
|
||||
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
|
||||
{interactionCount} 项待处理
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-amber-800/80">
|
||||
<span>{expanded ? '收起' : '展开'}</span>
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform duration-200 ease-out', expanded && 'rotate-180')} />
|
||||
</span>
|
||||
</button>
|
||||
<DisclosureContent
|
||||
id={contentId}
|
||||
open={expanded}
|
||||
className="mt-2"
|
||||
innerClassName="min-h-0"
|
||||
>
|
||||
<div
|
||||
data-testid="opencode-pending-interactions-list"
|
||||
className="max-h-[min(42vh,28rem)] space-y-2 overflow-y-auto overscroll-contain pr-1"
|
||||
>
|
||||
{questions.map((question) => (
|
||||
<PendingQuestionCard
|
||||
key={question.id}
|
||||
request={question}
|
||||
onReply={onReplyQuestion}
|
||||
onReject={onRejectQuestion}
|
||||
/>
|
||||
))}
|
||||
{permissions.map((permission) => (
|
||||
<PendingPermissionCard
|
||||
key={permission.id}
|
||||
request={permission}
|
||||
onReply={onReplyPermission}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DisclosureContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3666,27 +3637,6 @@ function ExecutionGraphTranscriptCard({
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantWaitingPlaceholder({ elapsedSeconds, assistantAvatarSrc, assistantAvatarAlt }: { elapsedSeconds: number; assistantAvatarSrc?: string; assistantAvatarAlt?: string }) {
|
||||
return (
|
||||
<div className="flex gap-3 group" data-testid="assistant-waiting-placeholder" aria-live="polite">
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-foreground/15 bg-surface-subtle text-foreground bg-surface-subtle">
|
||||
{assistantAvatarSrc ? <img src={assistantAvatarSrc} alt={assistantAvatarAlt} className="h-full w-full object-cover [image-rendering:pixelated]" /> : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 max-w-[80%] flex-col items-start space-y-2">
|
||||
<div className="relative w-full rounded-2xl bg-surface-subtle px-4 py-3 text-foreground bg-surface-subtle">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
<span className="truncate font-medium">{getAssistantWaitingLabel(elapsedSeconds)}</span>
|
||||
<span className="ml-auto shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{formatElapsedSeconds(elapsedSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PendingQuestionCardProps = {
|
||||
request: OpencodeQuestionRequest;
|
||||
onReply: (requestId: string, answers: string[][]) => Promise<void>;
|
||||
|
||||
@@ -278,7 +278,13 @@ export function buildSessionTranscriptViewModel({
|
||||
const partIDs = nativeTranscript.partOrderByMessageId[messageID] ?? [];
|
||||
partIDs.forEach((partID) => {
|
||||
const part = nativeTranscript.partsById[partID];
|
||||
if (!part || part.type === 'text' || part.type === 'tool' || part.type === 'reasoning') return;
|
||||
if (
|
||||
!part
|
||||
|| part.type === 'text'
|
||||
|| part.type === 'tool'
|
||||
|| part.type === 'reasoning'
|
||||
|| part.type === 'compaction'
|
||||
) return;
|
||||
const kind = nativePartKind(part);
|
||||
const status = nativePartStatus(part);
|
||||
executionTrace.push({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { extractText, extractTextSegments, extractThinkingSegments, extractToolUse } from './message-utils';
|
||||
import { extractThinkingSegments, extractToolUse } from './message-utils';
|
||||
import {
|
||||
getMessageVisibilityKey,
|
||||
getStreamingMessageVisibilityKey,
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from './chat-transcript';
|
||||
import type { RawMessage, ToolStatus } from '@/types/chat';
|
||||
import type { OpencodeSessionTranscriptState } from '@/types/opencode';
|
||||
import { projectAssistantTextWithNativeParts } from './session-transcript-view-model';
|
||||
|
||||
export type TaskStepStatus = 'running' | 'completed' | 'error';
|
||||
|
||||
@@ -23,41 +22,12 @@ export interface TaskStep {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the index of the "final reply" assistant message in a run segment.
|
||||
*
|
||||
* The reply is the last assistant message that carries non-empty text
|
||||
* content, regardless of whether it ALSO carries tool calls. (Mixed
|
||||
* `text + toolCall` replies are rare but real — the model can emit a parting
|
||||
* text block alongside a final tool call. Treating such a message as the
|
||||
* reply avoids mis-protecting an earlier narration as the "answer" and
|
||||
* leaking the actual last text into the fold.)
|
||||
*
|
||||
* When this returns a non-negative index, the caller should avoid folding
|
||||
* that message's text into the graph (it is the answer the user sees in the
|
||||
* chat stream). When the run is still active (streaming) the final reply is
|
||||
* produced via `streamingMessage` instead, so callers pass
|
||||
* `hasStreamingReply = true` to skip protection and let every assistant-with-
|
||||
* text message in history be folded into the graph as narration.
|
||||
*/
|
||||
export function findReplyMessageIndex(messages: RawMessage[], hasStreamingReply: boolean): number {
|
||||
if (hasStreamingReply) return -1;
|
||||
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
|
||||
const message = messages[idx];
|
||||
if (!message || message.role !== 'assistant') continue;
|
||||
if (extractText(message).trim().length === 0) continue;
|
||||
return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
interface DeriveTaskStepsInput {
|
||||
messages: RawMessage[];
|
||||
messageIndexOffset?: number;
|
||||
streamingMessage: unknown | null;
|
||||
streamingMessageKey?: string;
|
||||
streamingTools: ToolStatus[];
|
||||
omitLastStreamingMessageSegment?: boolean;
|
||||
includeThinking?: boolean;
|
||||
nativeTranscript?: OpencodeSessionTranscriptState;
|
||||
}
|
||||
@@ -294,7 +264,6 @@ export function deriveTaskSteps({
|
||||
streamingMessage,
|
||||
streamingMessageKey,
|
||||
streamingTools,
|
||||
omitLastStreamingMessageSegment = false,
|
||||
includeThinking = true,
|
||||
nativeTranscript,
|
||||
}: DeriveTaskStepsInput): TaskStep[] {
|
||||
@@ -321,12 +290,6 @@ export function deriveTaskSteps({
|
||||
? streamingMessage as RawMessage
|
||||
: null;
|
||||
|
||||
// The final answer the user sees as a chat bubble. We avoid folding it into
|
||||
// the graph to prevent duplication. When a run is still streaming, the
|
||||
// reply lives in `streamingMessage`, so every pure-text assistant message in
|
||||
// `messages` is treated as intermediate narration.
|
||||
const replyIndex = findReplyMessageIndex(messages, streamMessage != null);
|
||||
|
||||
for (const [messageIndex, message] of messages.entries()) {
|
||||
if (!message || message.role !== 'assistant') continue;
|
||||
const stableMessageKey = getMessageVisibilityKey(
|
||||
@@ -344,28 +307,10 @@ export function deriveTaskSteps({
|
||||
});
|
||||
}
|
||||
|
||||
const toolUses = extractToolUse(message);
|
||||
// Fold any intermediate assistant text into the graph as a narration
|
||||
// step — including text that lives on a mixed `text + toolCall` message.
|
||||
// The narration step is emitted BEFORE the tool steps so the graph
|
||||
// preserves the original ordering (the assistant "thinks out loud" and
|
||||
// then invokes the tool).
|
||||
const nativeTextProjection = projectAssistantTextWithNativeParts(message, nativeTranscript);
|
||||
const narrationSegments = nativeTextProjection?.processSegments ?? extractTextSegments(message);
|
||||
const graphNarrationSegments = nativeTextProjection
|
||||
? narrationSegments
|
||||
: messageIndex === replyIndex
|
||||
? narrationSegments.slice(0, -1)
|
||||
: narrationSegments;
|
||||
appendDetailSegments(graphNarrationSegments, {
|
||||
idPrefix: `history-message-${message.id || messageIndex}`,
|
||||
label: 'Message',
|
||||
kind: 'message',
|
||||
running: false,
|
||||
upsertStep,
|
||||
});
|
||||
|
||||
toolUses.forEach((tool, index) => {
|
||||
// Assistant text is user-facing response content. Keep it in the normal
|
||||
// chat bubble even when the same message also invokes tools; only native
|
||||
// thinking, tools, and runtime/system events belong to the folded trace.
|
||||
extractToolUse(message).forEach((tool, index) => {
|
||||
const input = tool.input as Record<string, unknown>;
|
||||
const url = tool.name === 'web_fetch' && typeof input?.url === 'string' ? input.url : undefined;
|
||||
upsertStep({
|
||||
@@ -399,23 +344,6 @@ export function deriveTaskSteps({
|
||||
});
|
||||
}
|
||||
|
||||
// Stream-time narration should also appear in the execution graph so that
|
||||
// intermediate process output stays in P1 instead of leaking into the
|
||||
// assistant reply area.
|
||||
const nativeStreamTextProjection = projectAssistantTextWithNativeParts(streamMessage, nativeTranscript);
|
||||
const streamNarrationSegments = nativeStreamTextProjection?.processSegments ?? extractTextSegments(streamMessage);
|
||||
const graphStreamNarrationSegments = nativeStreamTextProjection
|
||||
? streamNarrationSegments
|
||||
: omitLastStreamingMessageSegment
|
||||
? streamNarrationSegments.slice(0, -1)
|
||||
: streamNarrationSegments;
|
||||
appendDetailSegments(graphStreamNarrationSegments, {
|
||||
idPrefix: 'stream-message',
|
||||
label: 'Message',
|
||||
kind: 'message',
|
||||
running: !omitLastStreamingMessageSegment,
|
||||
upsertStep,
|
||||
});
|
||||
appendNativeExecutionParts(
|
||||
nativeTranscript,
|
||||
streamMessage.id,
|
||||
|
||||
Reference in New Issue
Block a user