+
)}
- renderWikiWrite={(toolCall: ToolCallsType) => (
-
-
-
+ renderWikiWrite={(toolCall: ToolCallsType, isLive: boolean) => (
+
)}
/>
)}
- {/* Only when nothing inline is already showing the activity: a live
- reasoning or tool segment carries its own shimmer, so this would be
- a second indicator away from the point of action. */}
- {isStreaming && !hasLiveInlineStep && (
-
- )}
{message && (
-
+ // ml-4 plus each button's own p-2 puts the first glyph on the answer's
+ // ml-6 text column.
+
{type === 'ERROR' ? (
{retryBtn}
@@ -937,39 +862,48 @@ function ToolCallApprovalBar({
);
}
+/** The wiki-write step, in the same inline chip language as the other steps. */
export function WikiWriteToolCallCard({
toolCall,
+ isLive,
}: {
toolCall: ToolCallsType;
+ isLive?: boolean;
}) {
const { t } = useTranslation();
const path = wikiWritePath(toolCall);
const actionKey = wikiWriteActionKey(toolCall.action_name);
const isError = toolCall.status === 'error';
+ const namespace = isToolCallRunning(toolCall)
+ ? 'wikiWrite.active'
+ : 'wikiWrite';
+ const label = t(`conversation.${namespace}.${actionKey}`, {
+ defaultValue: t(`conversation.${namespace}.edited`),
+ });
return (
-
-
- ✏️
-
-
- {t(`conversation.wikiWrite.${actionKey}`, {
- defaultValue: t('conversation.wikiWrite.edited'),
- })}
+
+
+
+ {label}
{path && (
-
+
{path}
)}
{isError && (
-
+
{t('conversation.wikiWrite.failed')}
)}
diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx
index 34d60d31..30a6cba5 100644
--- a/frontend/src/conversation/ConversationMessages.tsx
+++ b/frontend/src/conversation/ConversationMessages.tsx
@@ -8,7 +8,6 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
-import DocsGPT3 from '../assets/cute_docsgpt3.svg';
import Retry from '../assets/retry.svg?react';
import { Button } from '../components/ui/button';
import {
@@ -131,6 +130,12 @@ export default function ConversationMessages({
? 'w-full max-w-325 px-2'
: 'w-full max-w-325 px-2 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12';
+ // The empty state sits directly on top of the composer with nothing between
+ // them, so it takes the composer's width rather than the wider message column.
+ const emptyStateColumnClass = isSplitView
+ ? 'w-full max-w-290 px-2'
+ : 'w-full max-w-290 px-2 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12';
+
const renderResponseView = (query: Query, index: number) => {
const isLastMessage = index === queries.length - 1;
const bubbleMargin = DEFAULT_BUBBLE_MARGIN;
@@ -228,19 +233,7 @@ export default function ConversationMessages({
className={`fade-in-bubble group dark:text-foreground flex flex-col flex-wrap self-start ${bubbleMargin}`}
>
-
-
-

-
-
- {t('conversation.answer')}
-
-
-
+
);
@@ -252,7 +245,7 @@ export default function ConversationMessages({
if (queries.length === 0) {
return (
-
+
{headerContent}
{showHeroOnEmpty ? : null}
diff --git a/frontend/src/conversation/SharedConversation.tsx b/frontend/src/conversation/SharedConversation.tsx
index 3a732467..3a45a4fe 100644
--- a/frontend/src/conversation/SharedConversation.tsx
+++ b/frontend/src/conversation/SharedConversation.tsx
@@ -140,7 +140,7 @@ export const SharedConversation = () => {
twitterDescription="Shared conversations with DocsGPT"
/>
-
+
{title}
@@ -160,7 +160,7 @@ export const SharedConversation = () => {
queries={queries}
status={status}
/>
-
+
{apiKey ? (
+ {/* Stands in for a step chip's icon so the live text keeps the same
+ offset whether a chip or this line is carrying it. */}
+
+
+
{hasAnswerText
? t('conversation.streamingStatus.generating')
diff --git a/frontend/src/conversation/WikiWriteToolCallCard.test.tsx b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx
index 4441710b..64d6716b 100644
--- a/frontend/src/conversation/WikiWriteToolCallCard.test.tsx
+++ b/frontend/src/conversation/WikiWriteToolCallCard.test.tsx
@@ -18,10 +18,10 @@ beforeAll(async () => {
});
});
-const render = (toolCall: ToolCallsType): string =>
+const render = (toolCall: ToolCallsType, isLive?: boolean): string =>
renderToStaticMarkup(
-
+
,
);
@@ -34,11 +34,49 @@ describe('WikiWriteToolCallCard', () => {
arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
status: 'completed',
});
- expect(html).toContain('✏️');
expect(html).toContain('Edited wiki page');
expect(html).toContain('/policy.md');
});
+ it('reads in the present tense while the write is still running', () => {
+ const html = render(
+ {
+ tool_name: 'wiki',
+ action_name: 'wiki_create',
+ call_id: 'c1',
+ arguments: { path: '/policy.md' },
+ status: 'pending',
+ },
+ true,
+ );
+ expect(html).toContain('Creating wiki page…');
+ expect(html).toContain('shimmer-text');
+ });
+
+ it('drops the shimmer once the write has settled', () => {
+ const html = render({
+ tool_name: 'wiki',
+ action_name: 'wiki_create',
+ call_id: 'c1',
+ arguments: { path: '/policy.md' },
+ status: 'completed',
+ });
+ expect(html).not.toContain('shimmer-text');
+ });
+
+ it('marks a failed write', () => {
+ const html = render({
+ tool_name: 'wiki',
+ action_name: 'wiki_delete',
+ call_id: 'c1',
+ arguments: { path: '/policy.md' },
+ status: 'error',
+ error: 'boom',
+ });
+ expect(html).toContain('Deleted wiki page');
+ expect(html).toContain('failed');
+ });
+
it('uses the new path for a rename', () => {
const html = render({
tool_name: 'wiki',
diff --git a/frontend/src/conversation/answerFlowIntegration.test.tsx b/frontend/src/conversation/answerFlowIntegration.test.tsx
index afc32b64..d2ef60fe 100644
--- a/frontend/src/conversation/answerFlowIntegration.test.tsx
+++ b/frontend/src/conversation/answerFlowIntegration.test.tsx
@@ -7,6 +7,7 @@ import en from '../locale/en.json';
import AnswerFlow from './AnswerFlow';
import reducer, {
addQuery,
+ applyMessageTail,
setStatus,
updateStreamingQuery,
updateThought,
@@ -92,4 +93,56 @@ describe('stream reducers feeding the answer flow', () => {
html.indexOf('DocsGPT rocks.'),
);
});
+
+ // Reopening the streaming conversation polls /tail alongside the live SSE, so
+ // the flat snapshot lands between two deltas.
+ it('keeps every step visible when a tail snapshot interleaves with the stream', () => {
+ const at = { index: 0, conversationId: null };
+ let state = reducer(undefined, addQuery({ prompt: 'hi' }));
+ state = reducer(state, setStatus('loading'));
+ state = reducer(
+ state,
+ updateThought({ ...at, query: { thought: 'I should search.' } }),
+ );
+ state = reducer(
+ state,
+ updateToolCall({ ...at, tool_call: searchCall('completed') }),
+ );
+ state = reducer(
+ state,
+ applyMessageTail({
+ index: 0,
+ tail: {
+ status: 'pending',
+ thought: 'I should search.',
+ tool_calls: [searchCall('completed')],
+ },
+ }),
+ );
+ // A second call arrives after the tail; it must not become the whole order.
+ state = reducer(
+ state,
+ updateToolCall({
+ ...at,
+ tool_call: { ...searchCall('completed'), call_id: 'c2' },
+ }),
+ );
+
+ const query = state.queries[0];
+ const html = renderToStaticMarkup(
+
+ null}
+ renderWikiWrite={() => null}
+ />
+ ,
+ );
+
+ expect(html).toContain('I should search.');
+ expect(html.split('Searched the web').length - 1).toBe(2);
+ });
});
diff --git a/frontend/src/conversation/answerSegments.test.ts b/frontend/src/conversation/answerSegments.test.ts
index 1eef9725..593b22ef 100644
--- a/frontend/src/conversation/answerSegments.test.ts
+++ b/frontend/src/conversation/answerSegments.test.ts
@@ -87,8 +87,17 @@ describe('synthesizeSegments / getAnswerSegments', () => {
});
it('prefers live steps when present', () => {
- const live: AnswerSegment[] = [{ kind: 'thought', text: 'live' }];
- expect(getAnswerSegments({ thought: 'flat', segments: live })).toBe(live);
+ const live: AnswerSegment[] = [
+ { kind: 'thought', text: 'live' },
+ { kind: 'tool', call_id: 'c1' },
+ ];
+ expect(
+ getAnswerSegments({
+ thought: 'live',
+ tool_calls: [call({ call_id: 'c1' })],
+ segments: live,
+ }),
+ ).toBe(live);
});
it('falls back when steps were cleared by a tail snapshot', () => {
@@ -96,4 +105,59 @@ describe('synthesizeSegments / getAnswerSegments', () => {
{ kind: 'thought', text: 'flat' },
]);
});
+
+ it('falls back when the order misses a call the answer holds', () => {
+ expect(
+ getAnswerSegments({
+ thought: 'reasoning',
+ tool_calls: [call({ call_id: 'c1' }), call({ call_id: 'c2' })],
+ segments: [{ kind: 'tool', call_id: 'c2' }],
+ }),
+ ).toEqual([
+ { kind: 'thought', text: 'reasoning' },
+ { kind: 'tool', call_id: 'c1' },
+ { kind: 'tool', call_id: 'c2' },
+ ]);
+ });
+
+ it('falls back when the order misses reasoning the answer holds', () => {
+ expect(
+ getAnswerSegments({
+ thought: 'full reasoning',
+ tool_calls: [call({ call_id: 'c1' })],
+ segments: [
+ { kind: 'tool', call_id: 'c1' },
+ { kind: 'thought', text: 'reasoning' },
+ ],
+ }),
+ ).toEqual([
+ { kind: 'thought', text: 'full reasoning' },
+ { kind: 'tool', call_id: 'c1' },
+ ]);
+ });
+
+ it('keeps the live order when reasoning arrived in several pieces', () => {
+ const live: AnswerSegment[] = [
+ { kind: 'thought', text: 'plan' },
+ { kind: 'tool', call_id: 'c1' },
+ { kind: 'thought', text: 'verify' },
+ ];
+ expect(
+ getAnswerSegments({
+ thought: 'planverify',
+ tool_calls: [call({ call_id: 'c1' })],
+ segments: live,
+ }),
+ ).toBe(live);
+ });
+
+ it('does not fall back for a call the backend left without an id', () => {
+ const live: AnswerSegment[] = [{ kind: 'tool', call_id: 'c1' }];
+ expect(
+ getAnswerSegments({
+ tool_calls: [call({ call_id: 'c1' }), call({ call_id: '' })],
+ segments: live,
+ }),
+ ).toBe(live);
+ });
});
diff --git a/frontend/src/conversation/answerSegments.ts b/frontend/src/conversation/answerSegments.ts
index f380f306..77c1e60e 100644
--- a/frontend/src/conversation/answerSegments.ts
+++ b/frontend/src/conversation/answerSegments.ts
@@ -45,10 +45,38 @@ export function synthesizeSegments(query: {
return segments;
}
+/**
+ * Whether the recorded order still accounts for every part of the answer. A
+ * call with no id can never be recorded, so it never counts against the order.
+ */
+function coversAnswer(
+ query: { thought?: string; tool_calls?: ToolCallsType[] },
+ segments: AnswerSegment[],
+): boolean {
+ const ordered = new Set(
+ segments.flatMap((segment) =>
+ segment.kind === 'tool' ? [segment.call_id] : [],
+ ),
+ );
+ const everyCallOrdered = (query.tool_calls ?? []).every(
+ (call) => !call.call_id || ordered.has(call.call_id),
+ );
+ const orderedThought = segments
+ .flatMap((segment) => (segment.kind === 'thought' ? [segment.text] : []))
+ .join('');
+ return everyCallOrdered && orderedThought === (query.thought ?? '');
+}
+
export function getAnswerSegments(query: {
thought?: string;
tool_calls?: ToolCallsType[];
segments?: AnswerSegment[];
}): AnswerSegment[] {
- return query.segments?.length ? query.segments : synthesizeSegments(query);
+ // The order only arranges what ``thought``/``tool_calls`` already hold, so one
+ // that no longer accounts for them (a tail snapshot landed mid-stream, a
+ // reconnect replayed part of it) loses to synthesis rather than hiding the
+ // steps it never recorded.
+ if (query.segments?.length && coversAnswer(query, query.segments))
+ return query.segments;
+ return synthesizeSegments(query);
}
diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts
index 8d24c5f5..fbdc0b8e 100644
--- a/frontend/src/conversation/conversationSlice.ts
+++ b/frontend/src/conversation/conversationSlice.ts
@@ -1013,9 +1013,11 @@ export const conversationSlice = createSlice({
const { index, tail } = action.payload;
const query = state.queries[index];
if (!query) return;
- // A tail is a flat snapshot with no ordering, so any live segments it
- // overwrites would be stale; drop them and let rendering synthesize.
- delete query.segments;
+ // A tail is a flat snapshot with no ordering. The live order is left
+ // alone: dropping it here left the stream reducers rebuilding a partial
+ // one from the next delta, which hid every step that came before. It is
+ // rendering that decides, per answer, whether the recorded order still
+ // accounts for the snapshot or synthesis has to take over.
const status = tail?.status as MessageStatus | undefined;
query.messageStatus = status;
query.lastHeartbeatAt = tail?.last_heartbeat_at ?? query.lastHeartbeatAt;
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 5081d1a3..20b622d0 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -752,6 +752,12 @@ Avoid over-scrolling in mobile browsers
color: inherit;
background: none;
}
+
+ /* Same specificity as the dark gradient above, which would otherwise
+ outrank the reset and paint the sweep as a solid bar behind the text. */
+ .dark .shimmer-text {
+ background-image: none;
+ }
}
@keyframes fadeInUp {
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index da87aea6..5813d503 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -1100,7 +1100,15 @@
"insert": "Wiki-Seite bearbeitet",
"delete": "Wiki-Seite gelöscht",
"rename": "Wiki-Seite umbenannt",
- "failed": "fehlgeschlagen"
+ "failed": "fehlgeschlagen",
+ "active": {
+ "edited": "Bearbeite Wiki…",
+ "create": "Erstelle Wiki-Seite…",
+ "str_replace": "Bearbeite Wiki-Seite…",
+ "insert": "Bearbeite Wiki-Seite…",
+ "delete": "Lösche Wiki-Seite…",
+ "rename": "Benenne Wiki-Seite um…"
+ }
},
"streamingStatus": {
"thinking": "Denke nach…",
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index e0f68681..474cf60c 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -1101,7 +1101,15 @@
"insert": "Edited wiki page",
"delete": "Deleted wiki page",
"rename": "Renamed wiki page",
- "failed": "failed"
+ "failed": "failed",
+ "active": {
+ "edited": "Editing wiki…",
+ "create": "Creating wiki page…",
+ "str_replace": "Editing wiki page…",
+ "insert": "Editing wiki page…",
+ "delete": "Deleting wiki page…",
+ "rename": "Renaming wiki page…"
+ }
},
"streamingStatus": {
"thinking": "Thinking…",
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index c9e3b33d..39a4485f 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -1100,7 +1100,15 @@
"insert": "Página de wiki editada",
"delete": "Página de wiki eliminada",
"rename": "Página de wiki renombrada",
- "failed": "falló"
+ "failed": "falló",
+ "active": {
+ "edited": "Editando wiki…",
+ "create": "Creando página de wiki…",
+ "str_replace": "Editando página de wiki…",
+ "insert": "Editando página de wiki…",
+ "delete": "Eliminando página de wiki…",
+ "rename": "Renombrando página de wiki…"
+ }
},
"streamingStatus": {
"thinking": "Pensando…",
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index cba64262..b43cdd23 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -1100,7 +1100,15 @@
"insert": "Wikiページを編集",
"delete": "Wikiページを削除",
"rename": "Wikiページの名前を変更",
- "failed": "失敗"
+ "failed": "失敗",
+ "active": {
+ "edited": "Wikiを編集中…",
+ "create": "Wikiページを作成中…",
+ "str_replace": "Wikiページを編集中…",
+ "insert": "Wikiページを編集中…",
+ "delete": "Wikiページを削除中…",
+ "rename": "Wikiページの名前を変更中…"
+ }
},
"streamingStatus": {
"thinking": "考え中…",
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index e63d312e..4a61e39c 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -1100,7 +1100,15 @@
"insert": "Отредактирована страница вики",
"delete": "Удалена страница вики",
"rename": "Переименована страница вики",
- "failed": "не удалось"
+ "failed": "не удалось",
+ "active": {
+ "edited": "Редактирую вики…",
+ "create": "Создаю страницу вики…",
+ "str_replace": "Редактирую страницу вики…",
+ "insert": "Редактирую страницу вики…",
+ "delete": "Удаляю страницу вики…",
+ "rename": "Переименовываю страницу вики…"
+ }
},
"streamingStatus": {
"thinking": "Думаю…",
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index 85d829bf..15d26f0c 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -1100,7 +1100,15 @@
"insert": "已編輯 Wiki 頁面",
"delete": "已刪除 Wiki 頁面",
"rename": "已重新命名 Wiki 頁面",
- "failed": "失敗"
+ "failed": "失敗",
+ "active": {
+ "edited": "正在編輯 Wiki…",
+ "create": "正在建立 Wiki 頁面…",
+ "str_replace": "正在編輯 Wiki 頁面…",
+ "insert": "正在編輯 Wiki 頁面…",
+ "delete": "正在刪除 Wiki 頁面…",
+ "rename": "正在重新命名 Wiki 頁面…"
+ }
},
"streamingStatus": {
"thinking": "思考中…",
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index b9be75d5..92a7a6fb 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -1100,7 +1100,15 @@
"insert": "已编辑 Wiki 页面",
"delete": "已删除 Wiki 页面",
"rename": "已重命名 Wiki 页面",
- "failed": "失败"
+ "failed": "失败",
+ "active": {
+ "edited": "正在编辑 Wiki…",
+ "create": "正在创建 Wiki 页面…",
+ "str_replace": "正在编辑 Wiki 页面…",
+ "insert": "正在编辑 Wiki 页面…",
+ "delete": "正在删除 Wiki 页面…",
+ "rename": "正在重命名 Wiki 页面…"
+ }
},
"streamingStatus": {
"thinking": "思考中…",
diff --git a/frontend/src/utils/streamingStatusUtils.ts b/frontend/src/utils/streamingStatusUtils.ts
index aad9e4bb..f1925447 100644
--- a/frontend/src/utils/streamingStatusUtils.ts
+++ b/frontend/src/utils/streamingStatusUtils.ts
@@ -91,12 +91,26 @@ function activityLabel(
);
}
+/**
+ * Whether the call has been handed off and not come back yet. A call the client
+ * still has to execute counts: it has no result either.
+ */
+export function isToolCallRunning(toolCall: {
+ status?: ToolCallsType['status'];
+}): boolean {
+ return (
+ toolCall.status === 'pending' ||
+ toolCall.status === 'requires_client_execution'
+ );
+}
+
export function getToolChipLabel(
toolCall: ToolCallsType,
t: TFunction,
): string {
const activity = describeToolCall(toolCall);
- const namespace =
- toolCall.status === 'pending' ? 'streamingStatus' : 'toolChip';
+ const namespace = isToolCallRunning(toolCall)
+ ? 'streamingStatus'
+ : 'toolChip';
return activityLabel(activity, namespace, t);
}