feat: suggest stalled stories and reusable experiences, and let agents read stories through MCP
Also includes pending workspace changes: unsaved-change guard, keep-alive cache and live message store, knowledge workspace dialogs and perf driver updates.
This commit is contained in:
@@ -3149,6 +3149,25 @@ describe("App", () => {
|
||||
expect(document.querySelector('[data-route="settings"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the chat workspace resident while other workspaces rotate", async () => {
|
||||
await api.updates!.updateSettings({ magicNotesEnabled: true });
|
||||
let now = 3_000_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => ++now);
|
||||
render(<App />);
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
const chatRoute = composer.closest('[data-route="chat"]');
|
||||
expect(chatRoute).not.toBeNull();
|
||||
|
||||
for (const label of ["魔法笔记", "知识库", "监督者", "运行记录"]) {
|
||||
fireEvent.click(await screen.findByRole("button", { name: label }));
|
||||
await screen.findByRole("heading", { name: label });
|
||||
}
|
||||
|
||||
expect(document.querySelector('[data-route="chat"]')).toBe(chatRoute);
|
||||
expect(document.querySelector('[data-route="magic-notes"]')).toBeNull();
|
||||
expect(document.querySelectorAll(".workspace-route-cache")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("preserves title, message, and project filtering with deferred search", async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||
{
|
||||
@@ -4527,12 +4546,14 @@ describe("App", () => {
|
||||
});
|
||||
}
|
||||
expect(await screen.findByText(/第三段/u)).toBeInTheDocument();
|
||||
// Deltas render live; App state absorbs them on the next flush, which
|
||||
// follows the bottom once.
|
||||
await waitFor(() => expect(scrollTo).toHaveBeenCalled(), { timeout: 2_000 });
|
||||
await act(
|
||||
() =>
|
||||
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
expect(scrollTo.mock.calls.length).toBeLessThanOrEqual(2);
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({ top: 1_500, behavior: "auto" });
|
||||
});
|
||||
@@ -4735,9 +4756,10 @@ describe("App", () => {
|
||||
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();
|
||||
// Streaming text re-renders only the message row, not the history pane.
|
||||
expect(screen.getByText('Live response continues')).toBeInTheDocument();
|
||||
expect(historyRenderProbes.pane).toHaveBeenCalledTimes(0);
|
||||
expect(historyRenderProbes.checklist).toHaveBeenCalledTimes(0);
|
||||
expect(historyRenderProbes.task).toHaveBeenCalledTimes(0);
|
||||
act(() => agentListener?.({ requestId, type: 'checklist', checklist: {
|
||||
source: 'opencode', items: [{ content: 'Checklist updated live', status: 'in_progress' }],
|
||||
@@ -14975,7 +14997,7 @@ describe("App", () => {
|
||||
fireEvent.change(screen.getByLabelText(/每批消息数/), { target: { value: '30' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
|
||||
await waitFor(() => expect(api.updates!.updateSettings).toHaveBeenLastCalledWith({
|
||||
supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 1024 }
|
||||
supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 1024, crossProject: false, storyThreadEvents: 20, experienceMinEvents: 5, stalledDays: 14 }
|
||||
}))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存回顾算法' })).toBeDisabled())
|
||||
fireEvent.click(screen.getByRole('tab', { name: '工作回顾' }))
|
||||
|
||||
+177
-106
@@ -76,6 +76,7 @@ import {
|
||||
import {
|
||||
Component,
|
||||
memo,
|
||||
startTransition,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
@@ -187,6 +188,17 @@ import {
|
||||
type ChatScrollSnapshot,
|
||||
} from "./ChatHistoryPane";
|
||||
import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation";
|
||||
import {
|
||||
ComposerDraftEffect,
|
||||
ComposerDraftHasText,
|
||||
ComposerDraftText,
|
||||
createComposerDraftStore,
|
||||
} from "./composer-draft-store";
|
||||
import {
|
||||
createLiveMessageStore,
|
||||
LiveMessageStoreContext,
|
||||
type ConversationsUpdate,
|
||||
} from "./live-message-store";
|
||||
import {
|
||||
clearLegacyActivityHistory,
|
||||
loadLegacyActivityHistory,
|
||||
@@ -272,6 +284,10 @@ import {
|
||||
touchAndPruneKeepAliveEntries,
|
||||
type KeepAliveCacheEntry,
|
||||
} from "./keep-alive-cache";
|
||||
import {
|
||||
WorkspaceUnsavedChangesContext,
|
||||
type ReportWorkspaceUnsavedChanges,
|
||||
} from "./workspace-unsaved-changes";
|
||||
import { activateModalFocus, trapTabFocus } from "./dialog-focus";
|
||||
import { FloatingPortal } from "./FloatingPortal";
|
||||
import {
|
||||
@@ -310,13 +326,18 @@ const SettingsPanel = settingsPanelRoute.Component;
|
||||
const ActivityPanel = activityPanelRoute.Component;
|
||||
|
||||
const conversationPersistenceIntervalMs = 500;
|
||||
// How often streaming deltas are absorbed into App state; rows render them live.
|
||||
const liveMessageFlushIntervalMs = 250;
|
||||
const conversationSearchSnapshotDelayMs = 250;
|
||||
const keepAliveExpirationMs = 60 * 60 * 1_000;
|
||||
const keepAliveSweepIntervalMs = 5 * 60 * 1_000;
|
||||
const maximumCachedConversations = 12;
|
||||
const recentCachedConversations = 5;
|
||||
const maximumCachedWorkspaceViews = 4;
|
||||
const recentCachedWorkspaceViews = 3;
|
||||
// Chat is the primary route and stays resident outside the capacity below,
|
||||
// together with any route that reports unsaved edits.
|
||||
const pinnedWorkspaceViews = ["chat"] as const;
|
||||
const maximumCachedWorkspaceViews = 3;
|
||||
const recentCachedWorkspaceViews = 2;
|
||||
|
||||
function sameConversationQueueItems(
|
||||
current: ConversationQueueItem[],
|
||||
@@ -404,10 +425,12 @@ function RouteLoadingStatus({ label }: { label: string }): React.JSX.Element {
|
||||
function KeepAliveRoute({
|
||||
active,
|
||||
children,
|
||||
onUnsavedChanges,
|
||||
route,
|
||||
}: {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
onUnsavedChanges?: ReportWorkspaceUnsavedChanges;
|
||||
route: string;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
@@ -418,7 +441,9 @@ function KeepAliveRoute({
|
||||
hidden={!active}
|
||||
inert={!active}
|
||||
>
|
||||
{children}
|
||||
<WorkspaceUnsavedChangesContext.Provider value={onUnsavedChanges}>
|
||||
{children}
|
||||
</WorkspaceUnsavedChangesContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -552,6 +577,37 @@ type ActiveRun = {
|
||||
type WorkspaceView =
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings";
|
||||
|
||||
// Tracks which kept-alive routes currently report unsaved edits. Reads happen
|
||||
// only in event handlers and the sweep timer, so this is not React state.
|
||||
function createUnsavedWorkspaceTracker(): {
|
||||
pinnedViews: () => Set<WorkspaceView>;
|
||||
reporters: Record<
|
||||
"knowledge" | "heartbeat" | "magic-notes" | "activity",
|
||||
ReportWorkspaceUnsavedChanges
|
||||
>;
|
||||
} {
|
||||
const sources = new Map<WorkspaceView, Set<string>>();
|
||||
const reporterFor =
|
||||
(route: WorkspaceView): ReportWorkspaceUnsavedChanges =>
|
||||
(sourceId, dirty) => {
|
||||
const routeSources = sources.get(route) ?? new Set<string>();
|
||||
if (dirty) routeSources.add(sourceId);
|
||||
else routeSources.delete(sourceId);
|
||||
if (routeSources.size > 0) sources.set(route, routeSources);
|
||||
else sources.delete(route);
|
||||
};
|
||||
return {
|
||||
pinnedViews: () =>
|
||||
new Set<WorkspaceView>([...pinnedWorkspaceViews, ...sources.keys()]),
|
||||
reporters: {
|
||||
knowledge: reporterFor("knowledge"),
|
||||
heartbeat: reporterFor("heartbeat"),
|
||||
"magic-notes": reporterFor("magic-notes"),
|
||||
activity: reporterFor("activity"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const intentRoutePreloaders: Partial<
|
||||
Record<WorkspaceView, () => Promise<unknown>>
|
||||
> = {
|
||||
@@ -664,31 +720,6 @@ function loadPrimarySidebarWidth(): number {
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessageContentBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
type: "text" | "reasoning",
|
||||
delta: string,
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
if (!blocks || !delta) {
|
||||
return blocks;
|
||||
}
|
||||
const current = [...blocks];
|
||||
const previous = current.at(-1);
|
||||
if (previous?.type === type) {
|
||||
current[current.length - 1] = {
|
||||
...previous,
|
||||
content: `${previous.content}${delta}`,
|
||||
};
|
||||
return current;
|
||||
}
|
||||
current.push({
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
content: delta,
|
||||
});
|
||||
return current;
|
||||
}
|
||||
|
||||
function upsertMessageToolBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
tool: ToolActivity,
|
||||
@@ -2159,12 +2190,39 @@ function App(): React.JSX.Element {
|
||||
const locale = i18n.resolvedLanguage === "en-US" ? "en-US" : "zh-CN";
|
||||
const [initialConversationMigrationStoragePresent] = useState(hasConversationMigrationStorage);
|
||||
const conversationMigrationStoragePresent = useRef(initialConversationMigrationStoragePresent);
|
||||
const [conversations, setConversations] = useState(() =>
|
||||
const [conversations, setConversationsState] = useState(() =>
|
||||
loadConversations(
|
||||
t("conversation.greeting"),
|
||||
t("conversation.interrupted"),
|
||||
),
|
||||
);
|
||||
// Streaming deltas render through this store; App state absorbs them on a
|
||||
// slow cadence or with the next conversations update (see live-message-store).
|
||||
const [{ liveMessages, setConversations, cancelLiveMessageFlush }] = useState(() => {
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const update = (next: ConversationsUpdate): void => {
|
||||
const captured = store.capture();
|
||||
setConversationsState((current) => store.applyUpdate(current, next, captured));
|
||||
};
|
||||
const store = createLiveMessageStore({
|
||||
onPending: () => {
|
||||
if (flushTimer !== undefined) return;
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = undefined;
|
||||
if (!store.hasEntries()) return;
|
||||
startTransition(() => update((current) => current));
|
||||
}, liveMessageFlushIntervalMs);
|
||||
},
|
||||
});
|
||||
return {
|
||||
liveMessages: store,
|
||||
setConversations: update,
|
||||
cancelLiveMessageFlush: () => {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = undefined;
|
||||
},
|
||||
};
|
||||
});
|
||||
const [activeId, setActiveIdState] = useState(
|
||||
() => conversations[0]?.id ?? "",
|
||||
);
|
||||
@@ -2268,29 +2326,16 @@ function App(): React.JSX.Element {
|
||||
>(undefined);
|
||||
const viewRef = useRef<WorkspaceView>("chat");
|
||||
const heartbeatLoadRequestRef = useRef(0);
|
||||
const [conversationDrafts, setConversationDrafts] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const input = conversationDrafts[activeId] ?? "";
|
||||
// Drafts live outside App state: typing re-renders only the composer input.
|
||||
const [composerDrafts] = useState(createComposerDraftStore);
|
||||
const setConversationInput = useCallback(
|
||||
(conversationId: string, update: SetStateAction<string>): void => {
|
||||
setConversationDrafts((current) => {
|
||||
const currentValue = current[conversationId] ?? "";
|
||||
const nextValue =
|
||||
typeof update === "function" ? update(currentValue) : update;
|
||||
if (nextValue === currentValue) {
|
||||
return current;
|
||||
}
|
||||
if (!nextValue) {
|
||||
const next = { ...current };
|
||||
delete next[conversationId];
|
||||
return next;
|
||||
}
|
||||
return { ...current, [conversationId]: nextValue };
|
||||
});
|
||||
},
|
||||
[],
|
||||
(conversationId: string, update: SetStateAction<string>): void =>
|
||||
composerDrafts.set(conversationId, update),
|
||||
[composerDrafts],
|
||||
);
|
||||
const resizeActiveComposer = useCallback((): void => {
|
||||
resizeComposerTextarea(inputRef.current);
|
||||
}, []);
|
||||
// Async composer operations retain the conversation that started them.
|
||||
const setInput = useCallback(
|
||||
(update: SetStateAction<string>): void => setConversationInput(activeId, update),
|
||||
@@ -2535,6 +2580,10 @@ function App(): React.JSX.Element {
|
||||
const [cachedWorkspaceViews, setCachedWorkspaceViews] = useState<
|
||||
KeepAliveCacheEntry<WorkspaceView>[]
|
||||
>(() => [{ key: "chat", lastVisitedAt: Date.now() }]);
|
||||
// Routes with unsaved edits are pinned so eviction never drops them silently.
|
||||
const [unsavedWorkspaceTracker] = useState(createUnsavedWorkspaceTracker);
|
||||
const workspaceUnsavedChangesReporters = unsavedWorkspaceTracker.reporters;
|
||||
const getPinnedWorkspaceViews = unsavedWorkspaceTracker.pinnedViews;
|
||||
const [cachedConversationViews, setCachedConversationViews] = useState<
|
||||
KeepAliveCacheEntry<string>[]
|
||||
>(() => (activeId ? [{ key: activeId, lastVisitedAt: Date.now() }] : []));
|
||||
@@ -2575,10 +2624,12 @@ function App(): React.JSX.Element {
|
||||
protectedWorkspaceViews.add("activity");
|
||||
}
|
||||
viewRef.current = next;
|
||||
const pinnedViews = getPinnedWorkspaceViews();
|
||||
setCachedWorkspaceViews((current) =>
|
||||
touchAndPruneKeepAliveEntries(current, next, now, {
|
||||
expiresAfterMs: keepAliveExpirationMs,
|
||||
maximumEntries: maximumCachedWorkspaceViews,
|
||||
pinnedKeys: pinnedViews,
|
||||
protectedKeys: protectedWorkspaceViews,
|
||||
recentEntries: recentCachedWorkspaceViews,
|
||||
}),
|
||||
@@ -2602,7 +2653,7 @@ function App(): React.JSX.Element {
|
||||
target?.focus();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [getPinnedWorkspaceViews]);
|
||||
const requestWorkspaceLeave = useCallback((next: WorkspaceView, leave: () => void): void => {
|
||||
const leaveNotes = (): void => {
|
||||
if (viewRef.current === "magic-notes" && next !== "magic-notes" && notesLeaveRequesterRef.current) {
|
||||
@@ -3215,12 +3266,14 @@ function App(): React.JSX.Element {
|
||||
},
|
||||
),
|
||||
);
|
||||
const pinnedViews = getPinnedWorkspaceViews();
|
||||
setCachedWorkspaceViews((current) =>
|
||||
pruneKeepAliveEntries(current, {
|
||||
currentKey: view,
|
||||
expiresAfterMs: keepAliveExpirationMs,
|
||||
maximumEntries: maximumCachedWorkspaceViews,
|
||||
now,
|
||||
pinnedKeys: pinnedViews,
|
||||
protectedKeys: protectedWorkspaceViews,
|
||||
recentEntries: recentCachedWorkspaceViews,
|
||||
}),
|
||||
@@ -3228,7 +3281,7 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
const interval = window.setInterval(sweep, keepAliveSweepIntervalMs);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [activeId, assistantTasks, knowledgeOperationCount, view]);
|
||||
}, [activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view]);
|
||||
|
||||
useEffect(() => {
|
||||
livePrimarySidebarWidthRef.current = primarySidebarWidth;
|
||||
@@ -3349,7 +3402,10 @@ function App(): React.JSX.Element {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
conversationsRef.current = conversations;
|
||||
}, [conversations]);
|
||||
liveMessages.prune(conversations);
|
||||
}, [conversations, liveMessages]);
|
||||
|
||||
useEffect(() => cancelLiveMessageFlush, [cancelLiveMessageFlush]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
@@ -3396,10 +3452,6 @@ function App(): React.JSX.Element {
|
||||
assistantTasksRef.current = assistantTasks;
|
||||
}, [assistantTasks]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
resizeComposerTextarea(inputRef.current);
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
saveAppearanceTheme(appearanceTheme);
|
||||
}, [appearanceTheme]);
|
||||
@@ -3597,7 +3649,7 @@ function App(): React.JSX.Element {
|
||||
}),
|
||||
);
|
||||
},
|
||||
[activeId],
|
||||
[activeId, setConversations],
|
||||
);
|
||||
const activeProject = useMemo(
|
||||
() => projects.find((project) => project.id === activeProjectId),
|
||||
@@ -4119,7 +4171,7 @@ function App(): React.JSX.Element {
|
||||
else { setActiveId(conversation.id); showChatAndFocusComposer(); }
|
||||
return true;
|
||||
},
|
||||
[notify, projects, setActiveId, setView, showChatAndFocusComposer],
|
||||
[notify, projects, setActiveId, setView, showChatAndFocusComposer, setConversations],
|
||||
);
|
||||
const activeProjectDisplayName = activeProject
|
||||
? getProjectDisplayText(activeProject, tWorkspace).name
|
||||
@@ -4305,7 +4357,7 @@ function App(): React.JSX.Element {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
[setConversations],
|
||||
);
|
||||
|
||||
const recordActivity = useCallback(
|
||||
@@ -4446,7 +4498,7 @@ function App(): React.JSX.Element {
|
||||
throw reason;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[setConversations],
|
||||
);
|
||||
|
||||
const retryKnowledgeLoad = useCallback(async (): Promise<void> => {
|
||||
@@ -4554,6 +4606,7 @@ function App(): React.JSX.Element {
|
||||
activeProject,
|
||||
runtimeSettings,
|
||||
runtimeSwitching,
|
||||
setConversations,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4809,35 +4862,16 @@ function App(): React.JSX.Element {
|
||||
? { ...message, runtimeChecklist: event.checklist }
|
||||
: message,
|
||||
);
|
||||
} else if (event.type === "text") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const blocks = appendMessageContentBlock(
|
||||
message.blocks,
|
||||
"text",
|
||||
event.delta,
|
||||
);
|
||||
return {
|
||||
...message,
|
||||
content: `${message.content}${event.delta}`,
|
||||
blocks,
|
||||
status: undefined,
|
||||
};
|
||||
});
|
||||
} else if (event.type === "reasoning") {
|
||||
updateMessage(run.conversationId, run.messageId, (message) => {
|
||||
const currentReasoning = message.reasoning ?? "";
|
||||
const blocks = appendMessageContentBlock(
|
||||
message.blocks,
|
||||
"reasoning",
|
||||
event.delta,
|
||||
);
|
||||
return {
|
||||
...message,
|
||||
reasoning: `${currentReasoning}${event.delta}`,
|
||||
status: undefined,
|
||||
blocks,
|
||||
};
|
||||
});
|
||||
} else if (event.type === "text" || event.type === "reasoning") {
|
||||
// Rendered by the message row from the live store; App state absorbs
|
||||
// the deltas later (see live-message-store.ts).
|
||||
liveMessages.append(
|
||||
run.conversationId,
|
||||
run.messageId,
|
||||
event.type,
|
||||
event.delta,
|
||||
liveMessage,
|
||||
);
|
||||
} else if (event.type === "context-metrics") {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
@@ -5357,6 +5391,8 @@ function App(): React.JSX.Element {
|
||||
updateMessage,
|
||||
updateRequestActivity,
|
||||
markConversationCompleted,
|
||||
liveMessages,
|
||||
setConversations,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5405,7 +5441,7 @@ function App(): React.JSX.Element {
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [retainedConversationDetailIds]);
|
||||
}, [retainedConversationDetailIds, setConversations]);
|
||||
|
||||
const persistLocalConversationChanges = useCallback((): void => {
|
||||
const operation = conversationPersistenceQueueRef.current.then(async () => {
|
||||
@@ -5465,7 +5501,7 @@ function App(): React.JSX.Element {
|
||||
}).finally(() => { conversationHistoryRequests.current.delete(conversationId); });
|
||||
conversationHistoryRequests.current.set(conversationId, request);
|
||||
return request;
|
||||
}, [retainedConversationDetailIds]);
|
||||
}, [retainedConversationDetailIds, setConversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationStoreReady) return;
|
||||
@@ -5741,6 +5777,7 @@ function App(): React.JSX.Element {
|
||||
retainedConversationDetailIds,
|
||||
setConversationActivity,
|
||||
markConversationCompleted,
|
||||
setConversations,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -6203,7 +6240,7 @@ function App(): React.JSX.Element {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [conversationLoadRetry, setActiveId]);
|
||||
}, [conversationLoadRetry, setActiveId, setConversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
@@ -6691,7 +6728,7 @@ function App(): React.JSX.Element {
|
||||
removeImageListener();
|
||||
removeOpenSettingsListener();
|
||||
};
|
||||
}, [handleAgentEvent, setView]);
|
||||
}, [handleAgentEvent, setView, setConversations]);
|
||||
|
||||
useEffect(() => {
|
||||
const browserApi = window.goodbuddy.browser;
|
||||
@@ -7148,11 +7185,7 @@ function App(): React.JSX.Element {
|
||||
delete next[conversationId];
|
||||
return next;
|
||||
});
|
||||
setConversationDrafts((current) => {
|
||||
const next = { ...current };
|
||||
delete next[conversationId];
|
||||
return next;
|
||||
});
|
||||
composerDrafts.delete(conversationId);
|
||||
setChatScrollSnapshots((current) => {
|
||||
const next = { ...current };
|
||||
delete next[conversationId];
|
||||
@@ -7578,7 +7611,9 @@ function App(): React.JSX.Element {
|
||||
(candidate) => candidate.id === selectedRuntimeCommand,
|
||||
)
|
||||
: undefined;
|
||||
const commandArguments = queuedInput ? "" : (promptOverride ?? input).trim();
|
||||
const commandArguments = queuedInput
|
||||
? ""
|
||||
: (promptOverride ?? composerDrafts.get(activeId)).trim();
|
||||
const prompt =
|
||||
queuedInput?.prompt ??
|
||||
(command
|
||||
@@ -8641,7 +8676,7 @@ function App(): React.JSX.Element {
|
||||
if (narrowWindow) closeNarrowSidebar();
|
||||
};
|
||||
requestWorkspaceLeave("chat", () => { void open(); });
|
||||
}, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t]);
|
||||
}, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t, setConversations]);
|
||||
|
||||
const openAssistantTask = (task: AssistantTask): void => {
|
||||
if (!task.conversationId) {
|
||||
@@ -9230,6 +9265,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
|
||||
<LiveMessageStoreContext value={liveMessages}>
|
||||
<DocumentConversationContext value={{
|
||||
activeId: activeConversation?.remote ? undefined : activeId,
|
||||
create: () => new Promise((resolve, reject) => {
|
||||
@@ -10007,6 +10043,12 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
<div className="composer__input">
|
||||
<ComposerDraftText
|
||||
conversationId={activeId}
|
||||
store={composerDrafts}
|
||||
>
|
||||
{(draft) => (
|
||||
<>
|
||||
<textarea
|
||||
aria-describedby={
|
||||
contextError
|
||||
@@ -10023,7 +10065,7 @@ function App(): React.JSX.Element {
|
||||
ref={inputRef}
|
||||
rows={3}
|
||||
title={`${composerKeyboardHint}\n${composerConversationHint}`}
|
||||
value={input}
|
||||
value={draft}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onPaste={(event) => {
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
@@ -10096,6 +10138,14 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* After the textarea, so its ref is attached first. */}
|
||||
<ComposerDraftEffect
|
||||
draft={draft}
|
||||
onCommit={resizeActiveComposer}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComposerDraftText>
|
||||
</div>
|
||||
<div className="composer__toolbar">
|
||||
<div className="composer__controls">
|
||||
@@ -10523,6 +10573,11 @@ function App(): React.JSX.Element {
|
||||
<span className="sr-only" id={runtimeDetailId}>
|
||||
{runtimeDetail}
|
||||
</span>
|
||||
<ComposerDraftHasText
|
||||
conversationId={activeId}
|
||||
store={composerDrafts}
|
||||
>
|
||||
{(draftHasText) => (
|
||||
<button
|
||||
aria-describedby={
|
||||
runtimeState !== "ready"
|
||||
@@ -10537,7 +10592,7 @@ function App(): React.JSX.Element {
|
||||
: t("composer.send")
|
||||
}
|
||||
disabled={
|
||||
(!input.trim() &&
|
||||
(!draftHasText &&
|
||||
!(
|
||||
activeRuntimeSelection?.provider ===
|
||||
"opencode" &&
|
||||
@@ -10561,6 +10616,8 @@ function App(): React.JSX.Element {
|
||||
>
|
||||
<Send aria-hidden="true" size={17} />
|
||||
</button>
|
||||
)}
|
||||
</ComposerDraftHasText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10740,6 +10797,7 @@ function App(): React.JSX.Element {
|
||||
cachedWorkspaceViewKeys.has("magic-notes")) && (
|
||||
<KeepAliveRoute
|
||||
active={view === "magic-notes"}
|
||||
onUnsavedChanges={workspaceUnsavedChangesReporters["magic-notes"]}
|
||||
route="magic-notes"
|
||||
>
|
||||
<ApplicationAvailability enabled={magicNotesEnabled}>
|
||||
@@ -10767,7 +10825,11 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
{(view === "knowledge" ||
|
||||
cachedWorkspaceViewKeys.has("knowledge")) && (
|
||||
<KeepAliveRoute active={view === "knowledge"} route="knowledge">
|
||||
<KeepAliveRoute
|
||||
active={view === "knowledge"}
|
||||
onUnsavedChanges={workspaceUnsavedChangesReporters.knowledge}
|
||||
route="knowledge"
|
||||
>
|
||||
<PageShell variant="master-detail">
|
||||
<RouteErrorBoundary
|
||||
key="knowledge"
|
||||
@@ -11110,7 +11172,11 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
{(view === "heartbeat" ||
|
||||
cachedWorkspaceViewKeys.has("heartbeat")) && (
|
||||
<KeepAliveRoute active={view === "heartbeat"} route="heartbeat">
|
||||
<KeepAliveRoute
|
||||
active={view === "heartbeat"}
|
||||
onUnsavedChanges={workspaceUnsavedChangesReporters.heartbeat}
|
||||
route="heartbeat"
|
||||
>
|
||||
<PageShell variant="supervisor">
|
||||
<RouteErrorBoundary
|
||||
key="heartbeat"
|
||||
@@ -11285,7 +11351,11 @@ function App(): React.JSX.Element {
|
||||
)}
|
||||
{(view === "activity" ||
|
||||
cachedWorkspaceViewKeys.has("activity")) && (
|
||||
<KeepAliveRoute active={view === "activity"} route="activity">
|
||||
<KeepAliveRoute
|
||||
active={view === "activity"}
|
||||
onUnsavedChanges={workspaceUnsavedChangesReporters.activity}
|
||||
route="activity"
|
||||
>
|
||||
<PageShell variant="dashboard">
|
||||
<RouteErrorBoundary
|
||||
key="activity"
|
||||
@@ -11545,6 +11615,7 @@ function App(): React.JSX.Element {
|
||||
</div>
|
||||
{noteDraft.confirmation}
|
||||
</DocumentConversationContext>
|
||||
</LiveMessageStoreContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,9 +140,13 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
if (!active || !context || !scrollContainer || !content) return;
|
||||
const measure = (): void => {
|
||||
// The fixed context strip also needs space inside the scrolling content.
|
||||
context.parentElement?.style.setProperty(
|
||||
'--chat-context-height', `${context.getBoundingClientRect().height}px`,
|
||||
);
|
||||
// Only write on change: the variable lives on the shared scroll region,
|
||||
// so every write restyles all kept-alive panes.
|
||||
const parent = context.parentElement;
|
||||
const height = `${context.getBoundingClientRect().height}px`;
|
||||
if (parent && parent.style.getPropertyValue('--chat-context-height') !== height) {
|
||||
parent.style.setProperty('--chat-context-height', height);
|
||||
}
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'function') return;
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { AgentQuestionCard } from './AgentQuestionCard'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { useLiveMessage } from './live-message-store'
|
||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||
import { formatCompactTokens } from './token-format'
|
||||
import { isCancelledMessage } from './message-terminal-status'
|
||||
@@ -1335,7 +1336,12 @@ function ChatMessageRowView({
|
||||
)
|
||||
}
|
||||
|
||||
export const ChatMessageRow = memo(ChatMessageRowView)
|
||||
// Streaming deltas reach only this row through the live message store.
|
||||
export const ChatMessageRow = memo(function ChatMessageRow(
|
||||
props: ChatMessageRowProps
|
||||
): React.JSX.Element {
|
||||
return <ChatMessageRowView {...props} message={useLiveMessage(props.message)} />
|
||||
})
|
||||
|
||||
type ChatTimelineProps = {
|
||||
onAddToNote?: ChatMessageRowProps['onAddToNote']
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HeartbeatCenter, type HeartbeatCenterProps } from './HeartbeatCenter'
|
||||
import { supervisionRunRequestSchema } from '../../shared/supervision-contracts'
|
||||
import { PageShell } from './WorkspacePrimitives'
|
||||
import i18n from './i18n'
|
||||
import { WorkspaceUnsavedChangesContext } from './workspace-unsaved-changes'
|
||||
import { applicationSettingsSchema, defaultLocalToolEnvironmentSettings } from '../../shared/application-settings-contracts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -883,6 +884,24 @@ describe('HeartbeatCenter', () => {
|
||||
expect(props.onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an edited plan draft as unsaved until the dialog closes', () => {
|
||||
const report = vi.fn()
|
||||
renderComponent(
|
||||
<WorkspaceUnsavedChangesContext.Provider value={report}>
|
||||
<HeartbeatCenter {...createProps()} />
|
||||
</WorkspaceUnsavedChangesContext.Provider>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('tab', { name: i18n.t('supervisor.automatic', { ns: 'heartbeat' }) }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建心跳计划' }))
|
||||
expect(report).not.toHaveBeenCalledWith(expect.any(String), true)
|
||||
fireEvent.change(screen.getByLabelText('计划名称'), { target: { value: 'Draft plan' } })
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), true)
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭心跳计划' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /放弃/ }))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), false)
|
||||
})
|
||||
|
||||
it('locks every close action and duplicate submission while saving, then keeps the failed draft', async () => {
|
||||
let rejectSave!: (reason: Error) => void
|
||||
const onCreate = vi.fn(() => new Promise<void>((_resolve, reject) => { rejectSave = reject }))
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SupervisorWorkspace, type SupervisionGraphNavigation } from './Supervis
|
||||
import { SupervisorActivity } from './SupervisorActivity'
|
||||
import './supervisor-workspace.css'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes'
|
||||
import {
|
||||
EmptyState,
|
||||
PageHeader,
|
||||
@@ -155,8 +156,8 @@ function UnifiedSupervisorCenter(props: HeartbeatCenterProps): React.JSX.Element
|
||||
}}
|
||||
/>}
|
||||
<HeartbeatSections {...props} pageTab={pageTab}
|
||||
onOpenResult={(resultId) => {
|
||||
setActivityNavigation({ resultId, tab: 'graph' })
|
||||
onOpenResult={(resultId, focus) => {
|
||||
setActivityNavigation({ resultId, tab: 'graph', ...(focus ? { focus } : {}) })
|
||||
setPageTab('graph')
|
||||
}}
|
||||
onOpenActivity={(id) => {
|
||||
@@ -187,6 +188,7 @@ function SupervisionModelSettings(props: HeartbeatCenterProps): React.JSX.Elemen
|
||||
setParallel(String(concurrency))
|
||||
setOrganize(String(supervisor))
|
||||
}
|
||||
useWorkspaceUnsavedChanges(organize !== String(supervisor) || parallel !== String(concurrency))
|
||||
const valid = supervisionTimeoutSecondsSchema.safeParse(Number(organize)).success &&
|
||||
supervisorModelConcurrencySchema.safeParse(Number(parallel)).success
|
||||
const disabled = props.applicationSettingsPending || props.applicationSettingsLocked || !props.applicationSettings || !props.onUpdateApplicationSettings
|
||||
@@ -238,7 +240,7 @@ function HeartbeatSections({
|
||||
}: HeartbeatCenterProps & {
|
||||
pageTab: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
|
||||
onOpenActivity: (id: string) => void
|
||||
onOpenResult: (resultId: string) => void
|
||||
onOpenResult: (resultId: string, focus?: SupervisionGraphNavigation['focus']) => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
const { t: tWorkspace } = useTranslation('workspace')
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SegmentedControl
|
||||
} from './WorkspacePrimitives'
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes'
|
||||
|
||||
type HeartbeatSettingsProps = {
|
||||
onOpenActivity?: (id: string) => void
|
||||
@@ -50,6 +51,7 @@ export function HeartbeatSettings({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [confirmDiscard, setConfirmDiscard] = useState(false)
|
||||
useWorkspaceUnsavedChanges(open && dirty)
|
||||
const nameRef = useRef<HTMLInputElement>(null)
|
||||
const keepEditingRef = useRef<HTMLButtonElement>(null)
|
||||
const dialogId = useId()
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
} from '../../shared/knowledge-contracts'
|
||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||
import { DestructiveConfirmActions } from './WorkspacePrimitives'
|
||||
import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes'
|
||||
|
||||
export type KnowledgeChunkRole = SharedKnowledgeChunkRole
|
||||
export type KnowledgeManagedChunk = SharedKnowledgeManagedChunk
|
||||
@@ -102,6 +103,12 @@ export function KnowledgeChunkManager({
|
||||
const draftContent = selectedChunk
|
||||
? (contentDrafts[selectedChunk.id] ?? selectedChunk.content)
|
||||
: ''
|
||||
useWorkspaceUnsavedChanges(
|
||||
Object.entries(contentDrafts).some(([id, text]) => {
|
||||
const chunk = page.items.find((item) => item.id === id)
|
||||
return chunk ? text !== chunk.content : false
|
||||
})
|
||||
)
|
||||
const isSavingSelected = savingChunkId === selectedChunk?.id
|
||||
const totalPages = Math.max(1, Math.ceil(page.totalItems / page.pageSize))
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PageShell } from './WorkspacePrimitives'
|
||||
import { KnowledgeGraphChartLoader } from './knowledge-workspace/KnowledgeGraphChartLoader'
|
||||
import type { KnowledgeWorkspaceProps } from './knowledge-workspace/types'
|
||||
import i18n from './i18n'
|
||||
import { WorkspaceUnsavedChangesContext } from './workspace-unsaved-changes'
|
||||
import {
|
||||
defaultKnowledgeOntologySettings
|
||||
} from '../../shared/knowledge-ontology'
|
||||
@@ -1283,6 +1284,24 @@ describe('KnowledgeWorkspace', () => {
|
||||
expect(onRebuildLibrary).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports unsaved settings edits to the workspace route cache', () => {
|
||||
const report = vi.fn()
|
||||
const { unmount } = render(
|
||||
<WorkspaceUnsavedChangesContext.Provider value={report}>
|
||||
<KnowledgeWorkspace {...createProps()} />
|
||||
</WorkspaceUnsavedChangesContext.Provider>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '高级设置' }))
|
||||
expect(report).not.toHaveBeenCalledWith(expect.any(String), true)
|
||||
fireEvent.change(screen.getByLabelText('分块方式'), {
|
||||
target: { value: 'parent-child' }
|
||||
})
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), true)
|
||||
unmount()
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), false)
|
||||
})
|
||||
|
||||
it('saves library ontology definitions without rebuilding implicitly', async () => {
|
||||
const onUpdateKnowledgeSettings = vi.fn()
|
||||
const onRebuildLibrary = vi.fn()
|
||||
|
||||
@@ -31,7 +31,7 @@ it('saves validated algorithm controls and retains the draft until saved setting
|
||||
const cross = screen.getByRole('switch', { name: '跨项目关联' })
|
||||
expect(cross).not.toBeChecked()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存回顾算法' }))
|
||||
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048, crossProject: false, storyThreadEvents: 20, experienceMinEvents: 5 } })
|
||||
expect(save).toHaveBeenCalledWith({ supervisionReview: { pageSize: 17, batchCharacters: 8000, batchMessages: 30, executionSeconds: 300, responseKiB: 2048, crossProject: false, storyThreadEvents: 20, experienceMinEvents: 5, stalledDays: 14 } })
|
||||
expect(screen.getByLabelText(/每次读取来源条数/)).toHaveValue(17)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ApplicationSettings, ApplicationSettingsUpdate } from '../../shared/application-settings-contracts'
|
||||
import { defaultExperienceMinEvents, defaultStoryThreadEvents, defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||
import { defaultExperienceMinEvents, defaultStalledDays, defaultStoryThreadEvents, defaultSupervisionReviewSettings, supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
|
||||
|
||||
const fields = [
|
||||
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384], ['storyThreadEvents', 4, 500], ['experienceMinEvents', 2, 200]
|
||||
['pageSize', 1, 200], ['batchCharacters', 1000, 16000], ['batchMessages', 1, 50], ['responseKiB', 100, 16384], ['storyThreadEvents', 4, 500], ['experienceMinEvents', 2, 200], ['stalledDays', 1, 365]
|
||||
] as const
|
||||
|
||||
export function SupervisionReviewSettings({ settings, disabled, onSave }: {
|
||||
@@ -16,7 +16,8 @@ export function SupervisionReviewSettings({ settings, disabled, onSave }: {
|
||||
const current = { ...defaultSupervisionReviewSettings, ...settings?.supervisionReview,
|
||||
responseKiB: settings?.supervisionReview?.responseKiB ?? 1024, crossProject: settings?.supervisionReview?.crossProject ?? false,
|
||||
storyThreadEvents: settings?.supervisionReview?.storyThreadEvents ?? defaultStoryThreadEvents,
|
||||
experienceMinEvents: settings?.supervisionReview?.experienceMinEvents ?? defaultExperienceMinEvents }
|
||||
experienceMinEvents: settings?.supervisionReview?.experienceMinEvents ?? defaultExperienceMinEvents,
|
||||
stalledDays: settings?.supervisionReview?.stalledDays ?? defaultStalledDays }
|
||||
const [saved, setSaved] = useState(current)
|
||||
const [draft, setDraft] = useState(current)
|
||||
if (fields.some(([key]) => saved[key] !== current[key]) || saved.crossProject !== current.crossProject) { setSaved(current); setDraft(current) }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import i18n, { changeUiLocale } from './i18n'
|
||||
import { SupervisionSuggestionsPanel } from './SupervisionSuggestionsPanel'
|
||||
|
||||
const base = { resultId: 'result-1', heartbeatRunId: 'run-1', scope: { kind: 'global' }, sourceIds: ['source-1'], entityId: null, relationId: null,
|
||||
taskId: null, status: 'pending', createdAt: '2026-09-30T10:00:00Z' }
|
||||
const stalled = { ...base, id: '00000000-0000-4000-8000-000000000911', kind: 'stalled', title: 'Knowledge import', detail: '14 days without new events',
|
||||
storyId: 'story-1', experienceId: null }
|
||||
const experience = { ...base, id: '00000000-0000-4000-8000-000000000912', kind: 'experience', title: 'Let long jobs run', detail: 'May apply to "Indexing"',
|
||||
storyId: 'story-2', experienceId: 'experience-1' }
|
||||
|
||||
beforeEach(async () => {
|
||||
await changeUiLocale('en-US')
|
||||
Object.assign(window, { goodbuddy: { supervision: {
|
||||
suggestions: vi.fn(async () => [stalled, experience]),
|
||||
suggestionAction: vi.fn(async () => ({ ...stalled, status: 'accepted', taskId: null })),
|
||||
source: vi.fn(async () => null)
|
||||
} } })
|
||||
})
|
||||
afterEach(async () => { await changeUiLocale('zh-CN') })
|
||||
|
||||
it('labels story suggestions and opens the story or experience they are about', async () => {
|
||||
const onOpenResult = vi.fn()
|
||||
render(<SupervisionSuggestionsPanel tasks={[]} onUseFollowUpTask={vi.fn()} onOpenResult={onOpenResult} />)
|
||||
const panel = await screen.findByRole('region', { name: i18n.t('suggestions.title', { ns: 'heartbeat' }) })
|
||||
expect(within(panel).getByText('No recent progress')).toBeVisible()
|
||||
expect(within(panel).getByText('Relevant experience')).toBeVisible()
|
||||
const [first, second] = within(panel).getAllByRole('button', { name: i18n.t('supervisor.viewInGraph', { ns: 'heartbeat' }) })
|
||||
fireEvent.click(first!)
|
||||
expect(onOpenResult).toHaveBeenLastCalledWith('result-1', { kind: 'story', id: 'story-1' })
|
||||
fireEvent.click(second!)
|
||||
expect(onOpenResult).toHaveBeenLastCalledWith('result-1', { kind: 'experience', id: 'experience-1' })
|
||||
expect(within(panel).getByRole('button', { name: 'Create task' })).toBeVisible()
|
||||
expect(within(panel).getByRole('button', { name: 'Noted' })).toBeVisible()
|
||||
})
|
||||
@@ -14,7 +14,8 @@ export function SupervisionSuggestionsPanel({ tasks, onUseFollowUpTask, onOpenRe
|
||||
reloadKey?: number
|
||||
tasks: AssistantTask[]
|
||||
onUseFollowUpTask: (task: AssistantTask) => void
|
||||
onOpenResult?: (resultId: string) => void
|
||||
/** Story and experience suggestions open that story or experience in the graph lists. */
|
||||
onOpenResult?: (resultId: string, focus?: { kind: 'story' | 'experience'; id: string }) => void
|
||||
onOpenConversation?: (conversationId: string) => void
|
||||
}): React.JSX.Element | null {
|
||||
const { t, i18n } = useTranslation('heartbeat')
|
||||
@@ -82,7 +83,8 @@ export function SupervisionSuggestionsPanel({ tasks, onUseFollowUpTask, onOpenRe
|
||||
<div className="supervision-suggestions__evidence">
|
||||
<button type="button" className="link-button" aria-expanded={evidence?.suggestionId === item.id}
|
||||
disabled={!item.sourceIds.length} onClick={() => void showEvidence(item)}>{t('suggestions.evidence', { count: item.sourceIds.length })}</button>
|
||||
{item.resultId && onOpenResult && <button type="button" className="link-button" onClick={() => onOpenResult(item.resultId!)}>{t('supervisor.viewInGraph')}</button>}
|
||||
{item.resultId && onOpenResult && <button type="button" className="link-button" onClick={() => onOpenResult(item.resultId!, item.experienceId ? { kind: 'experience', id: item.experienceId }
|
||||
: item.storyId ? { kind: 'story', id: item.storyId } : undefined)}>{t('supervisor.viewInGraph')}</button>}
|
||||
</div>
|
||||
{evidence?.suggestionId === item.id && <ul className="supervision-suggestions__sources">
|
||||
{evidence.sources.map(source => <li key={source.id}>
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
// Three.js loads only when the spiral view is opened.
|
||||
const StoryGraph3D = lazy(() => import('./StoryGraph3D'))
|
||||
type Selection = { kind: 'event' | 'entity' | 'relation' | 'story' | 'experience'; id: string }
|
||||
export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph' }
|
||||
/** `focus` opens a story or experience in the graph lists, such as from a heartbeat suggestion. */
|
||||
export type SupervisionGraphNavigation = { resultId: string; tab?: 'overview' | 'graph'; focus?: { kind: 'story' | 'experience'; id: string } }
|
||||
type Props = {
|
||||
graphNavigation?: SupervisionGraphNavigation
|
||||
tab?: 'overview' | 'graph' | 'plans' | 'activity' | 'settings'
|
||||
@@ -67,8 +68,8 @@ export function SupervisorWorkspace({
|
||||
const [pending, setPending] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [errorAction, setErrorAction] = useState<'run' | 'source' | 'action'>()
|
||||
const [selection, setSelection] = useState<Selection>()
|
||||
const [listTab, setListTab] = useState<Selection['kind']>('event')
|
||||
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
|
||||
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
|
||||
const [graphMode, setGraphMode] = useState<'flat' | 'spiral'>('flat')
|
||||
const [source, setSource] = useState<{
|
||||
id: string
|
||||
@@ -97,7 +98,8 @@ export function SupervisorWorkspace({
|
||||
setGraph(emptyGraph)
|
||||
setLoading(true)
|
||||
setSource(undefined)
|
||||
setSelection(undefined)
|
||||
setSelection(graphNavigation?.focus)
|
||||
if (graphNavigation?.focus) setListTab(graphNavigation.focus.kind)
|
||||
setPending(undefined)
|
||||
setError(undefined)
|
||||
setLoadError(undefined)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useSyncExternalStore,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Per-conversation composer text, kept outside App state so a keystroke only
|
||||
* re-renders the textarea and the send button, not the whole window.
|
||||
*/
|
||||
export type ComposerDraftStore = ReturnType<typeof createComposerDraftStore>;
|
||||
|
||||
export function createComposerDraftStore() {
|
||||
const drafts = new Map<string, string>();
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = (): void => {
|
||||
for (const listener of [...listeners]) listener();
|
||||
};
|
||||
return {
|
||||
get(conversationId: string): string {
|
||||
return drafts.get(conversationId) ?? "";
|
||||
},
|
||||
set(conversationId: string, update: SetStateAction<string>): void {
|
||||
const current = drafts.get(conversationId) ?? "";
|
||||
const next = typeof update === "function" ? update(current) : update;
|
||||
if (next === current) return;
|
||||
if (next) drafts.set(conversationId, next);
|
||||
else drafts.delete(conversationId);
|
||||
notify();
|
||||
},
|
||||
delete(conversationId: string): void {
|
||||
if (drafts.delete(conversationId)) notify();
|
||||
},
|
||||
subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useComposerDraft<T>(
|
||||
store: ComposerDraftStore,
|
||||
conversationId: string,
|
||||
select: (draft: string) => T,
|
||||
): T {
|
||||
const getSnapshot = useCallback(
|
||||
() => select(store.get(conversationId)),
|
||||
[conversationId, select, store],
|
||||
);
|
||||
return useSyncExternalStore(store.subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
const identity = (draft: string): string => draft;
|
||||
const hasText = (draft: string): boolean => draft.trim().length > 0;
|
||||
|
||||
/** Renders `children` with the current draft text of a conversation. */
|
||||
export function ComposerDraftText({
|
||||
children,
|
||||
conversationId,
|
||||
store,
|
||||
}: {
|
||||
children: (draft: string) => ReactNode;
|
||||
conversationId: string;
|
||||
store: ComposerDraftStore;
|
||||
}): ReactNode {
|
||||
return children(useComposerDraft(store, conversationId, identity));
|
||||
}
|
||||
|
||||
/** Renders `children` with whether the draft has non-whitespace text. */
|
||||
export function ComposerDraftHasText({
|
||||
children,
|
||||
conversationId,
|
||||
store,
|
||||
}: {
|
||||
children: (hasText: boolean) => ReactNode;
|
||||
conversationId: string;
|
||||
store: ComposerDraftStore;
|
||||
}): ReactNode {
|
||||
return children(useComposerDraft(store, conversationId, hasText));
|
||||
}
|
||||
|
||||
/** Runs `resize` after each committed draft change. */
|
||||
export function ComposerDraftEffect({
|
||||
draft,
|
||||
onCommit,
|
||||
}: {
|
||||
draft: string;
|
||||
onCommit: () => void;
|
||||
}): null {
|
||||
useLayoutEffect(() => {
|
||||
onCommit();
|
||||
// Only draft changes resize; the callback identity is irrelevant.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draft]);
|
||||
return null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const heartbeat = {
|
||||
responseKiB: 'Response capacity, KiB (default 1024)',
|
||||
storyThreadEvents: 'Minimum events before a feature splits into sub-threads (default 20)',
|
||||
experienceMinEvents: 'Minimum events before a story is considered for experiences (default 5)',
|
||||
stalledDays: 'Days without new events before a story is flagged (default 14)',
|
||||
save: 'Save review algorithm',
|
||||
help: 'Page size controls database reads, not total coverage. Batch limits split work without dropping remaining text. These settings and the organize timeout are frozen for each new review; continuing uses its saved configuration. Response capacity uses the current setting for each request; increase it before continuing a failed review.',
|
||||
pageSize: 'Sources read at a time (default 50)', batchCharacters: 'Text size per batch (default 8000)',
|
||||
@@ -32,9 +33,9 @@ export const heartbeat = {
|
||||
},
|
||||
suggestions: {
|
||||
title: 'Supervisor suggestions',
|
||||
kind: { open_item: 'Open item', conflict: 'Disagreement', convention: 'Possible convention', revision: 'Important revision' },
|
||||
kind: { open_item: 'Open item', conflict: 'Disagreement', convention: 'Possible convention', revision: 'Important revision', stalled: 'No recent progress', experience: 'Relevant experience' },
|
||||
evidence: 'Evidence ({{count}})', moreEvidence: '{{count}} more in the story graph',
|
||||
accept: { open_item: 'Create task', conflict: 'Reviewed', convention: 'Keep as convention', revision: 'Noted' },
|
||||
accept: { open_item: 'Create task', conflict: 'Reviewed', convention: 'Keep as convention', revision: 'Noted', stalled: 'Create task', experience: 'Noted' },
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
timeouts: {
|
||||
|
||||
@@ -4,6 +4,7 @@ export const heartbeat = {
|
||||
responseKiB: '单次响应容量,KiB(默认 1024)',
|
||||
storyThreadEvents: '功能拆分子线索的最少事件数(默认 20)',
|
||||
experienceMinEvents: '故事参与经验整理的最少事件数(默认 5)',
|
||||
stalledDays: '故事多少天没有新进展时提醒(默认 14)',
|
||||
save: '保存回顾算法',
|
||||
help: '每页条数只控制数据库读取量,不限制回顾总量。分批上限用于拆分工作,剩余正文会继续处理。这些设置及整理超时在新回顾开始时固定;继续回顾沿用已保存的配置。响应容量按每次请求的当前设置生效,调高后可继续失败的回顾。',
|
||||
pageSize: '每次读取来源条数(默认 50)', batchCharacters: '每批文本量(默认 8000)',
|
||||
@@ -29,9 +30,9 @@ export const heartbeat = {
|
||||
},
|
||||
suggestions: {
|
||||
title: '监督建议',
|
||||
kind: { open_item: '未决事项', conflict: '分歧', convention: '候选约定', revision: '重要修订' },
|
||||
kind: { open_item: '未决事项', conflict: '分歧', convention: '候选约定', revision: '重要修订', stalled: '暂无进展', experience: '可用经验' },
|
||||
evidence: '查看依据({{count}} 处)', moreEvidence: '另有 {{count}} 处依据,可在图谱中查看',
|
||||
accept: { open_item: '转为任务', conflict: '已核对', convention: '确认为长期约定', revision: '已知悉' },
|
||||
accept: { open_item: '转为任务', conflict: '已核对', convention: '确认为长期约定', revision: '已知悉', stalled: '转为任务', experience: '已参考' },
|
||||
dismiss: '忽略'
|
||||
},
|
||||
timeouts: {
|
||||
|
||||
@@ -172,6 +172,53 @@ describe('keep-alive cache', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps pinned entries outside capacity and expiry', () => {
|
||||
const entries = [
|
||||
{ key: 'chat', lastVisitedAt: 1 },
|
||||
{ key: 'one', lastVisitedAt: 10 },
|
||||
{ key: 'two', lastVisitedAt: 20 },
|
||||
{ key: 'three', lastVisitedAt: 30 },
|
||||
{ key: 'four', lastVisitedAt: 40 }
|
||||
]
|
||||
const options = {
|
||||
currentKey: 'four',
|
||||
expiresAfterMs: 1_000,
|
||||
maximumEntries: 3,
|
||||
now: 50,
|
||||
pinnedKeys: new Set(['chat']),
|
||||
recentEntries: 3
|
||||
}
|
||||
|
||||
expect(
|
||||
pruneKeepAliveEntries(entries, options).map((entry) => entry.key)
|
||||
).toEqual(['chat', 'four', 'three', 'two'])
|
||||
expect(
|
||||
pruneKeepAliveEntries(entries, {
|
||||
...options,
|
||||
now: 10_000,
|
||||
recentEntries: 0
|
||||
}).map((entry) => entry.key)
|
||||
).toEqual(['chat', 'four'])
|
||||
})
|
||||
|
||||
it('preserves reference equality when pinned entries are unchanged', () => {
|
||||
const entries = [
|
||||
{ key: 'chat', lastVisitedAt: 1 },
|
||||
{ key: 'knowledge', lastVisitedAt: 20 }
|
||||
]
|
||||
|
||||
expect(
|
||||
pruneKeepAliveEntries(entries, {
|
||||
currentKey: 'knowledge',
|
||||
expiresAfterMs: 100,
|
||||
maximumEntries: 1,
|
||||
now: 30,
|
||||
pinnedKeys: new Set(['chat']),
|
||||
recentEntries: 1
|
||||
})
|
||||
).toBe(entries)
|
||||
})
|
||||
|
||||
it('expires an unprotected entry after one hour', () => {
|
||||
const entries = [{ key: 'knowledge', lastVisitedAt: 1_000 }]
|
||||
const options = {
|
||||
|
||||
@@ -8,6 +8,11 @@ export type KeepAlivePruneOptions<Key extends string> = {
|
||||
expiresAfterMs: number
|
||||
maximumEntries: number
|
||||
now: number
|
||||
/**
|
||||
* Entries that are always retained and do not consume capacity, for panes
|
||||
* that must stay resident (the primary pane, or panes with unsaved edits).
|
||||
*/
|
||||
pinnedKeys?: ReadonlySet<Key>
|
||||
protectedKeys?: ReadonlySet<Key>
|
||||
recentEntries: number
|
||||
}
|
||||
@@ -98,11 +103,12 @@ export function pruneKeepAliveEntries<Key extends string>(
|
||||
/**
|
||||
* Prunes pane cache membership without modifying the pane data itself.
|
||||
*
|
||||
* Capacity is absolute. Candidates are selected in this exact order:
|
||||
* the current pane, the globally newest `recentEntries`, the newest
|
||||
* protected panes, then all other unexpired panes by recency. Overlapping
|
||||
* categories do not consume capacity twice. Equal timestamps retain their
|
||||
* input order.
|
||||
* Pinned panes are always kept, never expire, and sit outside capacity.
|
||||
* For all other panes capacity is absolute. Candidates are selected in this
|
||||
* exact order: the current pane, the globally newest `recentEntries`, the
|
||||
* newest protected panes, then all other unexpired panes by recency.
|
||||
* Overlapping categories do not consume capacity twice. Equal timestamps
|
||||
* retain their input order.
|
||||
*/
|
||||
export function pruneKeepAliveEntries<Key extends string>(
|
||||
entries: readonly KeepAliveCacheEntry<Key>[],
|
||||
@@ -111,11 +117,22 @@ export function pruneKeepAliveEntries<Key extends string>(
|
||||
expiresAfterMs,
|
||||
maximumEntries,
|
||||
now,
|
||||
pinnedKeys,
|
||||
protectedKeys,
|
||||
recentEntries
|
||||
}: KeepAlivePruneOptions<Key>
|
||||
): readonly KeepAliveCacheEntry<Key>[] {
|
||||
const pinned: KeepAliveCacheEntry<Key>[] = []
|
||||
const pinnedSeen = new Set<Key>()
|
||||
const newestFirst = entries
|
||||
.filter((entry) => {
|
||||
if (!pinnedKeys?.has(entry.key)) return true
|
||||
if (!pinnedSeen.has(entry.key)) {
|
||||
pinnedSeen.add(entry.key)
|
||||
pinned.push(entry)
|
||||
}
|
||||
return false
|
||||
})
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
@@ -152,9 +169,11 @@ export function pruneKeepAliveEntries<Key extends string>(
|
||||
)
|
||||
.forEach(add)
|
||||
|
||||
pinned.forEach((entry) => selectedKeys.add(entry.key))
|
||||
const retained = [...pinned, ...selected]
|
||||
const unchanged =
|
||||
selected.length === entries.length &&
|
||||
retained.length === entries.length &&
|
||||
selectedKeys.size === entries.length &&
|
||||
entries.every((entry) => selectedKeys.has(entry.key))
|
||||
return unchanged ? entries : selected
|
||||
return unchanged ? entries : retained
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { resolvedLocale } from './formatting'
|
||||
import { parseAliases } from './helpers'
|
||||
import { useWorkspaceUnsavedChanges } from '../workspace-unsaved-changes'
|
||||
|
||||
export function EntityEditor({
|
||||
node,
|
||||
@@ -46,6 +47,13 @@ export function EntityEditor({
|
||||
const [aliases, setAliases] = useState(
|
||||
(node?.aliases ?? []).join(t('format.listSeparator'))
|
||||
)
|
||||
const [initial] = useState(() => ({ label, type, description, aliases }))
|
||||
useWorkspaceUnsavedChanges(
|
||||
label !== initial.label ||
|
||||
type !== initial.type ||
|
||||
description !== initial.description ||
|
||||
aliases !== initial.aliases
|
||||
)
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -156,6 +164,13 @@ export function RelationForm({
|
||||
const [description, setDescription] = useState(
|
||||
relation?.description ?? ''
|
||||
)
|
||||
const [initial] = useState(() => ({ source, target, type, description }))
|
||||
useWorkspaceUnsavedChanges(
|
||||
source !== initial.source ||
|
||||
target !== initial.target ||
|
||||
type !== initial.type ||
|
||||
description !== initial.description
|
||||
)
|
||||
const sourceNode = nodes.find((node) => node.id === source)
|
||||
const targetNode = nodes.find((node) => node.id === target)
|
||||
const allowedRelationTypes = display.relationTypes.filter(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { KnowledgeLibrary, KnowledgeWorkspaceProps, KnowledgeGraphStrategy } from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useWorkspaceUnsavedChanges } from '../workspace-unsaved-changes'
|
||||
import type { KnowledgeEmbeddingIndexSnapshot } from '../../../shared/embedding-contracts'
|
||||
import { defaultKnowledgeChunkingSettings, type KnowledgeChunkingSettings } from '../../../shared/knowledge-contracts'
|
||||
import {
|
||||
@@ -55,6 +56,17 @@ export function KnowledgeSettingsView({
|
||||
library.ontologySettings ?? defaultKnowledgeOntologySettings
|
||||
)
|
||||
const [ontologyError, setOntologyError] = useState<string>()
|
||||
const savedChunking = library.chunkingSettings ?? defaultKnowledgeChunkingSettings
|
||||
const savedOntology = library.ontologySettings ?? defaultKnowledgeOntologySettings
|
||||
const unsavedChanges = useMemo(
|
||||
() =>
|
||||
(chunking !== savedChunking &&
|
||||
JSON.stringify(chunking) !== JSON.stringify(savedChunking)) ||
|
||||
(ontology !== savedOntology &&
|
||||
JSON.stringify(ontology) !== JSON.stringify(savedOntology)),
|
||||
[chunking, ontology, savedChunking, savedOntology]
|
||||
)
|
||||
useWorkspaceUnsavedChanges(unsavedChanges)
|
||||
|
||||
const requestEmbeddingIndex = useCallback(
|
||||
() => Promise.resolve(onGetEmbeddingIndex(library.id)),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { InlineHelp } from '../InlineHelp'
|
||||
import { LoaderCircle, Check, AlertCircle, Trash2 } from 'lucide-react'
|
||||
import { activateModalFocus, trapTabFocus } from '../dialog-focus'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useWorkspaceUnsavedChanges } from '../workspace-unsaved-changes'
|
||||
|
||||
export function CreateLibraryWizard({
|
||||
onCancel,
|
||||
@@ -31,6 +32,13 @@ export function CreateLibraryWizard({
|
||||
useState<KnowledgeGraphStrategy>('rules')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
useWorkspaceUnsavedChanges(
|
||||
name.trim() !== '' ||
|
||||
description.trim() !== '' ||
|
||||
storageMode !== 'reference' ||
|
||||
graphEnabled ||
|
||||
graphStrategy !== 'rules'
|
||||
)
|
||||
|
||||
const submit = async (
|
||||
event: React.FormEvent<HTMLFormElement>
|
||||
@@ -229,6 +237,9 @@ export function EditLibraryDialog({
|
||||
const { t } = useTranslation('knowledge')
|
||||
const [name, setName] = useState(library.name)
|
||||
const [description, setDescription] = useState(library.description ?? '')
|
||||
useWorkspaceUnsavedChanges(
|
||||
name !== library.name || description !== (library.description ?? '')
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "./ChatTimeline";
|
||||
import type { Conversation } from "./chat-conversation";
|
||||
import { createLiveMessageStore, foldLiveDeltas, type ConversationsUpdate } from "./live-message-store";
|
||||
|
||||
function message(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: 1,
|
||||
state: "streaming",
|
||||
blocks: [],
|
||||
...overrides,
|
||||
} as Message;
|
||||
}
|
||||
|
||||
function conversation(messages: Message[]): Conversation {
|
||||
return { id: "c1", title: "t", updatedAt: 1, messages } as unknown as Conversation;
|
||||
}
|
||||
|
||||
function harness() {
|
||||
let id = 0;
|
||||
const store = createLiveMessageStore({ createId: () => `b${++id}` });
|
||||
let state = [conversation([message()])];
|
||||
const setConversations = (update: ConversationsUpdate): void => {
|
||||
const captured = store.capture();
|
||||
state = store.applyUpdate(state, update, captured);
|
||||
store.prune(state);
|
||||
};
|
||||
const committed = (): Message => state[0]!.messages[0]!;
|
||||
const displayed = (): Message => store.resolve(committed());
|
||||
const append = (kind: "text" | "reasoning", delta: string): void =>
|
||||
store.append("c1", "m1", kind, delta, committed());
|
||||
return { store, setConversations, committed, displayed, append, get state() { return state; } };
|
||||
}
|
||||
|
||||
describe("live message store", () => {
|
||||
it("folds deltas exactly like the former per-event updates", () => {
|
||||
let id = 0;
|
||||
const createId = () => `b${++id}`;
|
||||
const deltas = [
|
||||
{ kind: "reasoning" as const, delta: "think ", blockId: createId() },
|
||||
{ kind: "reasoning" as const, delta: "more", blockId: createId() },
|
||||
{ kind: "text" as const, delta: "Hello", blockId: createId() },
|
||||
{ kind: "text" as const, delta: " world", blockId: createId() },
|
||||
];
|
||||
const folded = foldLiveDeltas(message({ status: "thinking" } as Partial<Message>), deltas);
|
||||
expect(folded.content).toBe("Hello world");
|
||||
expect(folded.reasoning).toBe("think more");
|
||||
expect(folded.status).toBeUndefined();
|
||||
expect(folded.blocks).toEqual([
|
||||
{ id: "b1", type: "reasoning", content: "think more" },
|
||||
{ id: "b3", type: "text", content: "Hello world" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows deltas before App state absorbs them, then drops the buffer", () => {
|
||||
const h = harness();
|
||||
const listener = vi.fn();
|
||||
h.store.subscribe("m1", listener);
|
||||
h.append("text", "Hel");
|
||||
h.append("text", "lo");
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(h.committed().content).toBe("");
|
||||
expect(h.displayed().content).toBe("Hello");
|
||||
expect(h.displayed()).toBe(h.displayed());
|
||||
|
||||
h.setConversations((current) => current);
|
||||
expect(h.committed().content).toBe("Hello");
|
||||
expect(h.store.hasEntries()).toBe(false);
|
||||
expect(h.displayed()).toBe(h.committed());
|
||||
});
|
||||
|
||||
it("keeps text and tool blocks in arrival order when another update intervenes", () => {
|
||||
const h = harness();
|
||||
h.append("text", "Before tool. ");
|
||||
h.setConversations((current) => current.map((item) => ({
|
||||
...item,
|
||||
messages: item.messages.map((entry) => ({
|
||||
...entry,
|
||||
blocks: [...(entry.blocks ?? []), { id: "tool", type: "tool", tool: { name: "x" } }],
|
||||
} as Message)),
|
||||
})));
|
||||
h.append("text", "After tool.");
|
||||
expect(h.displayed().content).toBe("Before tool. After tool.");
|
||||
expect(h.displayed().blocks?.map((block) => block.type)).toEqual(["text", "tool", "text"]);
|
||||
h.setConversations((current) => current);
|
||||
expect(h.committed().blocks?.map((block) => block.type)).toEqual(["text", "tool", "text"]);
|
||||
expect(h.committed().content).toBe("Before tool. After tool.");
|
||||
});
|
||||
|
||||
it("absorbs only deltas captured when an update was dispatched", () => {
|
||||
const store = createLiveMessageStore({ createId: () => "b" });
|
||||
const base = [conversation([message()])];
|
||||
store.append("c1", "m1", "text", "one ", base[0]!.messages[0]);
|
||||
const captured = store.capture();
|
||||
store.append("c1", "m1", "text", "two", base[0]!.messages[0]);
|
||||
const next = store.applyUpdate(base, (current) => current, captured);
|
||||
expect(next[0]!.messages[0]!.content).toBe("one ");
|
||||
store.prune(next);
|
||||
expect(store.resolve(next[0]!.messages[0]!).content).toBe("one two");
|
||||
});
|
||||
|
||||
it("does not repeat deltas already contained in a replacement snapshot", () => {
|
||||
const h = harness();
|
||||
h.append("text", "Persisted ");
|
||||
h.append("text", "and live");
|
||||
// Main persisted the first delta; the refetched snapshot replaces state.
|
||||
h.setConversations([conversation([message({ content: "Persisted ", blocks: [] })])]);
|
||||
expect(h.displayed().content).toBe("Persisted and live");
|
||||
h.setConversations([conversation([message({ content: "Persisted and live", blocks: [] })])]);
|
||||
expect(h.displayed().content).toBe("Persisted and live");
|
||||
expect(h.store.hasEntries()).toBe(false);
|
||||
});
|
||||
|
||||
it("treats replaced content as containing every absorbed delta", () => {
|
||||
const h = harness();
|
||||
h.append("text", "partial");
|
||||
h.setConversations((current) => current.map((item) => ({
|
||||
...item,
|
||||
messages: item.messages.map((entry) => ({ ...entry, content: "failed", state: "error" } as Message)),
|
||||
})));
|
||||
expect(h.committed().content).toBe("failed");
|
||||
expect(h.displayed().content).toBe("failed");
|
||||
expect(h.store.hasEntries()).toBe(false);
|
||||
});
|
||||
|
||||
it("is idempotent when React replays an updater", () => {
|
||||
const store = createLiveMessageStore({ createId: () => "b" });
|
||||
const base = [conversation([message()])];
|
||||
store.append("c1", "m1", "text", "abc", base[0]!.messages[0]);
|
||||
const captured = store.capture();
|
||||
const update = (current: Conversation[]) => current;
|
||||
const first = store.applyUpdate(base, update, captured);
|
||||
const replay = store.applyUpdate(base, update, captured);
|
||||
expect(first[0]!.messages[0]!.content).toBe("abc");
|
||||
expect(replay[0]!.messages[0]!.content).toBe("abc");
|
||||
});
|
||||
|
||||
it("never hides text when the first delta had no committed base", () => {
|
||||
const store = createLiveMessageStore({ createId: () => "b" });
|
||||
store.append("c1", "m1", "text", "hello");
|
||||
const committed = message({ content: "prefix that is long" });
|
||||
expect(store.resolve(committed).content).toBe("prefix that is longhello");
|
||||
});
|
||||
|
||||
it("drops entries for messages that no longer exist", () => {
|
||||
const h = harness();
|
||||
h.append("text", "gone");
|
||||
h.setConversations([]);
|
||||
expect(h.store.hasEntries()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { createContext, useCallback, useContext, useSyncExternalStore } from "react";
|
||||
import type { ConversationMessageBlock } from "../../shared/assistant-contracts";
|
||||
import type { Message } from "./ChatTimeline";
|
||||
import type { Conversation } from "./chat-conversation";
|
||||
|
||||
/**
|
||||
* Streaming text and reasoning deltas for in-flight assistant messages.
|
||||
*
|
||||
* Each delta used to replace the whole conversations array, re-rendering the
|
||||
* entire App for every IPC batch. Deltas now land here first: only the row
|
||||
* showing the message re-renders, and App state absorbs them on a slow cadence
|
||||
* or as part of the next conversations update.
|
||||
*
|
||||
* Every delta has a sequence number and the text/reasoning lengths the message
|
||||
* reaches after it. To know which deltas a message object already contains:
|
||||
* - objects produced while absorbing deltas (or derived from those by a
|
||||
* functional update) are tagged with the last absorbed sequence;
|
||||
* - any other object (persisted snapshots, replaced state) is measured by
|
||||
* length, because deltas only ever append.
|
||||
* Display folds only newer deltas, so nothing is shown twice or dropped. An
|
||||
* update absorbs only deltas captured when it was dispatched, which keeps
|
||||
* text, tool and other blocks in their original order.
|
||||
*/
|
||||
export type LiveDeltaKind = "text" | "reasoning";
|
||||
|
||||
type LiveDelta = {
|
||||
seq: number;
|
||||
kind: LiveDeltaKind;
|
||||
delta: string;
|
||||
blockId: string;
|
||||
/** Message lengths after this delta is applied. */
|
||||
textLength: number;
|
||||
reasoningLength: number;
|
||||
};
|
||||
|
||||
export type LiveMessageSnapshot = {
|
||||
readonly conversationId: string;
|
||||
readonly messageId: string;
|
||||
readonly lastSeq: number;
|
||||
readonly deltas: readonly LiveDelta[];
|
||||
};
|
||||
|
||||
export type ConversationsUpdate =
|
||||
| Conversation[]
|
||||
| ((current: Conversation[]) => Conversation[]);
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export type LiveMessageStore = ReturnType<typeof createLiveMessageStore>;
|
||||
|
||||
const noSnapshots: readonly LiveMessageSnapshot[] = [];
|
||||
|
||||
function appendBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
type: LiveDeltaKind,
|
||||
delta: string,
|
||||
blockId: string,
|
||||
): ConversationMessageBlock[] | undefined {
|
||||
if (!blocks || !delta) {
|
||||
return blocks;
|
||||
}
|
||||
const current = [...blocks];
|
||||
const previous = current.at(-1);
|
||||
if (previous?.type === type) {
|
||||
current[current.length - 1] = {
|
||||
...previous,
|
||||
content: `${previous.content}${delta}`,
|
||||
};
|
||||
return current;
|
||||
}
|
||||
current.push({ id: blockId, type, content: delta });
|
||||
return current;
|
||||
}
|
||||
|
||||
/** Applies deltas in order, exactly like the former per-event updates. */
|
||||
export function foldLiveDeltas(
|
||||
message: Message,
|
||||
deltas: readonly Pick<LiveDelta, "kind" | "delta" | "blockId">[],
|
||||
): Message {
|
||||
if (!deltas.length) {
|
||||
return message;
|
||||
}
|
||||
let content = message.content;
|
||||
let reasoning = message.reasoning;
|
||||
let blocks = message.blocks;
|
||||
for (const { kind, delta, blockId } of deltas) {
|
||||
if (kind === "text") {
|
||||
content = `${content}${delta}`;
|
||||
} else {
|
||||
reasoning = `${reasoning ?? ""}${delta}`;
|
||||
}
|
||||
blocks = appendBlock(blocks, kind, delta, blockId);
|
||||
}
|
||||
const next: Message = { ...message, content, blocks, status: undefined };
|
||||
if (reasoning !== undefined) {
|
||||
next.reasoning = reasoning;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function createLiveMessageStore(options: {
|
||||
createId?: () => string;
|
||||
onPending?: () => void;
|
||||
} = {}) {
|
||||
const createId = options.createId ?? (() => crypto.randomUUID());
|
||||
const entries = new Map<string, LiveMessageSnapshot>();
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
/** Last delta sequence contained by message objects this store produced. */
|
||||
const absorbed = new WeakMap<Message, number>();
|
||||
const resolved = new WeakMap<Message, { snapshot: LiveMessageSnapshot; message: Message }>();
|
||||
let seq = 0;
|
||||
let version = 0;
|
||||
let resolvedConversations:
|
||||
| { input: Conversation[]; version: number; output: Conversation[] }
|
||||
| undefined;
|
||||
|
||||
const notify = (messageId: string): void => {
|
||||
for (const listener of [...(listeners.get(messageId) ?? [])]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
/** Number of leading deltas of `snapshot` that `message` already contains. */
|
||||
const containedCount = (message: Message, snapshot: LiveMessageSnapshot): number => {
|
||||
const { deltas } = snapshot;
|
||||
const tag = absorbed.get(message);
|
||||
if (tag !== undefined) {
|
||||
let count = 0;
|
||||
while (count < deltas.length && deltas[count]!.seq <= tag) count += 1;
|
||||
return count;
|
||||
}
|
||||
const textLength = message.content.length;
|
||||
const reasoningLength = message.reasoning?.length ?? 0;
|
||||
let count = 0;
|
||||
while (
|
||||
count < deltas.length &&
|
||||
deltas[count]!.textLength <= textLength &&
|
||||
deltas[count]!.reasoningLength <= reasoningLength
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const findMessage = (
|
||||
conversations: readonly Conversation[],
|
||||
snapshot: LiveMessageSnapshot,
|
||||
): Message | undefined =>
|
||||
conversations
|
||||
.find((item) => item.id === snapshot.conversationId)
|
||||
?.messages.find((item) => item.id === snapshot.messageId);
|
||||
|
||||
/**
|
||||
* Folds `snapshots` into `conversations`. Snapshots are immutable, so React
|
||||
* may replay an updater against an older base state and get the same result.
|
||||
*/
|
||||
const fold = (
|
||||
conversations: Conversation[],
|
||||
snapshots: readonly LiveMessageSnapshot[],
|
||||
): Conversation[] => {
|
||||
let next = conversations;
|
||||
for (const snapshot of snapshots) {
|
||||
const conversationIndex = next.findIndex((item) => item.id === snapshot.conversationId);
|
||||
const conversation = next[conversationIndex];
|
||||
if (!conversation) continue;
|
||||
const messageIndex = conversation.messages.findIndex((item) => item.id === snapshot.messageId);
|
||||
const message = conversation.messages[messageIndex];
|
||||
if (!message) continue;
|
||||
const count = containedCount(message, snapshot);
|
||||
if (count >= snapshot.deltas.length) continue;
|
||||
const updated = foldLiveDeltas(message, snapshot.deltas.slice(count));
|
||||
absorbed.set(updated, snapshot.lastSeq);
|
||||
const messages = [...conversation.messages];
|
||||
messages[messageIndex] = updated;
|
||||
if (next === conversations) next = [...conversations];
|
||||
next[conversationIndex] = { ...conversation, updatedAt: Date.now(), messages };
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Records a delta. `base` is the committed message, used to measure the
|
||||
* first delta of a message; omit it when the message is not committed yet.
|
||||
*/
|
||||
append(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
kind: LiveDeltaKind,
|
||||
delta: string,
|
||||
base?: Pick<Message, "content" | "reasoning">,
|
||||
): void {
|
||||
if (!delta) return;
|
||||
seq += 1;
|
||||
version += 1;
|
||||
const previous = entries.get(messageId);
|
||||
const last = previous?.deltas.at(-1);
|
||||
// Without a committed base, lengths are unknown; only tags then decide
|
||||
// what a message contains, so text is never hidden by a wrong guess.
|
||||
const textLength = last?.textLength ?? base?.content.length ?? Number.POSITIVE_INFINITY;
|
||||
const reasoningLength = last?.reasoningLength ??
|
||||
(base ? base.reasoning?.length ?? 0 : Number.POSITIVE_INFINITY);
|
||||
const entry: LiveDelta = {
|
||||
seq,
|
||||
kind,
|
||||
delta,
|
||||
blockId: createId(),
|
||||
textLength: kind === "text" ? textLength + delta.length : textLength,
|
||||
reasoningLength: kind === "reasoning" ? reasoningLength + delta.length : reasoningLength,
|
||||
};
|
||||
entries.set(messageId, {
|
||||
conversationId,
|
||||
messageId,
|
||||
lastSeq: seq,
|
||||
deltas: previous ? [...previous.deltas, entry] : [entry],
|
||||
});
|
||||
notify(messageId);
|
||||
options.onPending?.();
|
||||
},
|
||||
/** Pending deltas at dispatch time; pass to `applyUpdate`. */
|
||||
capture(): readonly LiveMessageSnapshot[] {
|
||||
return entries.size ? [...entries.values()] : noSnapshots;
|
||||
},
|
||||
hasEntries(): boolean {
|
||||
return entries.size > 0;
|
||||
},
|
||||
subscribe(messageId: string, listener: Listener): () => void {
|
||||
let set = listeners.get(messageId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(messageId, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set.delete(listener);
|
||||
if (!set.size && listeners.get(messageId) === set) listeners.delete(messageId);
|
||||
};
|
||||
},
|
||||
getSnapshot(messageId: string): LiveMessageSnapshot | undefined {
|
||||
return entries.get(messageId);
|
||||
},
|
||||
/** The message as it should be displayed now; stable identity per input. */
|
||||
resolve(message: Message, snapshot = entries.get(message.id)): Message {
|
||||
if (!snapshot) return message;
|
||||
const cached = resolved.get(message);
|
||||
if (cached?.snapshot === snapshot) return cached.message;
|
||||
const count = containedCount(message, snapshot);
|
||||
const display = count < snapshot.deltas.length
|
||||
? foldLiveDeltas(message, snapshot.deltas.slice(count))
|
||||
: message;
|
||||
resolved.set(message, { snapshot, message: display });
|
||||
return display;
|
||||
},
|
||||
/** Conversations with every pending delta applied, without changing state. */
|
||||
resolveConversations(conversations: Conversation[]): Conversation[] {
|
||||
if (!entries.size) return conversations;
|
||||
if (resolvedConversations?.input === conversations && resolvedConversations.version === version) {
|
||||
return resolvedConversations.output;
|
||||
}
|
||||
const output = fold(conversations, [...entries.values()]);
|
||||
resolvedConversations = { input: conversations, version, output };
|
||||
return output;
|
||||
},
|
||||
/**
|
||||
* Runs a conversations update after absorbing the deltas captured when it
|
||||
* was dispatched. New message objects a functional update derives from
|
||||
* absorbed messages contain the same deltas, so they inherit the tag.
|
||||
*/
|
||||
applyUpdate(
|
||||
current: Conversation[],
|
||||
update: ConversationsUpdate,
|
||||
captured: readonly LiveMessageSnapshot[],
|
||||
): Conversation[] {
|
||||
if (typeof update !== "function") return update;
|
||||
if (!captured.length) return update(current);
|
||||
const flushed = fold(current, captured);
|
||||
const next = update(flushed);
|
||||
if (next === flushed) return next;
|
||||
for (const snapshot of captured) {
|
||||
const before = findMessage(flushed, snapshot);
|
||||
const after = findMessage(next, snapshot);
|
||||
if (!before || !after || after === before || absorbed.has(after)) continue;
|
||||
const tag = absorbed.get(before);
|
||||
if (tag !== undefined) absorbed.set(after, tag);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
/** Drops deltas fully contained in committed state, and orphaned entries. */
|
||||
prune(conversations: readonly Conversation[]): void {
|
||||
for (const snapshot of [...entries.values()]) {
|
||||
const message = findMessage(conversations, snapshot);
|
||||
if (message && containedCount(message, snapshot) < snapshot.deltas.length) continue;
|
||||
entries.delete(snapshot.messageId);
|
||||
version += 1;
|
||||
notify(snapshot.messageId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const LiveMessageStoreContext = createContext<LiveMessageStore | null>(null);
|
||||
|
||||
const noSubscription = (): (() => void) => () => undefined;
|
||||
|
||||
/** The committed message plus any streaming deltas not yet in App state. */
|
||||
export function useLiveMessage(message: Message): Message {
|
||||
const store = useContext(LiveMessageStoreContext);
|
||||
const messageId = message.id;
|
||||
const subscribe = useCallback(
|
||||
(listener: Listener) => (store ? store.subscribe(messageId, listener) : noSubscription()),
|
||||
[messageId, store],
|
||||
);
|
||||
const getSnapshot = useCallback(
|
||||
() => store?.getSnapshot(messageId),
|
||||
[messageId, store],
|
||||
);
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
||||
return store && snapshot ? store.resolve(message, snapshot) : message;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defaultKnowledgeOntologySettings } from '../../shared/knowledge-ontology'
|
||||
import './i18n'
|
||||
import { EntityEditor } from './knowledge-workspace/GraphEditors'
|
||||
import { EditLibraryDialog } from './knowledge-workspace/LibraryDialogs'
|
||||
import type { KnowledgeLibrary } from './knowledge-workspace/types'
|
||||
import {
|
||||
WorkspaceUnsavedChangesContext,
|
||||
useWorkspaceUnsavedChanges
|
||||
} from './workspace-unsaved-changes'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function Probe({ dirty }: { dirty: boolean }): null {
|
||||
useWorkspaceUnsavedChanges(dirty)
|
||||
return null
|
||||
}
|
||||
|
||||
describe('workspace unsaved changes', () => {
|
||||
it('is a no-op outside a kept-alive route', () => {
|
||||
expect(() => render(<Probe dirty />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports transitions and clears on unmount', () => {
|
||||
const report = vi.fn()
|
||||
const { rerender, unmount } = render(
|
||||
<WorkspaceUnsavedChangesContext.Provider value={report}>
|
||||
<Probe dirty={false} />
|
||||
</WorkspaceUnsavedChangesContext.Provider>
|
||||
)
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), false)
|
||||
rerender(
|
||||
<WorkspaceUnsavedChangesContext.Provider value={report}>
|
||||
<Probe dirty />
|
||||
</WorkspaceUnsavedChangesContext.Provider>
|
||||
)
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), true)
|
||||
unmount()
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), false)
|
||||
})
|
||||
|
||||
it('reports an edited graph entity draft against its initial values', () => {
|
||||
const report = vi.fn()
|
||||
render(
|
||||
<WorkspaceUnsavedChangesContext.Provider value={report}>
|
||||
<EntityEditor
|
||||
node={{ id: 'n1', label: 'Alpha', type: 'concept' } as never}
|
||||
ontology={defaultKnowledgeOntologySettings}
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
</WorkspaceUnsavedChangesContext.Provider>
|
||||
)
|
||||
expect(report).not.toHaveBeenCalledWith(expect.any(String), true)
|
||||
const name = screen.getByDisplayValue('Alpha')
|
||||
fireEvent.change(name, { target: { value: 'Beta' } })
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), true)
|
||||
fireEvent.change(name, { target: { value: 'Alpha' } })
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), false)
|
||||
})
|
||||
|
||||
it('reports an edited library name', () => {
|
||||
const report = vi.fn()
|
||||
render(
|
||||
<WorkspaceUnsavedChangesContext.Provider value={report}>
|
||||
<EditLibraryDialog
|
||||
library={{ id: 'l1', name: 'Library', description: '' } as KnowledgeLibrary}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
</WorkspaceUnsavedChangesContext.Provider>
|
||||
)
|
||||
expect(report).not.toHaveBeenCalledWith(expect.any(String), true)
|
||||
fireEvent.change(screen.getByDisplayValue('Library'), {
|
||||
target: { value: 'Renamed' }
|
||||
})
|
||||
expect(report).toHaveBeenLastCalledWith(expect.any(String), true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, useContext, useEffect, useId } from 'react'
|
||||
|
||||
/**
|
||||
* Reports unsaved edits from inside a kept-alive workspace route. The route
|
||||
* cache pins any route with unsaved edits so eviction never discards them
|
||||
* silently.
|
||||
*/
|
||||
export type ReportWorkspaceUnsavedChanges = (
|
||||
sourceId: string,
|
||||
dirty: boolean
|
||||
) => void
|
||||
|
||||
export const WorkspaceUnsavedChangesContext = createContext<
|
||||
ReportWorkspaceUnsavedChanges | undefined
|
||||
>(undefined)
|
||||
|
||||
export function useWorkspaceUnsavedChanges(dirty: boolean): void {
|
||||
const report = useContext(WorkspaceUnsavedChangesContext)
|
||||
const sourceId = useId()
|
||||
useEffect(() => {
|
||||
if (!report) return
|
||||
report(sourceId, dirty)
|
||||
return () => report(sourceId, false)
|
||||
}, [dirty, report, sourceId])
|
||||
}
|
||||
Reference in New Issue
Block a user