+
),
@@ -805,6 +812,23 @@ describe('split', () => {
expect(mounted).toEqual(['a', 'b']);
});
+ it('offers an ordinary node beside a chat as a source candidate', () => {
+ openNode('a');
+ const threadId = useChatStore.getState().createThread();
+ store().openPreviewTarget({ kind: 'chat', canvasId: CANVAS_ID, threadId });
+ store().openPreviewTarget(
+ { kind: 'chat', canvasId: CANVAS_ID, threadId },
+ { openToSide: true },
+ );
+ render([canvasNode('a', 'Alpha')]);
+
+ expect(
+ container
+ ?.querySelector('[data-testid="chat-panel"]')
+ ?.getAttribute('data-adjacent-node-source-id'),
+ ).toBe('a');
+ });
+
it('bounds warm retention independently in each group', async () => {
openNode('a');
openNode('b');
diff --git a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.tsx b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.tsx
index 1cdb0e645..ef613c44e 100644
--- a/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.tsx
+++ b/apps/web/src/components/Panels/PreviewWorkspace/PreviewWorkspace.tsx
@@ -467,6 +467,19 @@ export function PreviewWorkspace({
{
+ const otherGroup = workspace.groups[1 - index];
+ const otherTarget = otherGroup?.activeTabId
+ ? workspace.tabs[otherGroup.activeTabId]?.target
+ : undefined;
+ return otherTarget?.kind === 'node'
+ ? otherTarget
+ : undefined;
+ })()
+ : undefined
+ }
isFocused={group.id === workspace.activeGroupId}
onFocus={() => setActiveGroup(group.id)}
onActivate={activateWorkspaceTab}
diff --git a/apps/web/src/hooks/useBuiltinThreadSettings.test.tsx b/apps/web/src/hooks/useBuiltinThreadSettings.test.tsx
index 4d264808a..49321e29f 100644
--- a/apps/web/src/hooks/useBuiltinThreadSettings.test.tsx
+++ b/apps/web/src/hooks/useBuiltinThreadSettings.test.tsx
@@ -30,7 +30,11 @@ let settingsSeenAfterSelection:
| { modelId: string | null; reasoningEffort: string | null }
| undefined;
-function Harness() {
+function Harness({
+ threadHasMessages = false,
+}: {
+ threadHasMessages?: boolean;
+}) {
const { settings, selectModel, selectReasoningEffort } =
useBuiltinThreadSettings({
threadId: THREAD_ID,
@@ -38,7 +42,7 @@ function Harness() {
provider: 'test-provider',
defaultModelId: 'default-model',
enabled: true,
- threadHasMessages: false,
+ threadHasMessages,
});
return (
<>
@@ -162,4 +166,57 @@ describe('useBuiltinThreadSettings', () => {
reasoningEffort: 'medium',
});
});
+
+ it('does not reload settings when the first message is sent', async () => {
+ apiMocks.getSettings.mockResolvedValue({
+ modelId: null,
+ reasoningEffort: null,
+ });
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ });
+ await act(async () => {
+ container?.querySelector('button')?.click();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ root?.render();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(apiMocks.getSettings).not.toHaveBeenCalled();
+ expect(
+ container.querySelector('[data-testid="settings"]')?.textContent,
+ ).toBe('model-1:medium');
+ expect(selectThreadSettings(useChatStore.getState(), THREAD_ID)).toEqual({
+ modelId: 'model-1',
+ reasoningEffort: 'medium',
+ });
+ });
+
+ it('loads server settings when an established conversation is mounted', async () => {
+ apiMocks.getSettings.mockResolvedValue({
+ modelId: 'server-model',
+ reasoningEffort: 'low',
+ });
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root?.render();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(apiMocks.getSettings).toHaveBeenCalledOnce();
+ expect(
+ container.querySelector('[data-testid="settings"]')?.textContent,
+ ).toBe('server-model:low');
+ });
});
diff --git a/apps/web/src/hooks/useBuiltinThreadSettings.ts b/apps/web/src/hooks/useBuiltinThreadSettings.ts
index 72a3cab1a..97e93d59a 100644
--- a/apps/web/src/hooks/useBuiltinThreadSettings.ts
+++ b/apps/web/src/hooks/useBuiltinThreadSettings.ts
@@ -88,6 +88,10 @@ export function useBuiltinThreadSettings({
// Bumped on every local user mutation. A settings fetch that started
// before a mutation must not clobber the newer local value (P1-2).
const mutationGenRef = useRef(0);
+ const threadMessageStateRef = useRef({
+ threadId: threadId ?? null,
+ hasMessages: threadHasMessages,
+ });
// Fetch the active provider's model catalogue (capability + labels).
useEffect(() => {
@@ -110,6 +114,16 @@ export function useBuiltinThreadSettings({
// Fetch this thread's persisted selection.
useEffect(() => {
+ const previousMessageState = threadMessageStateRef.current;
+ const isFirstMessageTransition =
+ previousMessageState.threadId === threadId &&
+ !previousMessageState.hasMessages &&
+ threadHasMessages;
+ threadMessageStateRef.current = {
+ threadId: threadId ?? null,
+ hasMessages: threadHasMessages,
+ };
+
if (!enabled || !threadId) {
replaceSettingsState({
threadId: threadId ?? null,
@@ -118,6 +132,14 @@ export function useBuiltinThreadSettings({
setLoading(false);
return;
}
+ // Sending the first message updates local history before the server has
+ // necessarily persisted the deployment. The current thread already owns
+ // the user's latest selection, so this lifecycle transition must not
+ // trigger a stale settings reload.
+ if (isFirstMessageTransition) {
+ setLoading(false);
+ return;
+ }
const restored = selectThreadSettings(useChatStore.getState(), threadId);
replaceSettingsState({ threadId, settings: restored });
// Before first send there is no durable server record. The local thread
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index f110f829d..0d3ea5d7d 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -554,6 +554,7 @@
"collapsePanel": "Collapse layers panel",
"empty": "No items",
"noMatches": "No matching layers",
+ "noMissingMatches": "No missing nodes match the current filters",
"filterBy": "Filter by {{label}}",
"stopFilteringBy": "Stop filtering by {{label}}",
"collapseAllFrames": "Collapse all frames",
@@ -563,6 +564,14 @@
"closeSearch": "Close search",
"searchPlaceholder": "Search this Space…",
"searchAria": "Search this Space",
+ "missingNodesCount_one": "{{count}} node file missing",
+ "missingNodesCount_other": "{{count}} node files missing",
+ "showMissingNodesOnly": "Show only nodes with missing files",
+ "showAllNodes": "Show all nodes",
+ "clearMissingFilter": "Clear missing-node filter",
+ "clearSearchBeforeMissingFilter": "Clear search before filtering missing nodes",
+ "nodeContentFileMissing": "Node content file missing",
+ "nodeSourceFileMissing": "Node source file missing",
"filterLabels": {
"note": "Note",
"text": "Text",
@@ -629,6 +638,7 @@
"attachedImageAlt": "Attached image",
"removeAttachment": "Remove attachment",
"lockSelectionAttachment": "Keep this selection as an attachment",
+ "addAdjacentNodeSource": "Add the node from the other pane as a source",
"attachmentSource": "Source:",
"attachmentContent": "Content:",
"stopGenerating": "Stop generating",
diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json
index 129a00aff..0e3644ad1 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -554,6 +554,7 @@
"collapsePanel": "收起图层面板",
"empty": "暂无项目",
"noMatches": "没有匹配的图层",
+ "noMissingMatches": "没有符合当前筛选条件的缺失节点",
"filterBy": "按{{label}}筛选",
"stopFilteringBy": "停止按{{label}}筛选",
"collapseAllFrames": "折叠所有框架",
@@ -563,6 +564,14 @@
"closeSearch": "关闭搜索",
"searchPlaceholder": "搜索当前 Space…",
"searchAria": "搜索当前 Space",
+ "missingNodesCount_one": "{{count}} 个节点文件缺失",
+ "missingNodesCount_other": "{{count}} 个节点文件缺失",
+ "showMissingNodesOnly": "只显示文件缺失的节点",
+ "showAllNodes": "显示全部节点",
+ "clearMissingFilter": "取消缺失节点筛选",
+ "clearSearchBeforeMissingFilter": "请先清除搜索,再筛选缺失节点",
+ "nodeContentFileMissing": "节点内容文件缺失",
+ "nodeSourceFileMissing": "节点源文件缺失",
"filterLabels": {
"note": "笔记",
"text": "文本",
@@ -629,6 +638,7 @@
"attachedImageAlt": "已附加图片",
"removeAttachment": "移除附件",
"lockSelectionAttachment": "将此选区保留为附件",
+ "addAdjacentNodeSource": "将另一栏节点添加为来源",
"attachmentSource": "来源:",
"attachmentContent": "内容:",
"stopGenerating": "停止生成",
diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md
index a2f356229..dac1dec09 100644
--- a/docs/architecture/preview-workspace.md
+++ b/docs/architecture/preview-workspace.md
@@ -86,6 +86,8 @@ Dragging a Chat or Note block into an editable Note uses Milkdown's geometric dr
PDF area capture routes directly to a Chat or Question conversation that is active in the group beside the PDF. When no conversation is visible beside it, the Canvas's canonical unbound Chat opens to the side and the capture is staged immediately as that thread's pending attachment. The explicit Send to Chat action always produces a thread-owned attachment; the shared dashed selection attachment remains reserved for passive browser text selection.
+When a conversation is visible beside an ordinary node, its composer offers that active node as a dashed source candidate. Confirming the candidate stages a thread-owned source attachment that the prompt renderer emits as a structured node reference; switching the node in the adjacent group updates the unconfirmed candidate, while an already confirmed source remains attached to the thread.
+
For a World `nodeRef` that presents a source Question, the target remains the World presentation node while `AgentConversationView` carries the source Canvas, node, and thread as conversation owner. History, reconnect, agent turns, tools, lifecycle writes, binding, mode, and change records use that owner scope.
An authored Question node remains authoritative for persisted agent mode and fixed binding. A new selectable Question thread inherits the Canvas's current binding unless the node supplies an explicit binding.
diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md
index c1193a277..2952fd3ae 100644
--- a/docs/architecture/web-architecture.md
+++ b/docs/architecture/web-architecture.md
@@ -137,6 +137,10 @@ Preview Workspace state, rendering, tab/group behavior, Chat isolation, runtime
The Expanded Node Panel derives connected-node navigation from the active Canvas edges without adding persisted navigation state. One relationship-menu trigger sits at the far left before the node title and groups destinations as sources, neighbors, and destinations instead of exposing three persistent toolbar buttons. Node-specific preview actions sit on the right before a divider and the view controls. A `forward` arrow follows the edge's source-to-target endpoints, a `backward` arrow reverses them, and a `both` arrow contributes the neighbor to both source and destination groups; a `none` edge has no directional meaning and appears in the neighbor group. Neighbors follow Canvas node order after missing endpoints, self-loops, and duplicates are removed. Bare Left/Right Arrow actions switch directly when one directional neighbor exists or open the relationship menu focused on the matching group when several exist; neutral connections remain menu-driven so the directional shortcuts do not imply an invented order. Editable controls, search, menus, media controls, and embedded viewers retain arrow-key ownership. Switching calls `openPreviewNode` and does not select or reveal the destination on the Canvas. It opens transiently: navigating between connected nodes is browsing, so it reuses the preview group's inspection slot rather than accumulating a tab per neighbor.
+Canvas-wide search keeps its query and results mounted when focus moves elsewhere. Its capture-phase Enter and Arrow navigation owns events from the search input, result list, and non-interactive Canvas targets, including React Flow node wrappers focused by live-follow; editable surfaces and other controls in Chat, Preview, or Canvas retain their native keyboard behavior without requiring the search to close.
+
+The Canvas Layer panel surfaces hydrated `contentMissing` and `artifactMissing` state in two places: each affected row carries a warning status with a kind-specific tooltip, and a count summary below the search and type-filter controls toggles a flat missing-only view. Missing-only filtering intersects with type chips, excludes not-yet-imported external notes, can be cleared from the summary, and exits automatically when the missing count reaches zero. Canvas text search remains the active result surface while its query is non-empty, so the summary preserves its count but disables missing-filter changes until search is cleared.
+
Preview Workspace is the only right-side presentation surface. `MainLayout` mounts it in the collapsible right column and `CenterArea` hosts only the Canvas. The floating Bot button opens and focuses the most recently active Chat tab, or creates one when none exists; it never collapses the workspace, whose own header control owns that action. Each Canvas persists one workspace containing one or two horizontal groups, semantic node or unbound-Chat targets, active tabs, split ratio, and deterministic activation sequence. Reopening a target activates its existing tab across groups; Open to Side moves it instead of duplicating it. Explicit opens are permanent, while confirmed Canvas search results and connected-node browsing use one reusable transient inspection tab per group. Transient tabs use italic titles and an accessible tooltip that identifies the temporary preview and its double-click-to-keep action. Double-clicking the tab or making a persistent mutation through its renderer promotes it in place: Preview Workspace owns the lifecycle transition, while Chat and Expanded Node renderers only report semantic commits such as sending a message, changing an attachment or thread setting, renaming a node, or editing node content. Moving through search results, scrolling, and editing an unsent draft do not promote the tab. Permanent tabs remain open until the user closes them or their target node is deleted; browsing stays bounded by reusing each group's transient inspection slot. The workspace tab strip and embedded Chat or Expanded Node action bars share a 36px height; the tab strip owns the primary title and close action. New conversation creates an independent thread-backed Chat tab in the focused group, and Save Chat as Question converts that tab's target in place. Tabs use shared pointer and keyboard drag sensors, but every drop delegates to the workspace model's `moveTab` action for ordering, cross-group movement, active-tab repair, and empty-group removal. The old single Chat panel, side-by-side centre preview, replace-Canvas mode, feature flag, and Settings toggle no longer exist. See [`docs/proposals/unified-preview-workspace.md`](../proposals/unified-preview-workspace.md).
Explicit node opens create a runtime-only `{ tabId, nonce }` editor-focus request in Preview Workspace. Only the addressed tab receives the request, and its renderer consumes the request after focusing so a later remount cannot replay stale intent; ordinary tab activation does not request editor focus.