From 5d1d676b8b318f3ee0613ac7c8ab97a5a384d02b Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Sat, 22 Aug 2026 16:20:17 +0800 Subject: [PATCH 1/6] feat: add adjacent preview node sources --- .../agent/conversation/prompt/attachments.ts | 6 +- .../conversation/prompt/build-prompt.test.ts | 25 +++++++ .../Panels/ChatPanel/ChatInput.test.tsx | 70 ++++++++++++++++++- .../components/Panels/ChatPanel/ChatInput.tsx | 46 +++++++++++- .../src/components/Panels/ChatPanel/index.tsx | 4 ++ .../Panels/PreviewWorkspace/PreviewGroup.tsx | 6 ++ .../PreviewWorkspace/PreviewRenderer.tsx | 23 ++++++ .../PreviewWorkspace.test.tsx | 26 ++++++- .../PreviewWorkspace/PreviewWorkspace.tsx | 13 ++++ apps/web/src/i18n/resources/en/common.json | 1 + apps/web/src/i18n/resources/zh-CN/common.json | 1 + docs/architecture/preview-workspace.md | 2 + 12 files changed, 218 insertions(+), 5 deletions(-) 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/Panels/ChatPanel/ChatInput.test.tsx b/apps/web/src/components/Panels/ChatPanel/ChatInput.test.tsx index 5811c9c98..db56cbdda 100644 --- a/apps/web/src/components/Panels/ChatPanel/ChatInput.test.tsx +++ b/apps/web/src/components/Panels/ChatPanel/ChatInput.test.tsx @@ -16,6 +16,16 @@ const chatState = { removePendingAttachment: vi.fn(), }; +const canvasState = { + canvasId: null as string | null, + nodes: [] as Array<{ + id: string; + type: string; + position: { x: number; y: number }; + data: { label: string }; + }>, +}; + const panelState: { focusChatInputRequest: { threadId: string; nonce: number } | null; } = { @@ -23,8 +33,8 @@ const panelState: { }; vi.mock('@/store/canvasStore', () => ({ - default: (selector: (state: { canvasId: null }) => unknown) => - selector({ canvasId: null }), + default: (selector: (state: typeof canvasState) => unknown) => + selector(canvasState), })); vi.mock('@/store/chatStore', () => { @@ -70,6 +80,9 @@ afterEach(() => { root = undefined; container = undefined; panelState.focusChatInputRequest = null; + canvasState.nodes = []; + chatState.pendingAttachments = []; + chatState.addPendingAttachment.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -166,4 +179,57 @@ describe('ChatInput', () => { expect(onSubmit).not.toHaveBeenCalled(); expect(container.querySelector('textarea')?.value).toBe('Keep this draft'); }); + + it('stages the node in the other preview group on explicit confirmation', () => { + canvasState.nodes = [ + { + id: 'node-adjacent', + type: 'note', + position: { x: 0, y: 0 }, + data: { label: 'Adjacent note' }, + }, + ]; + const onCommit = vi.fn(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root?.render( + + + , + ), + ); + + const candidate = container.querySelector( + 'button[aria-label="chat.addAdjacentNodeSource"]', + ); + expect(candidate?.textContent).toContain('Adjacent note'); + + act(() => candidate?.click()); + + expect(chatState.addPendingAttachment).toHaveBeenCalledWith('thread-test', { + type: 'text', + source: 'selection', + originNodeId: 'node-adjacent', + label: 'Adjacent note', + }); + expect(onCommit).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx b/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx index 675e38929..7ca2875f9 100644 --- a/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx +++ b/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import { resolveArtifactUrl, uploadImage, uploadPdf } from '@/api/artifact'; import { useChatSession } from '@/hooks/useChatSession'; +import useCanvasStore from '@/store/canvasStore'; import { selectThreadMessages, selectThreadPendingAttachments, @@ -90,6 +91,8 @@ interface ChatInputProps { * semantics of this prop. */ contextUsageOverride?: ContextUsageOverride | undefined; + /** Active node shown in the other Preview split group. */ + adjacentNodeSourceId?: string; disabled?: boolean; placeholder?: string; /** @@ -114,6 +117,7 @@ export const ChatInput = ({ acpSelectorsSlot, agentSelectorSlot, contextUsageOverride, + adjacentNodeSourceId, disabled = false, placeholder, connectedTop = false, @@ -130,6 +134,11 @@ export const ChatInput = ({ selectThreadPendingAttachments(s, threadId), ); const selectionAttachment = useChatStore((s) => s.selectionAttachment); + const adjacentNode = useCanvasStore((s) => + adjacentNodeSourceId + ? s.nodes.find((node) => node.id === adjacentNodeSourceId) + : undefined, + ); const addPendingAttachment = useChatStore((s) => s.addPendingAttachment); const removePendingAttachment = useChatStore( (s) => s.removePendingAttachment, @@ -400,8 +409,43 @@ export const ChatInput = ({ className={`border p-3 transition-colors ${connectedTop ? 'rounded-t-none rounded-b-2xl' : 'rounded-2xl'} ${isDragOver ? 'border-edge-default bg-info-bg' : 'border-edge-default bg-surface'}`} > {/* ── Pending attachment thumbnails ── */} - {(pendingAttachments.length > 0 || selectionAttachment) && ( + {(pendingAttachments.length > 0 || + selectionAttachment || + adjacentNode) && (
+ {adjacentNode && + !pendingAttachments.some( + (attachment) => + attachment.originNodeId === adjacentNode.id && + !attachment.content && + !attachment.url, + ) && ( + + + + )} {/* Selection attachment (from text highlight in expanded panel) */} {selectionAttachment && (() => { diff --git a/apps/web/src/components/Panels/ChatPanel/index.tsx b/apps/web/src/components/Panels/ChatPanel/index.tsx index ea5184eb4..1ab87ba07 100644 --- a/apps/web/src/components/Panels/ChatPanel/index.tsx +++ b/apps/web/src/components/Panels/ChatPanel/index.tsx @@ -76,6 +76,8 @@ interface ChatPanelProps { session: ChatSession; /** Workspace tab to convert in place when an unbound Chat is saved. */ previewTabId: string; + /** Active node in the other Preview split group, if one is visible. */ + adjacentNodeSourceId?: string; /** Reports a persistent thread mutation to the owning preview surface. */ onCommit?: () => void; /** One-shot initial scroll request from Preview Workspace. */ @@ -91,6 +93,7 @@ export const ChatPanel = ({ onToggle, session, previewTabId, + adjacentNodeSourceId, onCommit, openPositionRequest, onOpenPositionHandled, @@ -893,6 +896,7 @@ export const ChatPanel = ({ onSubmit={handleSubmit} onCommit={onCommit} onStop={stopStream} + adjacentNodeSourceId={adjacentNodeSourceId} isStreaming={isLoading} mode={mode} connectedTop={hasThreadChanges} diff --git a/apps/web/src/components/Panels/PreviewWorkspace/PreviewGroup.tsx b/apps/web/src/components/Panels/PreviewWorkspace/PreviewGroup.tsx index f48d07cf9..437fb502a 100644 --- a/apps/web/src/components/Panels/PreviewWorkspace/PreviewGroup.tsx +++ b/apps/web/src/components/Panels/PreviewWorkspace/PreviewGroup.tsx @@ -30,11 +30,13 @@ import type { TabDropIndicator } from './tabDnd'; import type { CanvasPreviewWorkspace, PreviewGroup as PreviewGroupModel, + PreviewTarget, } from '@/store/previewWorkspace/model'; type PreviewGroupProps = { group: PreviewGroupModel; workspace: CanvasPreviewWorkspace; + adjacentNodeTarget?: Extract; isFocused: boolean; onFocus: () => void; onActivate: (tabId: string) => void; @@ -60,6 +62,7 @@ type PreviewGroupProps = { export function PreviewGroup({ group, workspace, + adjacentNodeTarget, isFocused, onFocus, onActivate, @@ -148,6 +151,9 @@ export function PreviewGroup({ onClose(tab.id)} onCommit={() => onPromote(tab.id)} nodeFocusRequestNonce={ diff --git a/apps/web/src/components/Panels/PreviewWorkspace/PreviewRenderer.tsx b/apps/web/src/components/Panels/PreviewWorkspace/PreviewRenderer.tsx index 814d09bbb..64f7776e9 100644 --- a/apps/web/src/components/Panels/PreviewWorkspace/PreviewRenderer.tsx +++ b/apps/web/src/components/Panels/PreviewWorkspace/PreviewRenderer.tsx @@ -51,6 +51,7 @@ function questionSession( export function PreviewRenderer({ tabId, target, + adjacentNodeTarget, onClose, onCommit, nodeFocusRequestNonce, @@ -61,6 +62,8 @@ export function PreviewRenderer({ }: { tabId: string; target: PreviewTarget; + /** Active node shown in the other split group, offered as a source. */ + adjacentNodeTarget?: Extract; /** Closes the tab rendering this target. */ onClose: () => void; /** Promotes this tab after its target receives a persistent mutation. */ @@ -85,6 +88,25 @@ export function PreviewRenderer({ const reference = useCanvasStore((s) => target.kind === 'node' ? s.worldReferences[target.nodeId] : undefined, ); + const adjacentNode = useCanvasStore((s) => + adjacentNodeTarget + ? s.nodes.find((candidate) => candidate.id === adjacentNodeTarget.nodeId) + : undefined, + ); + const adjacentReference = useCanvasStore((s) => + adjacentNodeTarget + ? s.worldReferences[adjacentNodeTarget.nodeId] + : undefined, + ); + const adjacentNodeSourceId = + adjacentNode && + !conversationViewForNode( + adjacentNode, + adjacentNodeTarget?.canvasId ?? '', + adjacentReference, + ) + ? adjacentNode.id + : undefined; const session = useMemo(() => { if (target.kind === 'chat') { @@ -103,6 +125,7 @@ export function PreviewRenderer({ ({ ChatPanel: ({ session, onCommit, + adjacentNodeSourceId, }: { session?: { threadId: string }; onCommit?: () => void; + adjacentNodeSourceId?: string; }) => ( -
+
), @@ -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/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index f110f829d..7e9d732fd 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -629,6 +629,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..be1b15983 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -629,6 +629,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. From 094f69676d1e4f26a3935e48c06f5693210ff9a4 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Mon, 24 Aug 2026 10:28:53 +0800 Subject: [PATCH 2/6] fix: preserve keyboard focus during canvas search --- .../CanvasSearchResults.test.ts | 51 +++++++++++++++++++ .../CanvasLayerPanel/CanvasSearchResults.tsx | 13 +++-- .../CanvasLayerPanel/canvasSearchKeyboard.ts | 22 ++++++++ docs/architecture/web-architecture.md | 2 + 4 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.test.ts create mode 100644 apps/web/src/components/Panels/CanvasLayerPanel/canvasSearchKeyboard.ts 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/canvasSearchKeyboard.ts b/apps/web/src/components/Panels/CanvasLayerPanel/canvasSearchKeyboard.ts new file mode 100644 index 000000000..6cb1fd4eb --- /dev/null +++ b/apps/web/src/components/Panels/CanvasLayerPanel/canvasSearchKeyboard.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { isEditableTarget } from '../../../hooks/shortcuts/isEditableTarget'; + +const INTERACTIVE_TARGET_SELECTOR = + 'input, textarea, button, a[href], select, [contenteditable="true"], [role="textbox"], [role="button"], [role="menuitem"]'; + +export function shouldCanvasSearchOwnKeyboard( + target: EventTarget | null, +): boolean { + if (!(target instanceof Element)) return false; + if ( + target.closest('[data-canvas-search-input], [data-canvas-search-results]') + ) { + return true; + } + if (isEditableTarget(target) || target.closest(INTERACTIVE_TARGET_SELECTOR)) { + return false; + } + return target.closest('[data-canvas-root]') !== null; +} diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index c1193a277..3c2a4f62f 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -137,6 +137,8 @@ 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. + 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. From c8d43bab520025d397a2be6320e85a82f917a855 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Tue, 25 Aug 2026 09:45:30 +0800 Subject: [PATCH 3/6] fix: align adjacent source attachment tiles --- .../Panels/ChatPanel/ChatInput.test.tsx | 66 ++++++++++++++++++- .../components/Panels/ChatPanel/ChatInput.tsx | 45 ++++++++----- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/Panels/ChatPanel/ChatInput.test.tsx b/apps/web/src/components/Panels/ChatPanel/ChatInput.test.tsx index db56cbdda..0ffec6728 100644 --- a/apps/web/src/components/Panels/ChatPanel/ChatInput.test.tsx +++ b/apps/web/src/components/Panels/ChatPanel/ChatInput.test.tsx @@ -9,8 +9,10 @@ import { ChatSessionProvider } from '@/hooks/useChatSession'; import { ChatInput } from './ChatInput'; +import type { ChatAttachment } from '@huabu/shared'; + const chatState = { - pendingAttachments: [], + pendingAttachments: [] as ChatAttachment[], selectionAttachment: null, addPendingAttachment: vi.fn(), removePendingAttachment: vi.fn(), @@ -217,10 +219,17 @@ describe('ChatInput', () => { ), ); - const candidate = container.querySelector( - 'button[aria-label="chat.addAdjacentNodeSource"]', + const candidate = container.querySelector( + '[role="button"][aria-label="chat.addAdjacentNodeSource"]', ); expect(candidate?.textContent).toContain('Adjacent note'); + expect(candidate?.classList.contains('border-dashed')).toBe(true); + const candidatePreview = candidate?.firstElementChild; + expect(candidatePreview?.classList.contains('h-12')).toBe(true); + expect(candidatePreview?.classList.contains('w-12')).toBe(true); + expect(candidatePreview?.querySelector('span')?.classList).toContain( + 'line-clamp-3', + ); act(() => candidate?.click()); @@ -231,5 +240,56 @@ describe('ChatInput', () => { label: 'Adjacent note', }); expect(onCommit).toHaveBeenCalledOnce(); + + chatState.pendingAttachments = [ + { + type: 'text', + source: 'selection', + originNodeId: 'node-adjacent', + label: 'Adjacent note', + }, + ]; + act(() => + root?.render( + + + , + ), + ); + + expect( + container.querySelector( + '[role="button"][aria-label="chat.addAdjacentNodeSource"]', + ), + ).toBeNull(); + const confirmedTile = container.querySelector( + '.group.border-edge-default:not(.border-dashed)', + ); + expect(confirmedTile?.firstElementChild?.classList.contains('h-12')).toBe( + true, + ); + expect(confirmedTile?.firstElementChild?.classList.contains('w-12')).toBe( + true, + ); + expect( + confirmedTile?.querySelector( + 'button[aria-label="chat.removeAttachment"]', + ), + ).not.toBeNull(); }); }); diff --git a/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx b/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx index 7ca2875f9..c9fafef7d 100644 --- a/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx +++ b/apps/web/src/components/Panels/ChatPanel/ChatInput.tsx @@ -103,6 +103,14 @@ interface ChatInputProps { connectedTop?: boolean; } +const AttachmentTextPreview = ({ text }: { text: string }) => ( +
+ + {text} + +
+); + export const ChatInput = ({ value, onChange, @@ -421,11 +429,11 @@ export const ChatInput = ({ !attachment.url, ) && ( - + +
)} {/* Selection attachment (from text highlight in expanded panel) */} @@ -509,11 +526,7 @@ export const ChatInput = ({ lockSelectionAttachment(); }} > -
- - {previewText} - -
+ + {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 && (