fix: reduce chat typing and cached conversation switching overhead
Keep unchanged history panes and task strips from rerendering during typing, and stabilize message callbacks across cached conversation switches. Preserve streaming updates, scroll positions, drafts, and asynchronous attachment ownership. Add App regressions for render isolation, warm switches, retry, and image recovery. Focused tests, typecheck, and lint passed; the full suite encountered unrelated settings failures. Release note: 减少任务运行时输入和切换已缓存长会话的重复渲染,保留实时输出、滚动位置和会话草稿。
This commit is contained in:
@@ -55,6 +55,34 @@ const speechRecognitionMocks = vi.hoisted(() => ({
|
||||
const messageRenderProbe = vi.hoisted(() => vi.fn());
|
||||
const assistantTasksProbe = vi.hoisted(() => vi.fn());
|
||||
const activityRenderProbe = vi.hoisted(() => vi.fn());
|
||||
const historyRenderProbes = vi.hoisted(() => ({
|
||||
pane: vi.fn(), task: vi.fn(), checklist: vi.fn(),
|
||||
instrument<T>(component: T, probe: () => void): T {
|
||||
const wrap = (render: (...args: never[]) => unknown) => new Proxy(render, {
|
||||
apply(target, receiver, args) {
|
||||
probe();
|
||||
return Reflect.apply(target, receiver, args);
|
||||
},
|
||||
});
|
||||
// Preserve the production memo boundary, and count calls inside it.
|
||||
return (typeof component === 'function'
|
||||
? wrap(component as (...args: never[]) => unknown)
|
||||
: { ...component, type: wrap((component as { type: (...args: never[]) => unknown }).type) }) as T;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./ChatHistoryPane', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./ChatHistoryPane')>();
|
||||
return { ...original, ChatHistoryPane: historyRenderProbes.instrument(original.ChatHistoryPane, historyRenderProbes.pane) };
|
||||
});
|
||||
vi.mock('./ConversationTaskStrip', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./ConversationTaskStrip')>();
|
||||
return { ...original, ConversationTaskStrip: historyRenderProbes.instrument(original.ConversationTaskStrip, historyRenderProbes.task) };
|
||||
});
|
||||
vi.mock('./RuntimeChecklistStrip', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('./RuntimeChecklistStrip')>();
|
||||
return { ...original, RuntimeChecklistStrip: historyRenderProbes.instrument(original.RuntimeChecklistStrip, historyRenderProbes.checklist) };
|
||||
});
|
||||
|
||||
vi.mock("./WorkspacePrimitives", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("./WorkspacePrimitives")>();
|
||||
@@ -4498,7 +4526,88 @@ describe("App", () => {
|
||||
expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps each conversation history window and reader position", async () => {
|
||||
it('retries into the active draft after warm conversation switches', async () => {
|
||||
const histories: ConversationSnapshot[] = ['First retry', 'Second retry'].map((title, index) => ({
|
||||
id: crypto.randomUUID(), projectId, title, updatedAt: 2 - index,
|
||||
messages: [
|
||||
{ id: crypto.randomUUID(), role: 'user', content: `${title} prompt`, createdAt: 1, state: 'complete' },
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: `${title} failed`, createdAt: 2, state: 'error' },
|
||||
],
|
||||
}));
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce(histories);
|
||||
render(<App />);
|
||||
await screen.findByText('First retry failed');
|
||||
fireEvent.click(screen.getByText('Second retry').closest('button')!);
|
||||
await screen.findByText('Second retry failed');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
|
||||
const composer = screen.getByLabelText('向 GoodBuddy 提问');
|
||||
expect(composer).toHaveValue('Second retry prompt');
|
||||
expect(composer).toHaveFocus();
|
||||
fireEvent.click(screen.getByText('First retry').closest('button')!);
|
||||
expect(composer).toHaveValue('');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新编辑' }));
|
||||
expect(composer).toHaveValue('First retry prompt');
|
||||
fireEvent.click(screen.getByText('Second retry').closest('button')!);
|
||||
expect(composer).toHaveValue('Second retry prompt');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("isolates history and task strips while typing during a running request", async () => {
|
||||
const conversationId = crypto.randomUUID();
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||
id: conversationId, projectId, title: 'Running render isolation', updatedAt: 2, messages: [],
|
||||
}]);
|
||||
vi.mocked(api.tasks.list).mockResolvedValue([{
|
||||
id: crypto.randomUUID(), conversationId, projectId, title: 'Running scheduled task',
|
||||
instructions: 'Keep task details live', origin: 'schedule', status: 'running',
|
||||
createdAt: '2026-09-30T00:00:00.000Z',
|
||||
}]);
|
||||
render(<App />);
|
||||
const taskStrip = await screen.findByRole('region', { name: '当前会话的任务' });
|
||||
fireEvent.click(within(taskStrip).getByRole('button'));
|
||||
expect(within(taskStrip).getByText('运行中')).toBeVisible();
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
fireEvent.change(composer, { target: { value: 'Running render isolation' } });
|
||||
await waitFor(() => expect(screen.getByLabelText('加入待发送队列')).toBeEnabled());
|
||||
fireEvent.click(screen.getByLabelText('加入待发送队列'));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
act(() => agentListener?.({ requestId, type: 'text', delta: 'Live response' }));
|
||||
await screen.findByText('Live response');
|
||||
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
|
||||
source: 'opencode', items: [{ content: 'Working while typing', status: 'in_progress' }],
|
||||
} }));
|
||||
await screen.findByText('Working while typing');
|
||||
expect(taskStrip).toBeVisible();
|
||||
historyRenderProbes.pane.mockClear();
|
||||
historyRenderProbes.task.mockClear();
|
||||
historyRenderProbes.checklist.mockClear();
|
||||
messageRenderProbe.mockClear();
|
||||
const storageReads = vi.spyOn(Storage.prototype, 'getItem');
|
||||
for (const value of ['a', 'ab', 'abc']) {
|
||||
fireEvent.change(composer, { target: { value } });
|
||||
}
|
||||
expect.soft(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
|
||||
expect.soft(historyRenderProbes.task).toHaveBeenCalledTimes(0);
|
||||
expect.soft(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
|
||||
expect.soft(messageRenderProbe).toHaveBeenCalledTimes(0);
|
||||
expect.soft(storageReads.mock.calls.filter(([key]) => key === 'goodbuddy.conversations.v1')).toHaveLength(0);
|
||||
storageReads.mockRestore();
|
||||
act(() => agentListener?.({ requestId, type: 'text', delta: ' continues' }));
|
||||
await screen.findByText('Live response continues');
|
||||
expect(historyRenderProbes.pane).toHaveBeenCalled();
|
||||
expect(historyRenderProbes.checklist).toHaveBeenCalled();
|
||||
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
|
||||
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
|
||||
source: 'opencode', items: [{ content: 'Checklist updated live', status: 'in_progress' }],
|
||||
} }));
|
||||
await screen.findByText('Checklist updated live');
|
||||
expect(composer).toHaveValue('abc');
|
||||
fireEvent.click(screen.getByLabelText('停止生成'));
|
||||
await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledWith(requestId));
|
||||
});
|
||||
|
||||
it("keeps each conversation history window and reader position on warm switches without rerendering messages", async () => {
|
||||
const firstConversationId = "00000000-0000-4000-8000-000000000461";
|
||||
const secondConversationId = "00000000-0000-4000-8000-000000000462";
|
||||
const draftAttachment = {
|
||||
@@ -4602,6 +4711,21 @@ describe("App", () => {
|
||||
"第一段会话草稿",
|
||||
);
|
||||
expect(screen.getByText(draftAttachment.name)).toBeInTheDocument();
|
||||
messageRenderProbe.mockClear();
|
||||
historyRenderProbes.task.mockClear();
|
||||
historyRenderProbes.checklist.mockClear();
|
||||
fireEvent.click(screen.getByText("第二段会话").closest("button")!);
|
||||
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第二段会话草稿"));
|
||||
fireEvent.click(screen.getByText("第一段长会话").closest("button")!);
|
||||
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("第一段会话草稿"));
|
||||
expect(messageRenderProbe).toHaveBeenCalledTimes(0);
|
||||
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
|
||||
expect(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
|
||||
expect(firstChat.scrollTop).toBe(225);
|
||||
expect(reasoningDetails).toHaveAttribute('open');
|
||||
historyRenderProbes.pane.mockClear();
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { target: { value: 'Warm cached draft' } });
|
||||
expect(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("requires an accessible confirmation before permanently deleting a conversation", async () => {
|
||||
@@ -11651,6 +11775,7 @@ describe("App", () => {
|
||||
|
||||
it('recovers failed image editing through settings and composer attachments without resubmitting the operation', async () => {
|
||||
const conversationId = crypto.randomUUID();
|
||||
const otherId = crypto.randomUUID();
|
||||
const operation: import('../../shared/image-generation-contracts').ImageOperation = {
|
||||
id: crypto.randomUUID(), conversationId, messageId: crypto.randomUUID(), requestId: crypto.randomUUID(), callId: 'failed-edit',
|
||||
modelProfileId: crypto.randomUUID(), modelName: 'original-image-model', modelProfileName: 'Original image connection',
|
||||
@@ -11659,14 +11784,23 @@ describe("App", () => {
|
||||
};
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{ id: conversationId, projectId, title: 'Failed image editing', updatedAt: 2, workMode: 'execute', messages: [
|
||||
{ id: operation.messageId, role: 'assistant', content: '', createdAt: 1, state: 'complete', imageOperations: [operation] },
|
||||
] }]);
|
||||
] }, { id: otherId, projectId, title: 'Other image draft', updatedAt: 1, messages: [] }]);
|
||||
const source = { id: crypto.randomUUID(), name: 'replacement.png', size: 64, preview: 'Replacement image', kind: 'image' as const, contentUrl: 'data:image/png;base64,iVBORw0KGgo=' };
|
||||
vi.mocked(api.context.selectFiles).mockResolvedValueOnce([source]);
|
||||
const selection = deferred<ContextAttachment[]>();
|
||||
vi.mocked(api.context.selectFiles).mockReturnValueOnce(selection.promise);
|
||||
render(<App />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重新选择素材' }));
|
||||
const composer = screen.getByLabelText('向 GoodBuddy 提问');
|
||||
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
|
||||
expect(composer).toHaveFocus();
|
||||
await waitFor(() => expect(api.context.selectFiles).toHaveBeenCalledWith(conversationId));
|
||||
fireEvent.click(screen.getByText('Other image draft').closest('button')!);
|
||||
fireEvent.change(composer, { target: { value: 'Keep other image draft' } });
|
||||
await act(async () => selection.resolve([source]));
|
||||
expect(composer).toHaveValue('Keep other image draft');
|
||||
expect(within(composer.closest<HTMLElement>('.composer')!).queryByText('replacement.png')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Failed image editing').closest('button')!);
|
||||
expect(composer).toHaveValue('使用图片模型“Original image connection”:Change the background');
|
||||
expect(await within(composer.closest<HTMLElement>('.composer')!).findByText('replacement.png')).toBeVisible();
|
||||
expect(api.context.selectFiles).toHaveBeenCalledOnce();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
|
||||
+56
-64
@@ -575,6 +575,7 @@ const emptyTokenUsage: TokenUsageSummary = {
|
||||
};
|
||||
|
||||
const storageKey = "goodbuddy.conversations.v1";
|
||||
const emptyConversationTasks: AssistantTask[] = [];
|
||||
|
||||
const activeProjectStorageKey = "goodbuddy.active-project.v1";
|
||||
|
||||
@@ -1657,9 +1658,8 @@ function App(): React.JSX.Element {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
const locale = i18n.resolvedLanguage === "en-US" ? "en-US" : "zh-CN";
|
||||
const conversationMigrationStoragePresent = useRef(
|
||||
hasConversationMigrationStorage(),
|
||||
);
|
||||
const [initialConversationMigrationStoragePresent] = useState(hasConversationMigrationStorage);
|
||||
const conversationMigrationStoragePresent = useRef(initialConversationMigrationStoragePresent);
|
||||
const [conversations, setConversations] = useState(() =>
|
||||
loadConversations(
|
||||
t("conversation.greeting"),
|
||||
@@ -1773,10 +1773,10 @@ function App(): React.JSX.Element {
|
||||
Record<string, string>
|
||||
>({});
|
||||
const input = conversationDrafts[activeId] ?? "";
|
||||
const setInput = useCallback(
|
||||
(update: SetStateAction<string>): void => {
|
||||
const setConversationInput = useCallback(
|
||||
(conversationId: string, update: SetStateAction<string>): void => {
|
||||
setConversationDrafts((current) => {
|
||||
const currentValue = current[activeId] ?? "";
|
||||
const currentValue = current[conversationId] ?? "";
|
||||
const nextValue =
|
||||
typeof update === "function" ? update(currentValue) : update;
|
||||
if (nextValue === currentValue) {
|
||||
@@ -1784,13 +1784,18 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
if (!nextValue) {
|
||||
const next = { ...current };
|
||||
delete next[activeId];
|
||||
delete next[conversationId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [activeId]: nextValue };
|
||||
return { ...current, [conversationId]: nextValue };
|
||||
});
|
||||
},
|
||||
[activeId],
|
||||
[],
|
||||
);
|
||||
// Async composer operations retain the conversation that started them.
|
||||
const setInput = useCallback(
|
||||
(update: SetStateAction<string>): void => setConversationInput(activeId, update),
|
||||
[activeId, setConversationInput],
|
||||
);
|
||||
const [voiceListening, setVoiceListening] = useState(false);
|
||||
const [voiceRecording, setVoiceRecording] = useState(false);
|
||||
@@ -2449,11 +2454,15 @@ function App(): React.JSX.Element {
|
||||
>({});
|
||||
const retryMessage = useCallback(
|
||||
(content: string): void => {
|
||||
setInput(content);
|
||||
setConversationInput(activeConversationIdRef.current, content);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[setInput],
|
||||
[setConversationInput],
|
||||
);
|
||||
const setQuickActionInput = useCallback((value: string): void => {
|
||||
setConversationInput(activeConversationIdRef.current, value);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, [setConversationInput]);
|
||||
useEffect(
|
||||
() =>
|
||||
scheduleIdleRoutePreload(
|
||||
@@ -7690,14 +7699,14 @@ function App(): React.JSX.Element {
|
||||
}, [setView]);
|
||||
|
||||
const reselectImageSources = useCallback((operation: ImageOperation): void => {
|
||||
if (operation.conversationId !== activeId) return;
|
||||
setInput(current => current || t("chat.images.recoveryPrompt", {
|
||||
if (operation.conversationId !== activeConversationIdRef.current) return;
|
||||
setConversationInput(operation.conversationId, current => current || t("chat.images.recoveryPrompt", {
|
||||
model: operation.modelProfileName ?? operation.modelName,
|
||||
prompt: operation.input.prompt,
|
||||
}));
|
||||
inputRef.current?.focus();
|
||||
attachmentButtonRef.current?.click();
|
||||
}, [activeId, setInput, t]);
|
||||
}, [setConversationInput, t]);
|
||||
|
||||
const selectContextFiles = async (paths?: string[]): Promise<void> => {
|
||||
if (selectingContextFilesRef.current) {
|
||||
@@ -8206,16 +8215,16 @@ function App(): React.JSX.Element {
|
||||
return schedule;
|
||||
};
|
||||
|
||||
const runAssistantSchedule = async (scheduleId: string): Promise<void> => {
|
||||
const runAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
|
||||
await window.goodbuddy.schedules.runNow(scheduleId);
|
||||
setAssistantTasks(await window.goodbuddy.tasks.list());
|
||||
notify({
|
||||
tone: "success",
|
||||
message: t("notices.scheduleStarted"),
|
||||
});
|
||||
};
|
||||
}, [notify, t]);
|
||||
|
||||
const setAssistantScheduleEnabled = async (
|
||||
const setAssistantScheduleEnabled = useCallback(async (
|
||||
scheduleId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> => {
|
||||
@@ -8225,14 +8234,39 @@ function App(): React.JSX.Element {
|
||||
schedule.id === scheduleId ? { ...schedule, enabled } : schedule,
|
||||
),
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeAssistantSchedule = async (scheduleId: string): Promise<void> => {
|
||||
const removeAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
|
||||
await window.goodbuddy.schedules.remove(scheduleId);
|
||||
setAssistantSchedules((current) =>
|
||||
current.filter((schedule) => schedule.id !== scheduleId),
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const conversationTaskStrips = useMemo(() => new Map(cachedConversations.map(conversation => {
|
||||
const tasks = tasksByConversation.get(conversation.id) ?? emptyConversationTasks;
|
||||
return [conversation.id, (
|
||||
<div className="conversation-context-strips">
|
||||
{!conversation.remote && (
|
||||
<ConversationTaskStrip
|
||||
conversationMode={conversation.id === activeId
|
||||
? effectiveWorkMode
|
||||
: normalizeInteractiveWorkMode(conversation.workMode ?? projects.find(project => project.id === conversation.projectId)?.defaultWorkMode)}
|
||||
locale={locale}
|
||||
onRemoveSchedule={removeAssistantSchedule}
|
||||
onRunSchedule={runAssistantSchedule}
|
||||
onSelectTask={setSelectedAssistantTaskId}
|
||||
onSetScheduleEnabled={setAssistantScheduleEnabled}
|
||||
schedules={assistantSchedules}
|
||||
selectedTaskId={tasks.some(task => task.id === selectedAssistantTaskId) ? selectedAssistantTaskId : undefined}
|
||||
tasks={tasks}
|
||||
/>
|
||||
)}
|
||||
<RuntimeChecklistStrip messages={conversation.messages} activeMessageId={conversation.activeRequest?.messageId} />
|
||||
</div>
|
||||
)];
|
||||
})), [cachedConversations, tasksByConversation, activeId, effectiveWorkMode, projects, locale,
|
||||
removeAssistantSchedule, runAssistantSchedule, setAssistantScheduleEnabled, assistantSchedules, selectedAssistantTaskId]);
|
||||
|
||||
const clearLocalData = async (): Promise<void> => {
|
||||
conversationPersistencePausedRef.current = true;
|
||||
@@ -9504,55 +9538,13 @@ function App(): React.JSX.Element {
|
||||
onRespondQuestion={respondToQuestion}
|
||||
onRetry={retryMessage}
|
||||
onScrollSnapshotChange={handleChatScrollSnapshotChange}
|
||||
onSetInput={(value) => {
|
||||
setInput(value);
|
||||
requestAnimationFrame(() =>
|
||||
inputRef.current?.focus(),
|
||||
);
|
||||
}}
|
||||
onSetInput={setQuickActionInput}
|
||||
onVisibleMessageCountChange={
|
||||
handleVisibleMessageCountChange
|
||||
}
|
||||
quickActions={quickActions}
|
||||
scrollSnapshot={chatScrollSnapshots[conversation.id]}
|
||||
taskStrip={
|
||||
<div className="conversation-context-strips">
|
||||
{!conversation.remote ? (
|
||||
<ConversationTaskStrip
|
||||
conversationMode={
|
||||
conversation.id === activeId
|
||||
? effectiveWorkMode
|
||||
: normalizeInteractiveWorkMode(
|
||||
conversation.workMode ??
|
||||
projects.find((project) => project.id === conversation.projectId)?.defaultWorkMode
|
||||
)
|
||||
}
|
||||
locale={locale}
|
||||
onRemoveSchedule={removeAssistantSchedule}
|
||||
onRunSchedule={runAssistantSchedule}
|
||||
onSelectTask={setSelectedAssistantTaskId}
|
||||
onSetScheduleEnabled={setAssistantScheduleEnabled}
|
||||
schedules={assistantSchedules}
|
||||
selectedTaskId={
|
||||
(
|
||||
tasksByConversation.get(conversation.id) ?? []
|
||||
).some(
|
||||
(task) => task.id === selectedAssistantTaskId,
|
||||
)
|
||||
? selectedAssistantTaskId
|
||||
: undefined
|
||||
}
|
||||
tasks={
|
||||
tasksByConversation.get(conversation.id) ?? []
|
||||
}
|
||||
/>
|
||||
) : undefined}
|
||||
<RuntimeChecklistStrip
|
||||
messages={conversation.messages}
|
||||
activeMessageId={conversation.activeRequest?.messageId}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
taskStrip={conversationTaskStrips.get(conversation.id)}
|
||||
visibleMessageCount={
|
||||
visibleMessageCounts[conversation.id] ??
|
||||
messageRenderBatchSize
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ArrowDown, FileText, Sparkles } from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -34,7 +35,7 @@ export type ChatScrollSnapshot = {
|
||||
scrollTop: number;
|
||||
};
|
||||
|
||||
export function ChatHistoryPane({
|
||||
export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
onAddToNote,
|
||||
noteMessageNavigation,
|
||||
onOpenImageModelSettings,
|
||||
@@ -405,4 +406,4 @@ export function ChatHistoryPane({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CircleAlert,
|
||||
ClockFading
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AssistantSchedule,
|
||||
@@ -31,7 +31,7 @@ type ConversationTaskStripProps = {
|
||||
tasks: AssistantTask[]
|
||||
}
|
||||
|
||||
export function ConversationTaskStrip({
|
||||
export const ConversationTaskStrip = memo(function ConversationTaskStrip({
|
||||
conversationMode,
|
||||
locale,
|
||||
onRemoveSchedule,
|
||||
@@ -213,4 +213,4 @@ export function ConversationTaskStrip({
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { memo, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Message } from './ChatTimeline'
|
||||
import { isCancelledMessage } from './message-terminal-status'
|
||||
@@ -13,7 +13,7 @@ const statusIcons = {
|
||||
cancelled: XCircle
|
||||
}
|
||||
|
||||
export function RuntimeChecklistStrip({ messages, activeMessageId }: {
|
||||
export const RuntimeChecklistStrip = memo(function RuntimeChecklistStrip({ messages, activeMessageId }: {
|
||||
messages: readonly Message[]
|
||||
activeMessageId?: string
|
||||
}): React.JSX.Element | null {
|
||||
@@ -26,7 +26,7 @@ export function RuntimeChecklistStrip({ messages, activeMessageId }: {
|
||||
: messages.at(-1)
|
||||
if (message?.role !== 'assistant' || !message.runtimeChecklist?.items.length) return null
|
||||
return <Checklist key={message.id} message={message} />
|
||||
}
|
||||
})
|
||||
|
||||
function Checklist({ message }: { message: Message }): React.JSX.Element {
|
||||
const { t } = useTranslation('app')
|
||||
|
||||
Reference in New Issue
Block a user