diff --git a/apps/server/src/modules/agent/conversation/prompt/attachments.ts b/apps/server/src/modules/agent/conversation/prompt/attachments.ts index b0bb8ca75..6aab71eb0 100644 --- a/apps/server/src/modules/agent/conversation/prompt/attachments.ts +++ b/apps/server/src/modules/agent/conversation/prompt/attachments.ts @@ -163,12 +163,16 @@ export async function buildAttachmentParts( } case 'text': { - // Text excerpted from a node — content is always present if (att.content && att.content.trim().length > 0) { parts.push({ type: 'text', text: `\n${escapeXmlText(att.content)}\n`, }); + } else if (originIds.length > 0) { + parts.push({ + type: 'text', + text: ``, + }); } break; } diff --git a/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts b/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts index bb088cb81..13caee91c 100644 --- a/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts +++ b/apps/server/src/modules/agent/conversation/prompt/build-prompt.test.ts @@ -218,6 +218,31 @@ describe('renderEnvelopeMessages', () => { ); }); + it('renders a source-only attachment as a node reference', async () => { + const { messages } = await renderEnvelopeMessages( + makeEnvelope({ + text: 'use this source', + attachments: [ + { + type: 'text', + source: 'selection', + label: 'Adjacent note', + originNodeId: 'node-adjacent', + }, + ], + }), + NO_CANVAS, + ); + + const flat = textOf(messages[0].content); + expect(flat).toContain( + '', + ); + expect(flat.indexOf('origin="node-adjacent"')).toBeLessThan( + flat.indexOf(''), + ); + }); + it('places the sketch-raster hint with the selection visuals', async () => { const { messages } = await renderEnvelopeMessages( makeEnvelope({ diff --git a/apps/web/src/components/Nodes/MissingFileBanner.test.tsx b/apps/web/src/components/Nodes/MissingFileBanner.test.tsx index 182dc0118..152fb5a82 100644 --- a/apps/web/src/components/Nodes/MissingFileBanner.test.tsx +++ b/apps/web/src/components/Nodes/MissingFileBanner.test.tsx @@ -47,6 +47,11 @@ describe('getMissingFileKind', () => { expect(getMissingFileKind({})).toBeNull(); }); + it('ignores non-boolean truthy flag values', () => { + expect(getMissingFileKind({ contentMissing: 'true' })).toBeNull(); + expect(getMissingFileKind({ artifactMissing: 1 })).toBeNull(); + }); + it('distinguishes artifact loss from sidecar loss', () => { expect(getMissingFileKind({ artifactMissing: true })).toBe('artifact'); expect(getMissingFileKind({ contentMissing: true })).toBe('sidecar'); diff --git a/apps/web/src/components/Nodes/MissingFileBanner.tsx b/apps/web/src/components/Nodes/MissingFileBanner.tsx index 0538cd20c..cce615499 100644 --- a/apps/web/src/components/Nodes/MissingFileBanner.tsx +++ b/apps/web/src/components/Nodes/MissingFileBanner.tsx @@ -10,16 +10,8 @@ import useCanvasStore from '@/store/canvasStore'; import './MissingFileBanner.css'; -export type MissingFileKind = 'sidecar' | 'artifact'; - -export function getMissingFileKind(data: { - contentMissing?: boolean; - artifactMissing?: boolean; -}): MissingFileKind | null { - if (data.contentMissing) return 'sidecar'; - if (data.artifactMissing) return 'artifact'; - return null; -} +export { getMissingFileKind } from './missingFile'; +export type { MissingFileKind } from './missingFile'; export interface MissingFileBannerProps { /** Node ID — used by the Remove button to delete this node from the canvas. */ diff --git a/apps/web/src/components/Nodes/missingFile.ts b/apps/web/src/components/Nodes/missingFile.ts new file mode 100644 index 000000000..d4f2fab97 --- /dev/null +++ b/apps/web/src/components/Nodes/missingFile.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export type MissingFileKind = 'sidecar' | 'artifact'; + +export interface MissingFileData { + contentMissing?: boolean; + artifactMissing?: boolean; +} + +export function getMissingFileKind( + data: Record | MissingFileData, +): MissingFileKind | null { + if (data.contentMissing === true) return 'sidecar'; + if (data.artifactMissing === true) return 'artifact'; + return null; +} + +export function hasMissingFile( + data: Record | MissingFileData, +): boolean { + return getMissingFileKind(data) !== null; +} diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasLayerTree.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasLayerTree.tsx index 6aca21ff7..96aa4b338 100644 --- a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasLayerTree.tsx +++ b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasLayerTree.tsx @@ -21,7 +21,9 @@ import React, { useRef, useState, } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getMissingFileKind } from '@/components/Nodes/missingFile'; import useCanvasStore from '@/store/canvasStore.ts'; import { useExternalImportsStore } from '@/store/externalImportsStore'; import { usePanelStore } from '@/store/panelStore'; @@ -124,9 +126,17 @@ const SortableRow = React.memo( onToggleCollapse, onToggleLock, }: SortableRowProps) => { + const { t } = useTranslation(); const { listeners, setNodeRef, isDragging } = useSortable({ id: item.id, }); + const missingFileKind = getMissingFileKind(item.node.data); + const missingFileLabel = + missingFileKind === 'sidecar' + ? t('layers.nodeContentFileMissing') + : missingFileKind === 'artifact' + ? t('layers.nodeSourceFileMissing') + : undefined; // Intentionally drop BOTH the active row's drag transform AND the // sibling rows' strategy transform: the dragged row stays in its @@ -148,6 +158,7 @@ const SortableRow = React.memo( isSelected={isDirectlySelected} isHighlighted={isHighlighted} isDragging={isDragging} + missingFileLabel={missingFileLabel} isCollapsible={isCollapsible} isCollapsed={isCollapsed} isLocked={isLocked} diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.test.ts b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.test.ts new file mode 100644 index 000000000..6f40adf3f --- /dev/null +++ b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { shouldCanvasSearchOwnKeyboard } from './canvasSearchKeyboard'; + +describe('shouldCanvasSearchOwnKeyboard', () => { + it('keeps navigation ownership in the search input and result list', () => { + const searchInput = document.createElement('input'); + searchInput.dataset.canvasSearchInput = 'true'; + + const results = document.createElement('div'); + results.dataset.canvasSearchResults = ''; + const resultButton = document.createElement('button'); + results.appendChild(resultButton); + + expect(shouldCanvasSearchOwnKeyboard(searchInput)).toBe(true); + expect(shouldCanvasSearchOwnKeyboard(resultButton)).toBe(true); + }); + + it('keeps ownership when React Flow focuses a plain canvas node', () => { + const canvas = document.createElement('div'); + canvas.dataset.canvasRoot = ''; + const node = document.createElement('div'); + canvas.appendChild(node); + + expect(shouldCanvasSearchOwnKeyboard(node)).toBe(true); + }); + + it('yields to chat and canvas editors while search remains open', () => { + const chatInput = document.createElement('textarea'); + const canvas = document.createElement('div'); + canvas.dataset.canvasRoot = ''; + const noteEditor = document.createElement('div'); + noteEditor.setAttribute('contenteditable', 'true'); + const editorText = document.createElement('span'); + noteEditor.appendChild(editorText); + canvas.appendChild(noteEditor); + + expect(shouldCanvasSearchOwnKeyboard(chatInput)).toBe(false); + expect(shouldCanvasSearchOwnKeyboard(noteEditor)).toBe(false); + expect(shouldCanvasSearchOwnKeyboard(editorText)).toBe(false); + }); + + it('yields to controls outside the search results', () => { + const button = document.createElement('button'); + + expect(shouldCanvasSearchOwnKeyboard(button)).toBe(false); + }); +}); diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx index 63178c7bb..1875afcb1 100644 --- a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx +++ b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx @@ -36,6 +36,7 @@ import { Spline, ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; +import { shouldCanvasSearchOwnKeyboard } from './canvasSearchKeyboard'; import { focusNodesOnCanvas } from './focusNodesOnCanvas'; import { getNodeIcon } from '../../../config/nodeIcons'; import { scheduleScrollToMatch } from '../../../hooks/searchDom'; @@ -337,14 +338,12 @@ export const CanvasSearchResults = (): React.JSX.Element => { // bar and any canvas-level Arrow / Enter handlers while a query // is active. // - // LOAD-BEARING — capture phase + stopPropagation here suppresses - // *all* canvas-level Enter / Arrow handlers while the result list - // is mounted. That is intentional (we own the keyboard while - // searching); Escape is handled by `CanvasSearchInput` (which - // clears the query and closes the scope — that unmounts this - // component and the keydown listener cleans up). + // Search keeps ownership while focus is in its input/results or React Flow + // has moved focus onto a plain Canvas node wrapper. Editors and controls in + // Chat, Preview, and Canvas keep their own Enter / Arrow behavior. useEffect(() => { const handler = (e: KeyboardEvent) => { + if (!shouldCanvasSearchOwnKeyboard(e.target)) return; if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); @@ -428,7 +427,7 @@ export const CanvasSearchResults = (): React.JSX.Element => { !isStreaming && query.trim().length > 0 && results.length === 0 && !error; return ( -
+
{/* Truncation banner. VS Code-style: lives at the TOP so the user spots the warning before scrolling and knows the list is incomplete. */} diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/MissingNodesSummary.test.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/MissingNodesSummary.test.tsx new file mode 100644 index 000000000..8c81e9b64 --- /dev/null +++ b/apps/web/src/components/Panels/CanvasLayerPanel/MissingNodesSummary.test.tsx @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MissingNodesSummary } from './MissingNodesSummary'; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +describe('', () => { + let root: Root | undefined; + let container: HTMLDivElement | undefined; + + afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = undefined; + container = undefined; + }); + + const renderSummary = ( + props: Partial> = {}, + ) => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + undefined} + onClear={() => undefined} + {...props} + />, + ); + }); + }; + + it('toggles the missing-node filter from the count button', () => { + const onToggle = vi.fn(); + renderSummary({ onToggle }); + + const toggle = container?.querySelector( + 'button[aria-pressed="false"]', + ); + act(() => toggle?.click()); + + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it('offers an explicit clear action while active', () => { + const onClear = vi.fn(); + renderSummary({ isActive: true, onClear }); + + const clear = container?.querySelector( + 'button[aria-label="layers.clearMissingFilter"]', + ); + act(() => clear?.click()); + + expect(clear).not.toBeNull(); + expect(onClear).toHaveBeenCalledOnce(); + }); + + it('disables filter changes while canvas search is active', () => { + renderSummary({ isDisabled: true }); + + expect( + container?.querySelector( + 'button[aria-pressed="false"]', + )?.disabled, + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/MissingNodesSummary.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/MissingNodesSummary.tsx new file mode 100644 index 000000000..1145e8d7a --- /dev/null +++ b/apps/web/src/components/Panels/CanvasLayerPanel/MissingNodesSummary.tsx @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import clsx from 'clsx'; +import { FileWarning, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '../../Common/Button'; + +interface MissingNodesSummaryProps { + count: number; + isActive: boolean; + isDisabled: boolean; + onToggle: () => void; + onClear: () => void; +} + +export const MissingNodesSummary = ({ + count, + isActive, + isDisabled, + onToggle, + onClear, +}: MissingNodesSummaryProps) => { + const { t } = useTranslation(); + const toggleTitle = isDisabled + ? t('layers.clearSearchBeforeMissingFilter') + : isActive + ? t('layers.showAllNodes') + : t('layers.showMissingNodesOnly'); + + return ( +
+ + {isActive && ( + + )} +
+ ); +}; diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/TreeRowItem.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/TreeRowItem.tsx index 5f83df248..c0954f840 100644 --- a/apps/web/src/components/Panels/CanvasLayerPanel/TreeRowItem.tsx +++ b/apps/web/src/components/Panels/CanvasLayerPanel/TreeRowItem.tsx @@ -2,11 +2,19 @@ // Licensed under the MIT license. import clsx from 'clsx'; -import { ChevronDown, ChevronRight, Lock, Plus, Unlock } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + FileWarning, + Lock, + Plus, + Unlock, +} from 'lucide-react'; import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../Common/Button'; +import { Tooltip } from '../../Common/Tooltip'; import type { DraggableSyntheticListeners } from '@dnd-kit/core'; import type { ReactNode } from 'react'; @@ -20,6 +28,7 @@ export interface TreeRowItemProps extends React.HTMLAttributes { isSelected?: boolean; isHighlighted?: boolean; isDragging?: boolean; + missingFileLabel?: string; // Frame/Group specific isCollapsible?: boolean; @@ -102,6 +111,7 @@ export const TreeRowItem = React.memo( isSelected, isHighlighted, isDragging, + missingFileLabel, isCollapsible = false, isCollapsed = false, onToggleCollapse, @@ -327,6 +337,17 @@ export const TreeRowItem = React.memo( {/* Action buttons on the right */}
+ {missingFileLabel && ( + + + + + + )} {isExternal && onImport && (
), @@ -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.