diff --git a/app/shared/src/i18n/workbench.ts b/app/shared/src/i18n/workbench.ts index cdd89fa01..2b846493d 100644 --- a/app/shared/src/i18n/workbench.ts +++ b/app/shared/src/i18n/workbench.ts @@ -60,6 +60,20 @@ export const sharedWorkbenchResources = { quickOpenTerminal: '终端', addMenu: '右侧窗口菜单', }, + engineering: { + session: '会话', + files: '文件', + changes: '变更', + preview: '预览', + commits: '提交', + noSessions: '暂无本地运行会话', + filesHostOnly: '工作区文件树仅由 Desktop host 填充', + changesHostOnly: 'Git 变更列表仅由 Desktop host 填充', + commitsHostOnly: '提交历史仅由 Desktop host 填充', + previewLatest: '最新产物', + openDetails: '在详情中查看', + previewUnavailable: '尚无可预览产物;此客户端不会构造虚假地址或内容。', + }, terminal: { ariaLabel: '本地终端', emptyTitle: '暂无终端会话', @@ -396,6 +410,20 @@ export const sharedWorkbenchResources = { quickOpenTerminal: 'Terminal', addMenu: 'Right panel menu', }, + engineering: { + session: 'Session', + files: 'Files', + changes: 'Changes', + preview: 'Preview', + commits: 'Commits', + noSessions: 'No local runtime sessions', + filesHostOnly: 'The workspace file tree is provided by the Desktop host only.', + changesHostOnly: 'Git changes are provided by the Desktop host only.', + commitsHostOnly: 'Commit history is provided by the Desktop host only.', + previewLatest: 'Latest artifact', + openDetails: 'View details', + previewUnavailable: 'No previewable artifact yet. This client will not invent a URL or content.', + }, terminal: { ariaLabel: 'Local terminal', emptyTitle: 'No terminal sessions', diff --git a/app/workbench/src/AgentHubWorkbench.tsx b/app/workbench/src/AgentHubWorkbench.tsx index 100a7f3da..a44da1fef 100644 --- a/app/workbench/src/AgentHubWorkbench.tsx +++ b/app/workbench/src/AgentHubWorkbench.tsx @@ -14,6 +14,10 @@ import { GlobalSearchDialog } from './GlobalSearchDialog'; import { WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT } from './desktopChromeEvents'; import { isEditableKeyboardTarget } from './workbenchSessionChromeHelpers'; import { WorkbenchFrame } from './WorkbenchFrame'; +import { + engineeringColumnActivitySignal, + useEngineeringColumnAutoOpen, +} from './useEngineeringColumnAutoOpen'; import { WorkbenchProfileOverlays } from './WorkbenchProfileOverlays'; import { WorkbenchTranscriptOverlays } from './WorkbenchTranscriptOverlays'; import { @@ -73,24 +77,6 @@ export function AgentHubWorkbench(props: AgentHubWorkbenchProps): React.ReactEle setActivePage, }); - // #1822: shared global dispatcher (Web + Desktop): search / settings / - // toggle-sidebar / toggle-run-panel / inspector quick-open. Reads - // resolved groups so custom keybindings actually take effect. - const handleQuickOpen = useCallback((): void => { - layout.openInspector(); - window.dispatchEvent(new CustomEvent(WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT, { - detail: { mode: 'files' }, - })); - }, [layout]); - - useWorkbenchGlobalShortcuts({ - onSearch: () => setGlobalSearchOpen(true), - onOpenSettings: () => setActivePage('settings'), - onToggleSidebar: layout.toggleSidebar, - onToggleRunPanel: layout.toggleInspector, - onQuickOpen: handleQuickOpen, - }); - const handleGlobalSearchSelect = useCallback((conversationId: string): void => { props.onActiveConversationChange?.(conversationId); setGlobalSearchOpen(false); @@ -109,11 +95,42 @@ export function AgentHubWorkbench(props: AgentHubWorkbenchProps): React.ReactEle t: translate, })); + const activitySignal = engineeringColumnActivitySignal({ + isAgentRunning: props.isAgentRunning, + runtimeEvidence: props.runtimeEvidence, + }); + const engineeringColumn = useEngineeringColumnAutoOpen({ + conversationId: session.currentConversationId, + isChatPage, + platformSurface: platform.surface, + activitySignal, + layout, + }); + const managedLayout = engineeringColumn.layout; + + // #1822 + #1964: the global run-panel shortcut is a manual toggle, so it + // participates in the same per-conversation suppression contract as the + // header button. Quick-open is an explicit request and always expands. + const handleQuickOpen = useCallback((): void => { + managedLayout.openInspector(); + window.dispatchEvent(new CustomEvent(WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT, { + detail: { mode: 'files' }, + })); + }, [managedLayout]); + + useWorkbenchGlobalShortcuts({ + onSearch: () => setGlobalSearchOpen(true), + onOpenSettings: () => setActivePage('settings'), + onToggleSidebar: managedLayout.toggleSidebar, + onToggleRunPanel: engineeringColumn.toggleInspector, + onQuickOpen: handleQuickOpen, + }); + const transcriptChrome = useWorkbenchTranscriptChrome(buildTranscriptChromeOptions({ props, t: translate, session, - layout, + layout: managedLayout, })); transcriptHelpersRef.current = { @@ -135,7 +152,7 @@ export function AgentHubWorkbench(props: AgentHubWorkbenchProps): React.ReactEle props, activePage, isChatPage, - layout, + layout: managedLayout, session, transcriptChrome, profile, diff --git a/app/workbench/src/ChatEngineeringColumn.module.css b/app/workbench/src/ChatEngineeringColumn.module.css index bdd5acd71..275253927 100644 --- a/app/workbench/src/ChatEngineeringColumn.module.css +++ b/app/workbench/src/ChatEngineeringColumn.module.css @@ -59,3 +59,57 @@ font-size: 11px; color: var(--td-ink-muted); } + +.previewPane { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.previewToolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-2); + min-height: 32px; + padding: 4px 8px; + border-bottom: 1px solid var(--td-line); + color: var(--td-ink-muted); + font-size: 12px; +} + +.previewDetailButton { + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--td-line); + border-radius: var(--td-radius-control); + background: var(--td-surface); + color: var(--td-ink); + font: inherit; + cursor: pointer; +} + +.previewDetailButton:hover:not(:disabled), +.previewDetailButton:focus-visible { + border-color: var(--td-accent); + box-shadow: var(--focus-ring); + outline: none; +} + +.previewDetailButton:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.previewBody { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.previewBody > * { + min-width: 0; + width: 100%; +} diff --git a/app/workbench/src/ChatEngineeringColumn.test.tsx b/app/workbench/src/ChatEngineeringColumn.test.tsx new file mode 100644 index 000000000..f5224c846 --- /dev/null +++ b/app/workbench/src/ChatEngineeringColumn.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { useTestI18nLanguage } from '@shared/testing/i18n'; +import type { AgentHubPlatform } from '@shared/platform'; +import type { RuntimeEvidenceSnapshot } from '@shared/inspector'; +import { + ChatEngineeringColumn, + engineeringPreviewSignal, + resolveEngineeringPreview, +} from './ChatEngineeringColumn'; +import { WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT } from './desktopChromeEvents'; + +beforeAll(async () => { + await useTestI18nLanguage('zh'); +}); + +const platform = { + surface: 'desktop', + capabilities: { + localEdge: true, + localFiles: true, + browserPreview: true, + localTerminal: true, + }, + conversations: { list: async () => [] }, + runs: { submitComposerIntent: async () => ({ intentId: 'x' }) }, +} as unknown as AgentHubPlatform; + +function evidence(id: string, path = `reports/${id}.md`): RuntimeEvidenceSnapshot { + return { + runId: 'run-1', + diffs: [], + artifacts: [{ + id, + runId: 'run-1', + threadId: 'thread-1', + kind: 'file', + path, + sizeBytes: 20, + createdAt: `2026-08-25T00:00:0${id.length}.000Z`, + }], + previews: [], + }; +} + +function auxTab(name: string): HTMLElement { + return within(screen.getByRole('tablist', { name: 'Aux panel' })).getByRole('tab', { name }); +} + +describe('ChatEngineeringColumn Preview (#1966)', () => { + it('resolves real preview evidence without constructing a URL', () => { + expect(engineeringPreviewSignal(undefined)).toBeNull(); + const resolved = resolveEngineeringPreview(evidence('artifact-1')); + expect(resolved?.kind).toBe('file'); + if (resolved?.kind === 'file') { + expect(resolved.file.name).toBe('reports/artifact-1.md'); + expect(resolved.file.contentRef).toEqual({ + kind: 'artifact', runId: 'run-1', id: 'artifact-1', + }); + expect(resolved.file.content).not.toContain('http://'); + } + }); + + it('auto-focuses Preview for new artifacts without changing inspector detail state', () => { + const { rerender } = render( + } + hasWorkspace + localFiles + conversationId="conv-a" + runtimeEvidence={evidence('artifact-1')} + platform={platform} + />, + ); + + expect(auxTab('预览')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('engineering-preview-pane')).toBeInTheDocument(); + expect(screen.getByTestId('inspector-detail')).toHaveAttribute('data-mode', 'files'); + + fireEvent.click(auxTab('会话')); + expect(auxTab('会话')).toHaveAttribute('aria-selected', 'true'); + + // Same evidence must not fight the user's selected aux tab. + rerender( + } + hasWorkspace + localFiles + conversationId="conv-a" + runtimeEvidence={evidence('artifact-1')} + platform={platform} + />, + ); + expect(auxTab('会话')).toHaveAttribute('aria-selected', 'true'); + + // A genuinely new artifact focuses Preview, while inspector remains details-only. + rerender( + } + hasWorkspace + localFiles + conversationId="conv-a" + runtimeEvidence={evidence('artifact-2')} + platform={platform} + />, + ); + expect(auxTab('预览')).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByTestId('inspector-detail')).toHaveAttribute('data-mode', 'files'); + }); + + it('keeps the tab keyboard reachable and switches inspector only on explicit details action', () => { + const detailEvents: Array = []; + const listener = (event: Event) => { + detailEvents.push((event as CustomEvent<{ mode?: string }>).detail?.mode); + }; + window.addEventListener(WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT, listener); + + render( + } + hasWorkspace + localFiles + conversationId="conv-a" + runtimeEvidence={evidence('artifact-1')} + platform={platform} + />, + ); + + const previewTab = auxTab('预览'); + previewTab.focus(); + fireEvent.keyDown(previewTab, { key: 'ArrowRight' }); + expect(document.activeElement).toBe(auxTab('提交')); + + fireEvent.click(screen.getByRole('button', { name: '在详情中查看' })); + expect(detailEvents).toEqual(['files']); + expect(screen.getByTestId('inspector-detail')).toHaveAttribute('data-mode', 'overview'); + + window.removeEventListener(WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT, listener); + }); + + it('shows an honest empty state and disables details when no content exists', () => { + render( + } + hasWorkspace={false} + localFiles + conversationId="conv-idle" + platform={platform} + />, + ); + fireEvent.click(auxTab('预览')); + expect(screen.getByText(/不会构造虚假地址或内容/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '在详情中查看' })).toBeDisabled(); + }); +}); diff --git a/app/workbench/src/ChatEngineeringColumn.tsx b/app/workbench/src/ChatEngineeringColumn.tsx index 5eb45a09a..726669b42 100644 --- a/app/workbench/src/ChatEngineeringColumn.tsx +++ b/app/workbench/src/ChatEngineeringColumn.tsx @@ -1,4 +1,7 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { RuntimeEvidenceSnapshot } from '@shared/inspector'; +import { SHARED_WORKBENCH_I18N_NAMESPACE } from '@shared/i18n'; import type { AgentHubPlatform, RuntimeSessionSummary, @@ -6,12 +9,19 @@ import type { WorkspaceGitChange, WorkspaceGitCommit, } from '@shared/platform'; +import { + BrowserPreview, + FilePreviewRouter, + runtimeEvidenceOverviewFiles, + type PreviewFile, +} from './inspector'; import { AuxPanel, resolveAvailableAuxTabs, resolveEffectiveAuxTab, type AuxPanelTab, } from './auxPanel'; +import { WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT } from './desktopChromeEvents'; import shellStyles from './AgentHubWorkbench.module.css'; import styles from './ChatEngineeringColumn.module.css'; @@ -19,6 +29,8 @@ export type ChatEngineeringColumnProps = { inspector: React.ReactNode; hasWorkspace: boolean; localFiles: boolean; + conversationId: string; + runtimeEvidence?: RuntimeEvidenceSnapshot | undefined; platform?: AgentHubPlatform | undefined; workDir?: string | undefined; /** #1823: when the inspector column is collapsed, the whole column — @@ -27,46 +39,104 @@ export type ChatEngineeringColumnProps = { inspectorCollapsed?: boolean | undefined; }; -const LABELS: Record = { - session_details: '会话', - file_tree: '文件', - changes: '变更', - git_log: '提交', -}; +export function engineeringPreviewSignal( + runtimeEvidence: RuntimeEvidenceSnapshot | undefined, +): string | null { + const artifact = runtimeEvidence?.artifacts.at(-1); + const preview = runtimeEvidence?.previews.at(-1); + if (!artifact && !preview) return null; + return [ + artifact ? `artifact:${artifact.id}:${artifact.createdAt ?? ''}` : 'artifact:none', + preview ? `preview:${preview.id}:${preview.status}:${preview.url ?? ''}` : 'preview:none', + ].join('|'); +} + +export function resolveEngineeringPreview( + runtimeEvidence: RuntimeEvidenceSnapshot | undefined, +): { kind: 'browser'; url: string } | { kind: 'file'; file: PreviewFile } | null { + if (!runtimeEvidence) return null; + const browserPreview = [...runtimeEvidence.previews] + .reverse() + .find((preview) => preview.status === 'ready' && Boolean(preview.url)); + if (browserPreview?.url) return { kind: 'browser', url: browserPreview.url }; + const artifactFiles = runtimeEvidenceOverviewFiles(runtimeEvidence).slice( + 0, + runtimeEvidence.artifacts.length, + ); + const file = artifactFiles.at(-1); + return file ? { kind: 'file', file } : null; +} /** - * Desktop engineering-loop column: RightInspector + AuxPanel stack (#1181). - * Folder-scoped aux tabs require hasWorkspace && localFiles. - * Folder tabs filled via WorkspaceFilesPort / WorkspaceGitPort when present (#1191). - * Shell width / collapse chrome lives on `.engineeringColumn`. + * Engineering-loop column: inspector detail above, fast-switch aux surface below. + * Preview follows the newest normalized artifact/preview but never changes the + * inspector's selected detail mode. The explicit "details" action is the only + * bridge that asks RightInspector to switch, preventing two surfaces from + * fighting for focus (#1966). */ export function ChatEngineeringColumn({ inspector, hasWorkspace, localFiles, + conversationId, + runtimeEvidence, platform, workDir, inspectorCollapsed, }: ChatEngineeringColumnProps): React.ReactElement { + const { t } = useTranslation(SHARED_WORKBENCH_I18N_NAMESPACE); + const preview = useMemo(() => resolveEngineeringPreview(runtimeEvidence), [runtimeEvidence]); + const previewSignal = engineeringPreviewSignal(runtimeEvidence); + const previewAvailable = localFiles || Boolean(previewSignal); const available = useMemo( - () => resolveAvailableAuxTabs({ hasWorkspace, localFiles }), - [hasWorkspace, localFiles], + () => resolveAvailableAuxTabs({ hasWorkspace, localFiles, previewAvailable }), + [hasWorkspace, localFiles, previewAvailable], ); - const [activeTab, setActiveTab] = useState('session_details'); + const [activeTab, setActiveTab] = useState(() => + previewSignal ? 'preview' : available[0] ?? 'session_details', + ); + const activeTabByConversation = useRef(new Map()); + const consumedPreviewSignals = useRef(new Map()); const effective = resolveEffectiveAuxTab(activeTab, available); const [files, setFiles] = useState([]); const [changes, setChanges] = useState([]); const [commits, setCommits] = useState([]); - // #1821: the 会话 tab used to be a static placeholder. Wire it to the real - // host runtime-session list when the port exists; otherwise show an honest - // empty state instead of fake content. const [runtimeSessions, setRuntimeSessions] = useState([]); + const labels = useMemo>(() => ({ + session_details: t('engineering.session'), + file_tree: t('engineering.files'), + changes: t('engineering.changes'), + preview: t('engineering.preview'), + git_log: t('engineering.commits'), + }), [t]); + + const selectTab = useCallback((tab: AuxPanelTab): void => { + activeTabByConversation.current.set(conversationId, tab); + setActiveTab(tab); + }, [conversationId]); + + useEffect(() => { + const remembered = activeTabByConversation.current.get(conversationId); + setActiveTab(remembered ?? (previewSignal ? 'preview' : available[0] ?? 'session_details')); + }, [conversationId]); // available and signal are handled by the effects below. + useEffect(() => { if (effective !== activeTab) setActiveTab(effective); }, [effective, activeTab]); useEffect(() => { + if (!previewSignal) return; + if (consumedPreviewSignals.current.get(conversationId) === previewSignal) return; + consumedPreviewSignals.current.set(conversationId, previewSignal); + activeTabByConversation.current.set(conversationId, 'preview'); + setActiveTab('preview'); + }, [conversationId, previewSignal]); + + useEffect(() => { + if (!platform?.workspaceFiles?.list && !platform?.workspaceGit?.listChanges && !platform?.workspaceGit?.listLog) { + return undefined; + } let cancelled = false; const root = workDir?.trim() || undefined; (async () => { @@ -92,13 +162,12 @@ export function ChatEngineeringColumn({ }; }, [platform, workDir, hasWorkspace]); - // #1821: 会话 tab — real host runtime sessions (Desktop port) or an honest - // empty state; no more static placeholder. useEffect(() => { + if (!platform?.host?.listRuntimeSessions) return undefined; let cancelled = false; (async () => { try { - const next = (await platform?.host?.listRuntimeSessions?.()) ?? []; + const next = (await platform.host?.listRuntimeSessions?.()) ?? []; if (!cancelled) setRuntimeSessions(next); } catch { if (!cancelled) setRuntimeSessions([]); @@ -109,10 +178,19 @@ export function ChatEngineeringColumn({ }; }, [platform]); + const openPreviewDetails = useCallback((): void => { + window.dispatchEvent(new CustomEvent(WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT, { + detail: { mode: preview?.kind === 'browser' ? 'browser' : 'files' }, + })); + }, [preview?.kind]); + return ( {inspector} @@ -120,15 +198,16 @@ export function ChatEngineeringColumn({ {{ session_details: ( {runtimeSessions.length === 0 ? ( - 暂无本地运行会话 + {t('engineering.noSessions')} ) : ( runtimeSessions.map((session) => ( @@ -142,12 +221,12 @@ export function ChatEngineeringColumn({ file_tree: ( {files.length === 0 ? ( - 工作区文件树(由 Desktop host 填充) + {t('engineering.filesHostOnly')} ) : ( - files.map((f) => ( - - {f.kind === 'dir' ? 'DIR' : 'FILE'} - {f.path} + files.map((file) => ( + + {file.kind === 'dir' ? 'DIR' : 'FILE'} + {file.path} )) )} @@ -156,26 +235,54 @@ export function ChatEngineeringColumn({ changes: ( {changes.length === 0 ? ( - Git 变更列表(由 Desktop host 填充) + {t('engineering.changesHostOnly')} ) : ( - changes.map((c) => ( - - {c.status} - {c.path} + changes.map((change) => ( + + {change.status} + {change.path} )) )} ), + preview: ( + + + {t('engineering.previewLatest')} + + {t('engineering.openDetails')} + + + + {!preview ? ( + {t('engineering.previewUnavailable')} + ) : preview.kind === 'browser' ? ( + undefined} /> + ) : ( + selectTab(available[0] ?? 'session_details')} + /> + )} + + + ), git_log: ( {commits.length === 0 ? ( - 提交历史(由 Desktop host 填充) + {t('engineering.commitsHostOnly')} ) : ( - commits.map((c) => ( - - {c.hash.slice(0, 7)} - {c.subject} + commits.map((commit) => ( + + {commit.hash.slice(0, 7)} + {commit.subject} )) )} diff --git a/app/workbench/src/WorkbenchFrameParts.test.tsx b/app/workbench/src/WorkbenchFrameParts.test.tsx index e23ddafbc..f40bb33fa 100644 --- a/app/workbench/src/WorkbenchFrameParts.test.tsx +++ b/app/workbench/src/WorkbenchFrameParts.test.tsx @@ -448,6 +448,36 @@ describe('WorkbenchFrameParts', () => { expect(screen.queryByTestId('aux-panel')).toBeNull(); }); + it('mounts Hub-only Preview aux surface on Web only when runtime evidence exists', () => { + const session = sessionMock(); + render( + , + ); + const column = screen.getByTestId('chat-engineering-column'); + expect(column.getAttribute('data-local-files')).toBe('false'); + expect(screen.getByTestId('right-inspector')).toBeTruthy(); + }); + it('stacks AuxPanel column when localFiles is true (Desktop)', () => { const session = sessionMock({ composer: { workDir: '/tmp/workspace' } }); render( diff --git a/app/workbench/src/WorkbenchFrameParts.tsx b/app/workbench/src/WorkbenchFrameParts.tsx index 5d11ed660..b504c16e1 100644 --- a/app/workbench/src/WorkbenchFrameParts.tsx +++ b/app/workbench/src/WorkbenchFrameParts.tsx @@ -108,7 +108,8 @@ export function WorkbenchRoutesFrame( * RightInspector host for chat page. * When `platform.capabilities.localFiles` (Desktop), stacks AuxPanel below * the inspector for the local engineering-loop chrome (#1181). - * Web keeps RightInspector only. + * Web adds the Preview aux surface only when normalized Hub evidence exists; + * it never gains local workspace/host capabilities (#1966). */ export function ChatInspectorFrame( props: ChatInspectorFrameProps, @@ -119,9 +120,12 @@ export function ChatInspectorFrame( ); const localFiles = Boolean(props.platform.capabilities.localFiles); - if (!localFiles) { - return inspector; - } + const hasRuntimePreview = Boolean( + props.runtimeEvidence?.artifacts?.length || props.runtimeEvidence?.previews?.length, + ); + // Web remains Hub-only: it gets the normalized preview surface only when + // Hub evidence exists; no Local Edge/file tabs or host URLs are invented. + if (!localFiles && !hasRuntimePreview) return inspector; const workDir = resolveComposerWorkDir(props.session.composer?.workDir); const hasWorkspace = Boolean(workDir); return ( @@ -129,6 +133,8 @@ export function ChatInspectorFrame( inspector={inspector} hasWorkspace={hasWorkspace} localFiles={localFiles} + conversationId={props.session.currentConversationId} + runtimeEvidence={props.runtimeEvidence} platform={props.platform} inspectorCollapsed={props.inspectorCollapsed} {...(workDir ? { workDir } : {})} diff --git a/app/workbench/src/__tests__/helpers.tsx b/app/workbench/src/__tests__/helpers.tsx index b44f89703..237d8cfb1 100644 --- a/app/workbench/src/__tests__/helpers.tsx +++ b/app/workbench/src/__tests__/helpers.tsx @@ -102,6 +102,7 @@ export function installWorkbenchTestHooks(): void { // mounts (useWorkbenchPanelLayout reads them in its state initializers). window.localStorage.removeItem('agenthub.workbench.inspectorWidth'); window.localStorage.removeItem('agenthub.workbench.inspectorCollapsed'); + window.localStorage.removeItem('agenthub.workbench.engineeringColumnByConversation.v1'); }); } diff --git a/app/workbench/src/__tests__/inspector.test.tsx b/app/workbench/src/__tests__/inspector.test.tsx index eb42e3280..c099d415f 100644 --- a/app/workbench/src/__tests__/inspector.test.tsx +++ b/app/workbench/src/__tests__/inspector.test.tsx @@ -142,8 +142,8 @@ describe('AgentHubWorkbench', () => { fireEvent.click(screen.getByRole('tab', { name: /文件/ })); fireEvent.click(screen.getByRole('button', { name: '打开预览 preview-1' })); expect(screen.getByRole('tab', { name: /浏览器/ })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByText('http://127.0.0.1:4173/preview')).toBeInTheDocument(); - const runtimePreviewRegion = screen.getByRole('region', { name: '内置浏览器预览' }); + expect(screen.getAllByText('http://127.0.0.1:4173/preview').length).toBeGreaterThanOrEqual(1); + const runtimePreviewRegion = within(screen.getByRole('complementary', { name: '右侧窗口' })).getByRole('region', { name: '内置浏览器预览' }); await waitFor(() => expect(document.activeElement).toBe(runtimePreviewRegion)); }); diff --git a/app/workbench/src/auxPanel/AuxPanel.test.tsx b/app/workbench/src/auxPanel/AuxPanel.test.tsx index 5a35b3d35..92b926785 100644 --- a/app/workbench/src/auxPanel/AuxPanel.test.tsx +++ b/app/workbench/src/auxPanel/AuxPanel.test.tsx @@ -7,6 +7,7 @@ const labels: Record = { session_details: '会话详情', file_tree: '文件树', changes: '变更', + preview: '预览', git_log: 'Git 日志', }; @@ -22,6 +23,7 @@ function renderAux(activeTab: AuxPanelTab = 'session_details'): { onActiveTabCha session_details: details, file_tree: tree, changes: changes, + preview: preview, git_log: log, }} />, @@ -111,6 +113,7 @@ describe('AuxPanel tablist roving tabindex (#1823)', () => { session_details: details, file_tree: tree, changes: changes, + preview: preview, git_log: log, }} />, diff --git a/app/workbench/src/auxPanel/AuxPanel.tsx b/app/workbench/src/auxPanel/AuxPanel.tsx index ae1c47f3e..d45be47c9 100644 --- a/app/workbench/src/auxPanel/AuxPanel.tsx +++ b/app/workbench/src/auxPanel/AuxPanel.tsx @@ -9,6 +9,7 @@ import styles from './AuxPanel.module.css'; export type AuxPanelProps = { hasWorkspace: boolean; localFiles?: boolean; + previewAvailable?: boolean; activeTab: AuxPanelTab; onActiveTabChange: (tab: AuxPanelTab) => void; labels: Record; @@ -23,6 +24,7 @@ export type AuxPanelProps = { export function AuxPanel({ hasWorkspace, localFiles = true, + previewAvailable = false, activeTab, onActiveTabChange, labels, @@ -30,8 +32,8 @@ export function AuxPanel({ className, }: AuxPanelProps): React.ReactElement { const available = useMemo( - () => resolveAvailableAuxTabs({ hasWorkspace, localFiles }), - [hasWorkspace, localFiles], + () => resolveAvailableAuxTabs({ hasWorkspace, localFiles, previewAvailable }), + [hasWorkspace, localFiles, previewAvailable], ); const effective = resolveEffectiveAuxTab(activeTab, available); diff --git a/app/workbench/src/auxPanel/resolveAuxTabs.test.ts b/app/workbench/src/auxPanel/resolveAuxTabs.test.ts index 64295a84a..bd3ed71aa 100644 --- a/app/workbench/src/auxPanel/resolveAuxTabs.test.ts +++ b/app/workbench/src/auxPanel/resolveAuxTabs.test.ts @@ -16,6 +16,12 @@ describe('resolveAvailableAuxTabs (#1172)', () => { ]); }); + it('exposes preview without local file capability when runtime evidence exists', () => { + expect(resolveAvailableAuxTabs({ hasWorkspace: false, localFiles: false, previewAvailable: true })).toEqual([ + 'preview', + ]); + }); + it('full tab order with workspace + localFiles', () => { expect(resolveAvailableAuxTabs({ hasWorkspace: true, localFiles: true })).toEqual([ 'session_details', @@ -24,6 +30,16 @@ describe('resolveAvailableAuxTabs (#1172)', () => { 'git_log', ]); }); + + it('places preview beside the engineering tabs when enabled', () => { + expect(resolveAvailableAuxTabs({ hasWorkspace: true, localFiles: true, previewAvailable: true })).toEqual([ + 'session_details', + 'file_tree', + 'changes', + 'preview', + 'git_log', + ]); + }); }); describe('resolveEffectiveAuxTab (#1172)', () => { diff --git a/app/workbench/src/auxPanel/resolveAuxTabs.ts b/app/workbench/src/auxPanel/resolveAuxTabs.ts index 60714cac4..9d618e175 100644 --- a/app/workbench/src/auxPanel/resolveAuxTabs.ts +++ b/app/workbench/src/auxPanel/resolveAuxTabs.ts @@ -7,10 +7,13 @@ import { export function resolveAvailableAuxTabs(input: AuxPanelAvailabilityInput): AuxPanelTab[] { const localFiles = input.localFiles !== false; - if (!input.hasWorkspace || !localFiles) { - return ['session_details']; + const tabs: AuxPanelTab[] = localFiles ? ['session_details'] : []; + if (input.hasWorkspace && localFiles) { + tabs.push('file_tree', 'changes'); } - return [...AUX_PANEL_TAB_ORDER]; + if (input.previewAvailable) tabs.push('preview'); + if (input.hasWorkspace && localFiles) tabs.push('git_log'); + return tabs.length > 0 ? tabs : ['session_details']; } /** diff --git a/app/workbench/src/auxPanel/types.ts b/app/workbench/src/auxPanel/types.ts index cf5a6009d..38c0a41dc 100644 --- a/app/workbench/src/auxPanel/types.ts +++ b/app/workbench/src/auxPanel/types.ts @@ -3,12 +3,13 @@ * Folder-scoped tabs require an open workspace; content is slot-driven only. */ -export type AuxPanelTab = 'session_details' | 'file_tree' | 'changes' | 'git_log'; +export type AuxPanelTab = 'session_details' | 'file_tree' | 'changes' | 'preview' | 'git_log'; export const AUX_PANEL_TAB_ORDER: readonly AuxPanelTab[] = [ 'session_details', 'file_tree', 'changes', + 'preview', 'git_log', ] as const; @@ -23,4 +24,6 @@ export type AuxPanelAvailabilityInput = { hasWorkspace: boolean; /** When false (Web), folder-scoped tabs stay hidden. Defaults true for Desktop. */ localFiles?: boolean; + /** Preview is available from normalized runtime evidence on any surface. */ + previewAvailable?: boolean; }; diff --git a/app/workbench/src/useEngineeringColumnAutoOpen.test.tsx b/app/workbench/src/useEngineeringColumnAutoOpen.test.tsx new file mode 100644 index 000000000..8bf764d88 --- /dev/null +++ b/app/workbench/src/useEngineeringColumnAutoOpen.test.tsx @@ -0,0 +1,117 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WorkbenchPanelLayout } from './useWorkbenchPanelLayout'; +import { + canAutoOpenEngineeringColumn, + engineeringColumnActivitySignal, + useEngineeringColumnAutoOpen, +} from './useEngineeringColumnAutoOpen'; +import { + readEngineeringColumnPreference, + writeEngineeringColumnPreference, +} from './workbenchPreferences'; + +function layoutMock(collapsed: boolean) { + return { + inspectorCollapsed: collapsed, + openInspector: vi.fn(), + closeInspector: vi.fn(), + toggleInspector: vi.fn(), + } as unknown as WorkbenchPanelLayout; +} + +describe('engineeringColumnActivitySignal (#1964)', () => { + it('stays idle for pure chat and changes for active runs or new artifacts', () => { + expect(engineeringColumnActivitySignal({})).toBeNull(); + expect(engineeringColumnActivitySignal({ isAgentRunning: true })).toContain('run:active'); + expect(engineeringColumnActivitySignal({ + runtimeEvidence: { + runId: 'run-1', + diffs: [], + artifacts: [{ + id: 'artifact-1', runId: 'run-1', threadId: 'thread-1', kind: 'file', path: 'out.md', sizeBytes: 2, + }], + previews: [], + }, + })).toContain('artifact:artifact-1'); + }); + + it('protects narrow layouts from automatic expansion', () => { + expect(canAutoOpenEngineeringColumn('desktop', 800)).toBe(false); + expect(canAutoOpenEngineeringColumn('desktop', 1440)).toBe(true); + expect(canAutoOpenEngineeringColumn('web', 768)).toBe(true); + expect(canAutoOpenEngineeringColumn('mobile', 1440)).toBe(false); + }); +}); + +describe('useEngineeringColumnAutoOpen (#1964)', () => { + beforeEach(() => { + window.localStorage.clear(); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 }); + }); + + it('auto-expands for an active run', async () => { + const layout = layoutMock(true); + renderHook(() => useEngineeringColumnAutoOpen({ + conversationId: 'conv-a', + isChatPage: true, + platformSurface: 'desktop', + activitySignal: 'run:active', + layout, + })); + await waitFor(() => expect(layout.openInspector).toHaveBeenCalled()); + expect(readEngineeringColumnPreference('conv-a')).toEqual({ + collapsed: false, + autoOpenSuppressed: false, + }); + }); + + it('persists manual collapse suppression and ignores later artifacts in that conversation', async () => { + const expanded = layoutMock(false); + const { result, rerender } = renderHook( + ({ signal, layout }) => useEngineeringColumnAutoOpen({ + conversationId: 'conv-a', + isChatPage: true, + platformSurface: 'desktop', + activitySignal: signal, + layout, + }), + { initialProps: { signal: 'run:active', layout: expanded } }, + ); + await waitFor(() => expect(expanded.openInspector).toHaveBeenCalledTimes(1)); + act(() => result.current.toggleInspector()); + expect(expanded.closeInspector).toHaveBeenCalledTimes(1); + expect(readEngineeringColumnPreference('conv-a')).toEqual({ + collapsed: true, + autoOpenSuppressed: true, + }); + + const collapsed = layoutMock(true); + rerender({ signal: 'run:active|artifact:new', layout: collapsed }); + await Promise.resolve(); + expect(collapsed.openInspector).not.toHaveBeenCalled(); + }); + + it('restores isolated conversation preferences when switching sessions', async () => { + writeEngineeringColumnPreference('conv-a', { collapsed: true, autoOpenSuppressed: true }); + writeEngineeringColumnPreference('conv-b', { collapsed: false, autoOpenSuppressed: false }); + const layout = layoutMock(false); + const { rerender } = renderHook( + ({ conversationId }) => useEngineeringColumnAutoOpen({ + conversationId, + isChatPage: true, + platformSurface: 'desktop', + activitySignal: 'run:active', + layout, + }), + { initialProps: { conversationId: 'conv-a' } }, + ); + await waitFor(() => expect(layout.closeInspector).toHaveBeenCalledTimes(1)); + + rerender({ conversationId: 'conv-b' }); + await waitFor(() => expect(layout.openInspector).toHaveBeenCalled()); + + rerender({ conversationId: 'conv-a' }); + await waitFor(() => expect(layout.closeInspector).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/app/workbench/src/useEngineeringColumnAutoOpen.ts b/app/workbench/src/useEngineeringColumnAutoOpen.ts new file mode 100644 index 000000000..3d874cd88 --- /dev/null +++ b/app/workbench/src/useEngineeringColumnAutoOpen.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { RuntimeEvidenceSnapshot } from '@shared/inspector'; +import type { AgentHubPlatform } from '@shared/platform'; +import type { WorkbenchPanelLayout } from './useWorkbenchPanelLayout'; +import { + readEngineeringColumnPreference, + writeEngineeringColumnPreference, +} from './workbenchPreferences'; +import { WORKSPACE_MOUNT_COLLAPSE_INSPECTOR_WIDTH } from './workbenchLayoutConstants'; + +export interface EngineeringColumnActivityInput { + isAgentRunning?: boolean | undefined; + runtimeEvidence?: RuntimeEvidenceSnapshot | undefined; +} + +export function engineeringColumnActivitySignal({ + isAgentRunning, + runtimeEvidence, +}: EngineeringColumnActivityInput): string | null { + const loading = runtimeEvidence?.loading; + const hasActiveRun = Boolean( + isAgentRunning + || loading?.diff + || loading?.artifacts + || loading?.previews, + ); + const artifacts = runtimeEvidence?.artifacts ?? []; + const previews = runtimeEvidence?.previews ?? []; + const newestArtifact = artifacts.at(-1); + const newestPreview = previews.at(-1); + if (!hasActiveRun && !newestArtifact && !newestPreview) return null; + return [ + hasActiveRun ? `run:${runtimeEvidence?.runId ?? 'active'}` : 'run:none', + newestArtifact ? `artifact:${newestArtifact.id}:${newestArtifact.createdAt ?? ''}` : 'artifact:none', + newestPreview + ? `preview:${newestPreview.id}:${newestPreview.status}:${newestPreview.url ?? ''}` + : 'preview:none', + ].join('|'); +} + +export function canAutoOpenEngineeringColumn( + surface: AgentHubPlatform['surface'], + viewportWidth: number, +): boolean { + if (surface === 'mobile') return false; + if (surface === 'desktop') return viewportWidth >= WORKSPACE_MOUNT_COLLAPSE_INSPECTOR_WIDTH; + return viewportWidth >= 720; +} + +export function useEngineeringColumnAutoOpen(params: { + conversationId: string; + isChatPage: boolean; + platformSurface: AgentHubPlatform['surface']; + activitySignal: string | null; + layout: WorkbenchPanelLayout; +}): { + layout: WorkbenchPanelLayout; + toggleInspector: () => void; +} { + const { + conversationId, + isChatPage, + platformSurface, + activitySignal, + layout, + } = params; + const previousConversationId = useRef(null); + const consumedSignals = useRef(new Map()); + + useEffect(() => { + if (!isChatPage || !conversationId) return; + const preference = readEngineeringColumnPreference(conversationId); + const switchedConversation = previousConversationId.current !== conversationId; + previousConversationId.current = conversationId; + + if (switchedConversation && preference) { + if (preference.collapsed) layout.closeInspector(); + else layout.openInspector(); + } + + if (!activitySignal || preference?.autoOpenSuppressed) return; + const alreadyConsumed = consumedSignals.current.get(conversationId) === activitySignal; + if (alreadyConsumed && !switchedConversation) return; + consumedSignals.current.set(conversationId, activitySignal); + + const viewportWidth = typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth; + if (!canAutoOpenEngineeringColumn(platformSurface, viewportWidth)) return; + layout.openInspector(); + writeEngineeringColumnPreference(conversationId, { + collapsed: false, + autoOpenSuppressed: false, + }); + }, [ + activitySignal, + conversationId, + isChatPage, + layout.closeInspector, + layout.openInspector, + platformSurface, + ]); + + const toggleInspector = useCallback((): void => { + const nextCollapsed = !layout.inspectorCollapsed; + if (conversationId) { + writeEngineeringColumnPreference(conversationId, { + collapsed: nextCollapsed, + // Suppression is created only when the user closes an active surface. + // Closing an idle chat still allows its first future run to auto-open. + autoOpenSuppressed: nextCollapsed && Boolean(activitySignal), + }); + } + if (nextCollapsed) layout.closeInspector(); + else layout.openInspector(); + }, [activitySignal, conversationId, layout.closeInspector, layout.inspectorCollapsed, layout.openInspector]); + + const managedLayout = useMemo( + () => ({ ...layout, toggleInspector }), + [layout, toggleInspector], + ); + + return { layout: managedLayout, toggleInspector }; +} diff --git a/app/workbench/src/useWorkbenchPanelLayout.ts b/app/workbench/src/useWorkbenchPanelLayout.ts index ef3c27f2b..a58d0982e 100644 --- a/app/workbench/src/useWorkbenchPanelLayout.ts +++ b/app/workbench/src/useWorkbenchPanelLayout.ts @@ -163,6 +163,10 @@ export function useWorkbenchPanelLayout({ setInspectorCollapsed(false); }, [restoreInspectorWidth]); + const closeInspector = useCallback((): void => { + setInspectorCollapsed(true); + }, []); + const collapseSidebarForWorkspacePressure = useCallback((nextInspectorWidth: number): void => { maybeCollapseSidebarForWorkspacePressure({ isChatPage, @@ -377,6 +381,7 @@ export function useWorkbenchPanelLayout({ resizeInspectorBy, resizeSidebarBy, openInspector, + closeInspector, restoreInspectorWidth, restoreSidebarWidth, }); diff --git a/app/workbench/src/workbenchPanelLayoutHelpers.ts b/app/workbench/src/workbenchPanelLayoutHelpers.ts index ac697a979..bfcb99e68 100644 --- a/app/workbench/src/workbenchPanelLayoutHelpers.ts +++ b/app/workbench/src/workbenchPanelLayoutHelpers.ts @@ -55,6 +55,7 @@ export interface WorkbenchPanelLayout { resizeInspectorBy: (delta: number) => void; resizeSidebarBy: (delta: number) => void; openInspector: (width?: number) => void; + closeInspector: () => void; restoreInspectorWidth: (width?: number) => void; restoreSidebarWidth: (width?: number) => void; shellStyle: React.CSSProperties; @@ -467,6 +468,7 @@ export function buildWorkbenchPanelLayoutResult(params: { resizeInspectorBy: (delta: number) => void; resizeSidebarBy: (delta: number) => void; openInspector: (width?: number) => void; + closeInspector: () => void; restoreInspectorWidth: (width?: number) => void; restoreSidebarWidth: (width?: number) => void; }): WorkbenchPanelLayout { @@ -490,6 +492,7 @@ export function buildWorkbenchPanelLayoutResult(params: { resizeInspectorBy: params.resizeInspectorBy, resizeSidebarBy: params.resizeSidebarBy, openInspector: params.openInspector, + closeInspector: params.closeInspector, restoreInspectorWidth: params.restoreInspectorWidth, restoreSidebarWidth: params.restoreSidebarWidth, shellStyle: buildPanelShellStyle({ diff --git a/app/workbench/src/workbenchPreferences.test.ts b/app/workbench/src/workbenchPreferences.test.ts index bd4fe02bf..e27047b85 100644 --- a/app/workbench/src/workbenchPreferences.test.ts +++ b/app/workbench/src/workbenchPreferences.test.ts @@ -4,9 +4,12 @@ import { composerSubmitBehaviorLabel, normalizeComposerSubmitBehavior, readComposerSubmitBehavior, + readEngineeringColumnPreference, subscribeWorkbenchPreference, WORKBENCH_COMPOSER_SUBMIT_BEHAVIOR_KEY, + WORKBENCH_ENGINEERING_COLUMN_PREFERENCES_KEY, writeComposerSubmitBehavior, + writeEngineeringColumnPreference, } from './workbenchPreferences'; describe('normalizeComposerSubmitBehavior', () => { @@ -97,3 +100,41 @@ describe('subscribeWorkbenchPreference', () => { unsubscribeSecond(); }); }); + + +describe('engineering column conversation preferences (#1964)', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('persists collapse and auto-open suppression per conversation', () => { + writeEngineeringColumnPreference('conv-a', { collapsed: true, autoOpenSuppressed: true }); + writeEngineeringColumnPreference('conv-b', { collapsed: false, autoOpenSuppressed: false }); + + expect(readEngineeringColumnPreference('conv-a')).toEqual({ + collapsed: true, + autoOpenSuppressed: true, + }); + expect(readEngineeringColumnPreference('conv-b')).toEqual({ + collapsed: false, + autoOpenSuppressed: false, + }); + expect(readEngineeringColumnPreference('conv-c')).toBeUndefined(); + expect(window.localStorage.getItem(WORKBENCH_ENGINEERING_COLUMN_PREFERENCES_KEY)).toContain('conv-a'); + }); + + it('fails closed on malformed storage without leaking preferences across sessions', () => { + window.localStorage.setItem(WORKBENCH_ENGINEERING_COLUMN_PREFERENCES_KEY, '{bad-json'); + expect(readEngineeringColumnPreference('conv-a')).toBeUndefined(); + + window.localStorage.setItem( + WORKBENCH_ENGINEERING_COLUMN_PREFERENCES_KEY, + JSON.stringify({ 'conv-a': { collapsed: 'yes' }, 'conv-b': { collapsed: true } }), + ); + expect(readEngineeringColumnPreference('conv-a')).toBeUndefined(); + expect(readEngineeringColumnPreference('conv-b')).toEqual({ + collapsed: true, + autoOpenSuppressed: false, + }); + }); +}); diff --git a/app/workbench/src/workbenchPreferences.ts b/app/workbench/src/workbenchPreferences.ts index 749f9fcf4..32ca16661 100644 --- a/app/workbench/src/workbenchPreferences.ts +++ b/app/workbench/src/workbenchPreferences.ts @@ -2,7 +2,14 @@ import { useSyncExternalStore } from 'react'; export type ComposerSubmitBehavior = 'enter-send' | 'ctrl-enter-send'; +export interface EngineeringColumnConversationPreference { + collapsed: boolean; + autoOpenSuppressed: boolean; +} + export const WORKBENCH_COMPOSER_SUBMIT_BEHAVIOR_KEY = 'agenthub.workbench.composerSubmitBehavior'; +export const WORKBENCH_ENGINEERING_COLUMN_PREFERENCES_KEY = + 'agenthub.workbench.engineeringColumnByConversation.v1'; const DEFAULT_COMPOSER_SUBMIT_BEHAVIOR: ComposerSubmitBehavior = 'enter-send'; const listeners = new Set<() => void>(); @@ -11,6 +18,56 @@ function emitPreferenceChange(): void { for (const listener of listeners) listener(); } +function readEngineeringColumnPreferenceMap(): Record { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(WORKBENCH_ENGINEERING_COLUMN_PREFERENCES_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const result: Record = {}; + for (const [conversationId, value] of Object.entries(parsed)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const candidate = value as Partial; + if (typeof candidate.collapsed !== 'boolean') continue; + result[conversationId] = { + collapsed: candidate.collapsed, + autoOpenSuppressed: candidate.autoOpenSuppressed === true, + }; + } + return result; + } catch { + return {}; + } +} + +export function readEngineeringColumnPreference( + conversationId: string | null | undefined, +): EngineeringColumnConversationPreference | undefined { + const normalized = conversationId?.trim(); + if (!normalized) return undefined; + return readEngineeringColumnPreferenceMap()[normalized]; +} + +export function writeEngineeringColumnPreference( + conversationId: string, + preference: EngineeringColumnConversationPreference, +): void { + const normalized = conversationId.trim(); + if (!normalized || typeof window === 'undefined') return; + try { + const current = readEngineeringColumnPreferenceMap(); + current[normalized] = preference; + window.localStorage.setItem( + WORKBENCH_ENGINEERING_COLUMN_PREFERENCES_KEY, + JSON.stringify(current), + ); + } catch { + // Quota / private-mode: the current interaction still works in memory. + } + emitPreferenceChange(); +} + export function normalizeComposerSubmitBehavior(value: string | null | undefined): ComposerSubmitBehavior { return value === 'ctrl-enter-send' || value === 'Ctrl+Enter 发送' ? 'ctrl-enter-send' diff --git a/docs/architecture/04-frontend-data-flow.md b/docs/architecture/04-frontend-data-flow.md index f00aa83c1..8ca291caf 100644 --- a/docs/architecture/04-frontend-data-flow.md +++ b/docs/architecture/04-frontend-data-flow.md @@ -1,21 +1,14 @@ # Frontend Data Flow -> 子文档 | 主索引:[architecture.md](../architecture.md) +> 子文档 | 主索引:[architecture.md](../architecture.md) · Hub API:[01-hub-server.md](01-hub-server.md) > -> 最后更新:2026-08-24 +> 最后更新:2026-08-25 本文档只记录 Desktop/Web/Mobile 共享前端的数据流合同和 source owner。具体 hook、组件 props、测试用例数量以源码和测试为准,不在这里复制清单。 ## 设计边界 -前端分三层: - -```text -Desktop/Web/Mobile app shell - -> platform adapter - -> @agenthub/workbench shell + app/shared chatview - -> transcript normalizer + renderer -``` +前端分三层:`app shell(Desktop/Web/Mobile) -> platform adapter -> @agenthub/workbench shell + app/shared chatview -> transcript normalizer + renderer`。 规则: @@ -60,6 +53,12 @@ UI 可以根据 `capabilities` 隐藏或禁用动作,但不能 fork 另一套 Do not add per-hook inventory tables here. If a file moves, update this owner map and the nearest README/source test, not a duplicate implementation checklist. +## 工程列焦点合同 + +- 自动展开:active run 或新产物 evidence 可自动展开工程列;用户在运行中手动收回时按会话持久化抑制,切换会话恢复各自选择;无 active run/产物的纯聊天不改变布局默认。窄视口可延后自动展开以保留可恢复的聊天表面,头部按钮与键盘切换仍可用。 +- Preview 焦点:工程列 `Preview` 标签跟随最新规范化 preview/artifact evidence,但不修改 `RightInspector` 当前详情标签;只有显式“在详情中查看 / View details”动作才请求 inspector 切换到 Browser 或 Files,避免快览与详情表面争焦点。 +- 表面边界:Desktop 可经 Local Edge 支持的 `PreviewPort` 解析产物内容,Web 保持 Hub-only;Hub 未提供安全 preview URL/content endpoint 时,Preview 必须诚实显示不可用,不构造 Local Edge URL。 + ## Transcript Pipeline The visible chat flow is a single timeline: @@ -138,11 +137,6 @@ Stubbed Hub, fixture, readiness-only and manifest-only outputs must set `real_te | Data boundary | `app/shared/src/testing/e2eDataModeContract.ts` plus surface-specific E2E assertions | | Packaged Desktop | Tauri package/sidecar/icon/installer evidence, not Vite-only | -## Related Docs - -- [architecture.md](../architecture.md) — system overview and non-negotiable boundaries -- [01-hub-server.md](01-hub-server.md) — Hub API and realtime ownership - ## 前端 CI 易踩坑(站立规则) - `exactOptionalPropertyTypes`:禁 `...{ optional: maybeUndefined }`,defined 时赋值;async handler 传 `() => void` 用 `void fn()` 包装。`noUncheckedIndexedAccess`:CSS module / `Record` 索引用 `styles.foo ?? ''`。