Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion src/lib/chat-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import {
hasInspectableProcess,
hasProcessDetails,
isProtocolProcessStep,
latestThinkingStep,
mergeThinkingText,
mergeToolResult,
phaseFromMessageStatus,
processKey,
processPhaseLabel,
reduceProcessEvent,
showBubbleThinkingBar,
stepSummary,
thinkingElapsedMs,
timelineHasToolRow,
timelineProcessSteps,
toolActionTarget,
toolActionTone,
Expand Down Expand Up @@ -622,6 +626,97 @@ describe('chat-process reduceProcessEvent', () => {
expect(map['1:grok']?.steps[1]).toMatchObject({ type: 'tool', status: 'end', result: 'ok' });
});

it('stamps thinking start and freezes duration when the episode ends', () => {
let map: ProcessMap = reduceProcessEvent(
{},
{ type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' },
1000,
);
map = reduceProcessEvent(
map,
{
type: 'agentProcess',
turn: 1,
agent: 'grok',
step: { type: 'thinking', text: 'Hel', done: false },
},
2000,
);
expect(map['1:grok']?.thinkingStartedAt).toBe(2000);
expect(map['1:grok']?.thinkingDurationMs).toBeUndefined();
expect(thinkingElapsedMs(map['1:grok'], 3500)).toBe(1500);
expect(latestThinkingStep(map['1:grok']?.steps)).toMatchObject({ done: false });
expect(showBubbleThinkingBar(map['1:grok']?.steps, false)).toBe(true);
expect(showBubbleThinkingBar(map['1:grok']?.steps, true)).toBe(false);

map = reduceProcessEvent(
map,
{
type: 'agentProcess',
turn: 1,
agent: 'grok',
step: { type: 'thinking', text: 'lo', done: false },
},
2800,
);
expect(map['1:grok']?.thinkingStartedAt).toBe(2000);

map = reduceProcessEvent(
map,
{
type: 'agentProcess',
turn: 1,
agent: 'grok',
step: { type: 'tool', id: 't1', name: 'Read', status: 'start' },
},
5200,
);
expect(map['1:grok']?.thinkingDurationMs).toBe(3200);
expect(thinkingElapsedMs(map['1:grok'], 9000)).toBe(3200);
expect(timelineHasToolRow(map['1:grok']?.steps)).toBe(true);
});

it('starts a new thinking timer after a tool', () => {
let map: ProcessMap = reduceProcessEvent(
{},
{ type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' },
1,
);
map = reduceProcessEvent(
map,
{
type: 'agentProcess',
turn: 1,
agent: 'grok',
step: { type: 'thinking', text: 'first', done: false },
},
100,
);
map = reduceProcessEvent(
map,
{
type: 'agentProcess',
turn: 1,
agent: 'grok',
step: { type: 'tool', id: 't1', name: 'Read', status: 'start' },
},
400,
);
map = reduceProcessEvent(
map,
{
type: 'agentProcess',
turn: 1,
agent: 'grok',
step: { type: 'thinking', text: 'second', done: false },
},
900,
);
expect(map['1:grok']?.thinkingStartedAt).toBe(900);
expect(map['1:grok']?.thinkingDurationMs).toBeUndefined();
expect(thinkingElapsedMs(map['1:grok'], 1400)).toBe(500);
});

it('agentFinished marks leftover thinking done', () => {
let map: ProcessMap = reduceProcessEvent(
{},
Expand All @@ -646,9 +741,11 @@ describe('chat-process reduceProcessEvent', () => {
agent: 'grok',
message: finishedMsg({ status: 'ok', content: 'done', agentId: 'grok' }),
},
3,
5002,
);
expect(map['1:grok']?.steps[0]).toMatchObject({ type: 'thinking', done: true });
expect(map['1:grok']?.thinkingStartedAt).toBe(2);
expect(map['1:grok']?.thinkingDurationMs).toBe(5000);
});

it('finished finalizes still-active process views for the turn', () => {
Expand Down
83 changes: 83 additions & 0 deletions src/lib/chat-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type ProcessPhase =
| 'cancelled'
| 'timeout';

export type ThinkingStep = Extract<ProcessStep, { type: 'thinking' }>;

export type AgentProcessView = {
turn: number;
agent: AgentKey;
Expand All @@ -26,6 +28,10 @@ export type AgentProcessView = {
/** Structured steps (tool / thinking / status / raw / usage). Cap in reducer. */
steps: ProcessStep[];
updatedAt: number;
/** Wall-clock when the current/last thinking episode started. */
thinkingStartedAt?: number;
/** Frozen duration for the last thinking episode once it finishes. */
thinkingDurationMs?: number;
};

export type ProcessMap = Record<string, AgentProcessView>;
Expand Down Expand Up @@ -344,6 +350,39 @@ export function timelineProcessSteps(steps: ProcessStep[]): ProcessStep[] {
return out;
}

export function isThinkingStep(step: ProcessStep): step is ThinkingStep {
return step.type === 'thinking';
}

export function latestThinkingStep(steps: ProcessStep[] | undefined): ThinkingStep | undefined {
const found = lastMatching(steps ?? [], isThinkingStep);
return found && isThinkingStep(found) ? found : undefined;
}

/** Live timer, or the frozen duration after thinking ends. */
export function thinkingElapsedMs(view: AgentProcessView | undefined, now: number): number {
if (!view?.thinkingStartedAt) return 0;
if (view.thinkingDurationMs != null) return Math.max(0, view.thinkingDurationMs);
return Math.max(0, now - view.thinkingStartedAt);
}

/** Main-column chrome: thinking exists and the assistant body has not arrived. */
export function showBubbleThinkingBar(
steps: ProcessStep[] | undefined,
hasContent: boolean,
): boolean {
if (hasContent) return false;
return latestThinkingStep(steps) != null;
}

export function timelineHasToolRow(steps: ProcessStep[] | undefined): boolean {
return timelineProcessSteps(steps ?? []).some(
(step) => step.type === 'tool' || step.type === 'error' || step.type === 'raw',
);
}

function lastMatching<T, S extends T>(items: T[], pred: (item: T) => item is S): S | undefined;
function lastMatching<T>(items: T[], pred: (item: T) => boolean): T | undefined;
function lastMatching<T>(items: T[], pred: (item: T) => boolean): T | undefined {
for (let i = items.length - 1; i >= 0; i -= 1) {
if (pred(items[i])) return items[i];
Expand Down Expand Up @@ -549,6 +588,45 @@ function markLastThinkingDone(steps: ProcessStep[]): ProcessStep[] {
return steps;
}

function freezeThinkingDuration(
view: Pick<AgentProcessView, 'thinkingStartedAt' | 'thinkingDurationMs'>,
now: number,
): Pick<AgentProcessView, 'thinkingStartedAt' | 'thinkingDurationMs'> {
if (view.thinkingStartedAt == null || view.thinkingDurationMs != null) {
return {
thinkingStartedAt: view.thinkingStartedAt,
thinkingDurationMs: view.thinkingDurationMs,
};
}
return {
thinkingStartedAt: view.thinkingStartedAt,
thinkingDurationMs: Math.max(0, now - view.thinkingStartedAt),
};
}

function stampThinkingTiming(
prev: AgentProcessView,
step: ProcessStep,
now: number,
): Pick<AgentProcessView, 'thinkingStartedAt' | 'thinkingDurationMs'> {
if (step.type === 'thinking') {
const last = prev.steps[prev.steps.length - 1];
const mergeIntoOpen = last?.type === 'thinking' && !last.done;
const startedAt = mergeIntoOpen ? (prev.thinkingStartedAt ?? now) : now;
if (step.done) {
return { thinkingStartedAt: startedAt, thinkingDurationMs: Math.max(0, now - startedAt) };
}
return { thinkingStartedAt: startedAt, thinkingDurationMs: undefined };
}
if (prev.thinkingStartedAt != null && prev.thinkingDurationMs == null) {
return freezeThinkingDuration(prev, now);
}
return {
thinkingStartedAt: prev.thinkingStartedAt,
thinkingDurationMs: prev.thinkingDurationMs,
};
}

/**
* Codex `item.updated` reasoning is a full snapshot; Grok/Pi/Claude thinking
* chunks are deltas. If the new text already contains the previous text as a
Expand Down Expand Up @@ -686,6 +764,8 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no
stdout: prev?.stdout ?? '',
stderr: prev?.stderr ?? '',
steps: prev?.steps ?? [],
thinkingStartedAt: prev?.thinkingStartedAt,
thinkingDurationMs: prev?.thinkingDurationMs,
updatedAt: now,
},
};
Expand Down Expand Up @@ -728,6 +808,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no
...prev,
phase: prev.phase === 'queued' || prev.phase === 'starting' ? 'running' : prev.phase,
steps: pushStep(prev.steps, ev.step),
...stampThinkingTiming(prev, ev.step, now),
updatedAt: now,
},
};
Expand All @@ -744,6 +825,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no
phase: phaseFromMessageStatus(ev.message.status),
stdout: content || prev.stdout,
steps: markLastThinkingDone(prev.steps),
...freezeThinkingDuration(prev, now),
updatedAt: now,
},
};
Expand All @@ -765,6 +847,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no
// 生产取消时 ok=true;缺省 cancelled 当 false,兼容旧事件
phase: ev.cancelled ? 'cancelled' : ev.ok ? 'ok' : 'failed',
steps: markLastThinkingDone(view.steps),
...freezeThinkingDuration(view, now),
updatedAt: now,
};
changed = true;
Expand Down
59 changes: 59 additions & 0 deletions src/pages/chat/ChatMessageBubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,65 @@ describe('ChatMessageBubble streaming feel', () => {
expect(html).not.toContain('已停止');
});

it('shows a clickable thinking bar instead of three dots when thinking has no body yet', () => {
const process: AgentProcessView = {
turn: 1,
agent: 'codex',
phase: 'running',
stdout: '',
stderr: '',
steps: [{ type: 'thinking', text: 'secret plan that must not enter the bubble', done: false }],
updatedAt: 1,
thinkingStartedAt: Date.now() - 3200,
};
const html = renderToStaticMarkup(
createElement(TooltipProvider, null, createElement(ChatMessageBubble, {
message: agentMessage(''),
process,
isLastTurn: true,
multiAgent: false,
retryDisabled: false,
onRetry: () => undefined,
onOpenProcess: () => undefined,
})),
);
expect(html).toContain('data-help="chat-thinking-bar"');
expect(html).toContain('思考中');
expect(html).toContain('▸');
expect(html).not.toContain('正在想');
expect(html).not.toContain('secret plan that must not enter the bubble');
expect(html).not.toContain('data-help="chat-process-chip"');
});

it('shows 思考了 after thinking ends and before the reply body', () => {
const process: AgentProcessView = {
turn: 1,
agent: 'codex',
phase: 'running',
stdout: '',
stderr: '',
steps: [{ type: 'thinking', text: 'done thinking body', done: true }],
updatedAt: 1,
thinkingStartedAt: 1,
thinkingDurationMs: 3200,
};
const html = renderToStaticMarkup(
createElement(TooltipProvider, null, createElement(ChatMessageBubble, {
message: agentMessage(''),
process,
isLastTurn: true,
multiAgent: false,
retryDisabled: false,
onRetry: () => undefined,
onOpenProcess: () => undefined,
})),
);
expect(html).toContain('data-help="chat-thinking-bar"');
expect(html).toContain('思考了 3.2s');
expect(html).not.toContain('正在写');
expect(html).not.toContain('done thinking body');
});

it('opens process details from a one-line chip', () => {
const process: AgentProcessView = {
turn: 1,
Expand Down
Loading
Loading