Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/shared/src/i18n/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '暂无终端会话',
Expand Down Expand Up @@ -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',
Expand Down
57 changes: 37 additions & 20 deletions app/workbench/src/AgentHubWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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 = {
Expand All @@ -135,7 +152,7 @@ export function AgentHubWorkbench(props: AgentHubWorkbenchProps): React.ReactEle
props,
activePage,
isChatPage,
layout,
layout: managedLayout,
session,
transcriptChrome,
profile,
Expand Down
54 changes: 54 additions & 0 deletions app/workbench/src/ChatEngineeringColumn.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
156 changes: 156 additions & 0 deletions app/workbench/src/ChatEngineeringColumn.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ChatEngineeringColumn
inspector={<div data-testid="inspector-detail" data-mode="files" />}
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(
<ChatEngineeringColumn
inspector={<div data-testid="inspector-detail" data-mode="files" />}
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(
<ChatEngineeringColumn
inspector={<div data-testid="inspector-detail" data-mode="files" />}
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<string | undefined> = [];
const listener = (event: Event) => {
detailEvents.push((event as CustomEvent<{ mode?: string }>).detail?.mode);
};
window.addEventListener(WORKBENCH_INSPECTOR_QUICK_OPEN_EVENT, listener);

render(
<ChatEngineeringColumn
inspector={<div data-testid="inspector-detail" data-mode="overview" />}
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(
<ChatEngineeringColumn
inspector={<div />}
hasWorkspace={false}
localFiles
conversationId="conv-idle"
platform={platform}
/>,
);
fireEvent.click(auxTab('预览'));
expect(screen.getByText(/不会构造虚假地址或内容/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: '在详情中查看' })).toBeDisabled();
});
});
Loading
Loading