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:
mesalogo
2026-10-02 18:23:08 +08:00
parent 5f1ff24333
commit 43be465286
40 changed files with 1648 additions and 171 deletions
+27 -5
View File
@@ -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
View File
@@ -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>
);
}
+7 -3
View File
@@ -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;
+7 -1
View File
@@ -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']
+19
View File
@@ -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 }))
+5 -3
View File
@@ -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')
+2
View File
@@ -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}>
+6 -4
View File
@@ -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)
+100
View File
@@ -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: {
+47
View File
@@ -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 = {
+26 -7
View File
@@ -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)
+154
View File
@@ -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);
});
});
+319
View File
@@ -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])
}