From 046a9c2c63bd0fc5cda6cf4801e08d0b10a5e42d Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Tue, 6 Oct 2026 22:17:22 +0200 Subject: [PATCH] Remove the separate Agent workspace tab --- client/AGENTS.md | 4 +- client/features/chat/ChatPanel.tsx | 9 +- .../chat/sessions/ChatSelector.test.ts | 8 +- client/features/views/ViewManager.tsx | 2 +- client/features/workspace/WorkspaceScreen.tsx | 169 ++++-------------- .../features/workspace/WorkspaceTabs.test.tsx | 4 +- client/features/workspace/layout-save.test.ts | 2 +- client/features/workspace/moi-context.test.ts | 1 - .../features/workspace/tab-resolution.test.ts | 39 ++-- client/features/workspace/tab-resolution.ts | 14 +- .../workspace/useWorkspaceNavigation.test.tsx | 3 +- .../workspace/useWorkspaceNavigation.ts | 23 +-- docs/navigation.md | 3 +- docs/rfc-intents-v2.md | 1 - lib/moi-context.ts | 1 - lib/navigation.test.ts | 5 +- lib/navigation.ts | 3 +- lib/types.ts | 6 +- lib/workspace-tabs.test.ts | 1 - lib/workspace-tabs.ts | 1 - server/cli.ts | 4 +- server/harness/openclaw/adapter.test.ts | 2 +- server/tabs.test.ts | 19 +- server/tabs.ts | 7 +- server/test/layout.test.ts | 18 +- server/test/moi-context.test.ts | 12 +- .../.claude/skills/moi-workspace/SKILL.md | 2 +- 27 files changed, 116 insertions(+), 247 deletions(-) diff --git a/client/AGENTS.md b/client/AGENTS.md index 106f9955..9ca49c72 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -7,8 +7,8 @@ Before editing host-app UI, read `../DESIGN.md` and the relevant rules in `../.a ## Entities - **Workspace** — Top-level container. Has a name and contains widgets and a chat. One workspace is active at a time. -- **Widget** — A configurable card inside the workspace's main area. Displayed in a grid. When there are no widgets, the chat fills the whole panel. -- **Chat** — The agent conversation. Contains a scrollable message list and an input. Always lives in the panel. Two modes: sidebar (a bounded pane beside the widgets, or the full panel when there are no widgets) and floating (popover). Floating only applies when there are widgets and they need the room. +- **Widget** — A configurable card inside Overview. Displayed in a grid. +- **Chat** — The agent conversation. Contains a scrollable message list and an input. Available on Overview, Scratchpad, and views through a docked sidebar or floating popup. ## Conventions diff --git a/client/features/chat/ChatPanel.tsx b/client/features/chat/ChatPanel.tsx index bc64f776..7a2f90b8 100644 --- a/client/features/chat/ChatPanel.tsx +++ b/client/features/chat/ChatPanel.tsx @@ -54,8 +54,6 @@ type ChatPanelProps = { // into the trailing assistant run so a thinking-only // preview folds into the current tool group. See client/lib/preview-turn.ts. previewTurn?: Turn | null - // Selected session id — used only as the scroll reset key (jump to bottom on - // session switch). sessionId?: string | null processing: boolean composerBanner?: ComposerBanner @@ -64,8 +62,7 @@ type ChatPanelProps = { send: (text: string, options?: ChatSendOptions) => Promise stop: () => void onNavigateFromWelcome: (destination: WelcomeDestination) => void - // Chat on a separate tab doesn't have a close button - onClose?: () => void + onClose: () => void viewDraft?: ViewChatDraft } @@ -181,7 +178,7 @@ export function ChatPanel({
- {onClose && docked && ( + {docked && ( Undock chat )} - {!viewDraft && onClose && !docked && ( + {!viewDraft && !docked && ( diff --git a/client/features/chat/sessions/ChatSelector.test.ts b/client/features/chat/sessions/ChatSelector.test.ts index f80c1233..d5b819e7 100644 --- a/client/features/chat/sessions/ChatSelector.test.ts +++ b/client/features/chat/sessions/ChatSelector.test.ts @@ -94,12 +94,6 @@ describe('groupSessionsForTab', () => { const groups = groupSessionsForTab( [ ...sessions, - { - sessionId: 'agent', - summary: 'Agent', - lastModified: now.getTime() - 4, - tabId: 'agent' - }, { sessionId: 'older', summary: 'Older', @@ -119,7 +113,7 @@ describe('groupSessionsForTab', () => { expect( groups.map(group => [group.label, group.sessions.map(session => session.sessionId)]) ).toEqual([ - ['Today', ['scratchpad', 'overview', 'generic', 'agent']], + ['Today', ['scratchpad', 'overview', 'generic']], ['Yesterday', ['older']] ]) expect(sessions.map(session => session.sessionId)).toEqual([ diff --git a/client/features/views/ViewManager.tsx b/client/features/views/ViewManager.tsx index d74ffd07..299bb872 100644 --- a/client/features/views/ViewManager.tsx +++ b/client/features/views/ViewManager.tsx @@ -42,7 +42,7 @@ type ViewManagerProps = { views: CompiledView[] // The view the active tab names, or null when another tab is on screen. The // manager stays mounted either way — that is what makes coming back from the - // agent or Overview tab instant too. + // Overview or Scratchpad tab instant too. activeViewId: string | null // The active view's URL query params. Parked views retain their last values. params: Record diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 04158e92..bf328bf4 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -6,7 +6,6 @@ import { IconBrowserPlus, IconLayout2, IconLayoutSidebarRight, - IconMessages, IconSketching } from '@tabler/icons-react' @@ -48,11 +47,7 @@ import { } from '@/client/features/chat/chat-store' import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' import { WorkspaceMenu } from '@/client/features/workspace/WorkspaceMenu' -import { - effectiveOpenTabs, - normalizeTabsState, - tabAvailable -} from '@/client/features/workspace/tab-resolution' +import { effectiveOpenTabs, tabAvailable } from '@/client/features/workspace/tab-resolution' import { useWorkspaceNavigation } from '@/client/features/workspace/useWorkspaceNavigation' import { cn } from '@/client/lib/cn' import { useWorkspaceEvent } from '@/client/runtime/useWorkspaceEvents' @@ -94,18 +89,8 @@ function tabItemFor( tab: WorkspaceTabId, views: ViewInfo[], closable: boolean, - agentRunning: boolean, viewRunning: (sessionId: string) => boolean ): WorkspaceTabItem | null { - if (tab === 'agent') { - return { - key: tab, - Icon: IconMessages, - label: 'Agent', - closable, - loading: agentRunning - } - } if (tab === 'overview') { return { key: tab, @@ -139,16 +124,6 @@ function tabItemFor( : null } -function applyVisibleTabOrder( - open: WorkspaceTabId[], - visible: WorkspaceTabId[], - orderedVisible: WorkspaceTabId[] -) { - const visibleSet = new Set(visible) - let cursor = 0 - return open.map(tab => (visibleSet.has(tab) ? orderedVisible[cursor++] : tab)) -} - export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenProps) { const { layout, setLayout, workspaceId } = useWorkspaceLayoutCtx() const theme = resolveWorkspaceTheme(layout.theme) @@ -176,20 +151,12 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro useWorkspaceTheme(layout.theme) - // Split needs the open set to decide whether it's available at all, and the - // navigation hook needs split to resolve the active tab — so the open set is - // derived from the raw layout here, before either. - const hasWorkspaceContent = effectiveOpenTabs(normalizeTabsState(layout.tabs), allViews).some( - tab => tab !== 'agent' - ) const hasAppletWidgets = widgets.some(widget => !isDefaultWidget(widget.id)) const hasWorkspaceApplets = hasAppletWidgets || views.length > 0 - // Effective layout mode. Split is only visible with workspace content and - // enough row width; the saved mode remains the user's intent. - const wantsSplit = layout.layoutMode === 'split' && hasWorkspaceContent + // Split needs enough row width; the saved mode remains the user's intent. + const wantsSplit = layout.layoutMode === 'split' const mode: LayoutMode = wantsSplit && canUseSplit ? 'split' : 'fullscreen' - const dockedSplit = mode === 'split' // The tab address: URL in, active tab + applet params out, plus the persisted // tab state it keeps in sync. See useWorkspaceNavigation for the invariants. @@ -202,8 +169,7 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro isUnavailable, onNavigationClick } = useWorkspaceNavigation({ - views: allViews, - split: dockedSplit + views: allViews }) // Chat comes after navigation, and takes the address: every message carries @@ -237,21 +203,16 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro ) const openTabIds = effectiveOpenTabs(tabsState, allViews) - const nonAgentOpenTabs = openTabIds.filter(tab => tab !== 'agent') const openSet = new Set(tabsState.open) - // Entering split with the agent tab on screen needs no special-casing - // anymore: the URL resolution below derives a visible tab and the redirect - // effect makes the URL follow it (replace). const setMode = (m: LayoutMode) => { setLayout({ layoutMode: m }) } - const visibleTabIds = dockedSplit ? nonAgentOpenTabs : openTabIds const canCloseTabs = openTabIds.length > 1 - const tabItems = visibleTabIds + const tabItems = openTabIds .map(tab => - tabItemFor(tab, allViews, canCloseTabs, hasRunningSession, sessionId => + tabItemFor(tab, allViews, canCloseTabs, sessionId => isSessionRunning(sessionActivity, workspaceId, sessionId) ) ) @@ -302,10 +263,10 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro }, [allViews, setTabs, tabsState]) useEffect(() => { - if (mode !== 'fullscreen' || activeTab === 'agent') { + if (mode !== 'fullscreen') { setFloatingChatOpen(false) } - }, [activeTab, mode]) + }, [mode]) useEffect(() => { if (!activeDraftViewId) return @@ -313,15 +274,7 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro setChatFocusRequest(request => request + 1) }, [activeDraftViewId, mode]) - // Tab switching is navigation; the saved default and the open set follow via - // the navigation hook. Only the chat side effects belong to the screen. - const openTab = (tab: WorkspaceTabId) => { - navigateToTab(tab) - if (tab === 'agent') { - setFloatingChatOpen(false) - setChatFocusRequest(request => request + 1) - } - } + const openTab = navigateToTab const createView = () => { // A pending view has no chat until its first submission. @@ -373,10 +326,10 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro let open = tabsState.open.filter(t => t !== tab) if (open.length === 0) open = ['overview'] // The neighbor that takes over when the tab on screen closes. - const visibleIndex = visibleTabIds.indexOf(tab) + const visibleIndex = openTabIds.indexOf(tab) const nextTab = - visibleTabIds[visibleIndex + 1] ?? - visibleTabIds[visibleIndex - 1] ?? + openTabIds[visibleIndex + 1] ?? + openTabIds[visibleIndex - 1] ?? open.find(t => tabAvailable(t, allViews)) ?? 'overview' const active = @@ -401,9 +354,7 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro if (activeTab === tab) navigateToTab(nextTab, { replace: true }) } - const reorderTabs = (orderedVisibleTabs: WorkspaceTabId[]) => { - const open = applyVisibleTabOrder(tabsState.open, visibleTabIds, orderedVisibleTabs) - if (open === tabsState.open) return + const reorderTabs = (open: WorkspaceTabId[]) => { setTabs({ open, active: tabsState.active }) } @@ -413,7 +364,7 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro .getState() .setComposerDraft(composerDraftKey(workspaceId, composerSessionId), intent) } - if (mode === 'fullscreen' && activeTab !== 'agent') { + if (mode === 'fullscreen') { setFloatingChatOpen(true) if (floatingChatOpen) { setChatFocusRequest(request => request + 1) @@ -430,16 +381,6 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro useAppletChatAttachment(composerSessionId, openChat) const createItems: CreateWorkspaceTabItem[] = [ - ...(!dockedSplit && !openSet.has('agent') - ? ([ - { - key: 'agent', - Icon: IconMessages, - label: 'Agent', - onClick: () => openTab('agent') - } - ] satisfies CreateWorkspaceTabItem[]) - : []), ...(!openSet.has('scratchpad') ? ([ { @@ -490,50 +431,34 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro } : undefined - // The docked split chat. Full-screen Agent uses the tabbed chat below. + const chatPanelProps = { + agent: theme.agent, + focusRequest: chatFocusRequest, + chatLoaded: visibleChatLoaded, + hasWorkspaceApplets, + view, + forkedFromSessionId, + previewTurn, + sessionId, + processing, + composerBanner: activeDraftView ? viewDraftComposerBanner : composerBanner, + agentAvailability, + send: sendChat, + stop, + onNavigateFromWelcome: navigateFromWelcome, + viewDraft: viewChatDraft + } + const dockedChat = ( setMode('fullscreen')} annotation={dockedAnnotation} - viewDraft={viewChatDraft} docked /> ) - const tabbedChat = ( - - ) const workspacePanel = (
{isUnavailable ? ( openTab('overview')} /> - ) : activeTab === 'agent' ? ( - tabbedChat ) : activeTab === 'overview' ? ( openChat()} onDiscard={() => discardPendingView(pendingView)} /> ))} @@ -643,10 +566,10 @@ export function WorkspaceScreen({ widgets, views: allViews }: WorkspaceScreenPro
tabItemFor(tab, allViews, false, false, () => false)} + describeTab={tab => tabItemFor(tab, allViews, false, () => false)} onOpenTab={openTab} /> - {hasWorkspaceContent && canUseSplit && mode === 'fullscreen' && ( + {canUseSplit && mode === 'fullscreen' && (
- {hasWorkspaceContent && canUseSplit && splitLayoutConstraints ? ( + {canUseSplit && splitLayoutConstraints ? ( - {mode === 'fullscreen' && activeTab !== 'agent' && hasWorkspaceContent && ( + {mode === 'fullscreen' && ( {onClose => ( )} diff --git a/client/features/workspace/WorkspaceTabs.test.tsx b/client/features/workspace/WorkspaceTabs.test.tsx index d8abd099..bf27ac76 100644 --- a/client/features/workspace/WorkspaceTabs.test.tsx +++ b/client/features/workspace/WorkspaceTabs.test.tsx @@ -41,8 +41,8 @@ describe('WorkspaceTabs', () => { expect(html).not.toContain('mo-root') expect(html).toContain('data-slot="spinner"') const overviewButton = html.match(/]*aria-label="Overview"[^>]*>/)?.[0] - const agentButton = html.match(/]*aria-label="Test view"[^>]*>/)?.[0] + const viewButton = html.match(/]*aria-label="Test view"[^>]*>/)?.[0] expect(overviewButton).not.toContain('aria-roledescription') - expect(agentButton).toContain('aria-roledescription="sortable item"') + expect(viewButton).toContain('aria-roledescription="sortable item"') }) }) diff --git a/client/features/workspace/layout-save.test.ts b/client/features/workspace/layout-save.test.ts index a0c211ec..4007a7bc 100644 --- a/client/features/workspace/layout-save.test.ts +++ b/client/features/workspace/layout-save.test.ts @@ -49,7 +49,7 @@ describe('debounced layout saves', () => { test('the latest explicit navigation replaces the previous pending tab selection', () => { const pending = accumulateLayoutSave({ ...initial, tabs: selected }, { tabs: selected }, null) - const tabs: WorkspaceTabsState = { open: ['overview', 'agent'], active: 'agent' } + const tabs: WorkspaceTabsState = { open: ['overview', 'scratchpad'], active: 'scratchpad' } const payload = accumulateLayoutSave({ ...initial, tabs }, { tabs }, pending) expect(payload.tabs).toEqual(tabs) diff --git a/client/features/workspace/moi-context.test.ts b/client/features/workspace/moi-context.test.ts index 0ae82f51..77afbfa0 100644 --- a/client/features/workspace/moi-context.test.ts +++ b/client/features/workspace/moi-context.test.ts @@ -70,7 +70,6 @@ describe('envelopeTabParams', () => { // Widgets are not navigation targets, and the static tabs take no params — // so a stray record here means nothing and must not reach the envelope. expect(envelopeTabParams('overview', params)).toBeUndefined() - expect(envelopeTabParams('agent', params)).toBeUndefined() expect(envelopeTabParams('scratchpad', params)).toBeUndefined() }) }) diff --git a/client/features/workspace/tab-resolution.test.ts b/client/features/workspace/tab-resolution.test.ts index 1d39f179..62ad911d 100644 --- a/client/features/workspace/tab-resolution.test.ts +++ b/client/features/workspace/tab-resolution.test.ts @@ -29,16 +29,18 @@ describe('normalizeTabsState', () => { open: ['overview', 'scratchpad'], active: 'overview' }) - expect(normalizeTabsState(tabs([], 'agent'))).toEqual({ + expect(normalizeTabsState(tabs([], 'scratchpad'))).toEqual({ open: ['overview', 'scratchpad'], active: 'overview' }) }) test('pins Overview first, dedupes open, and preserves a valid active tab', () => { - expect(normalizeTabsState(tabs(['agent', 'overview', 'agent'], 'agent'))).toEqual({ - open: ['overview', 'agent'], - active: 'agent' + expect( + normalizeTabsState(tabs(['scratchpad', 'overview', 'scratchpad'], 'scratchpad')) + ).toEqual({ + open: ['overview', 'scratchpad'], + active: 'scratchpad' }) }) }) @@ -46,7 +48,6 @@ describe('normalizeTabsState', () => { describe('tabAvailable', () => { test('static tabs always exist', () => { expect(tabAvailable('overview', [])).toBe(true) - expect(tabAvailable('agent', [])).toBe(true) expect(tabAvailable('scratchpad', [])).toBe(true) }) @@ -60,8 +61,8 @@ describe('tabAvailable', () => { describe('effectiveOpenTabs', () => { test('filters unavailable tabs and keeps order', () => { expect( - effectiveOpenTabs(tabs(['views/gone', 'agent', 'views/orders'], 'agent'), views) - ).toEqual(['agent', 'views/orders']) + effectiveOpenTabs(tabs(['views/gone', 'scratchpad', 'views/orders'], 'scratchpad'), views) + ).toEqual(['scratchpad', 'views/orders']) }) test('falls back to the default open set when nothing survives', () => { @@ -73,38 +74,28 @@ describe('effectiveOpenTabs', () => { }) describe('resolveActiveTab', () => { - const state = tabs(['overview', 'agent', 'views/orders'], 'overview') + const state = tabs(['overview', 'scratchpad', 'views/orders'], 'overview') test('a bare URL resolves to the saved default', () => { - expect(resolveActiveTab(null, state, views, false)).toBe('overview') + expect(resolveActiveTab(null, state, views)).toBe('overview') }) test('a valid URL tab wins, even when not in the open set', () => { - expect(resolveActiveTab('views/orders', state, views, false)).toBe('views/orders') - expect(resolveActiveTab('scratchpad', state, views, false)).toBe('scratchpad') + expect(resolveActiveTab('views/orders', state, views)).toBe('views/orders') + expect(resolveActiveTab('scratchpad', state, views)).toBe('scratchpad') }) test('an unavailable explicit destination stays selected for recovery', () => { - expect(resolveActiveTab('views/gone', state, views, false)).toBe('views/gone') + expect(resolveActiveTab('views/gone', state, views)).toBe('views/gone') }) test('an unavailable saved default falls back to the first surviving tab', () => { const stale = tabs(['views/gone', 'views/orders'], 'views/gone') - expect(resolveActiveTab(null, stale, views, false)).toBe('views/orders') + expect(resolveActiveTab(null, stale, views)).toBe('views/orders') }) test('when nothing survives, the default open set answers', () => { const dead = tabs(['views/gone'], 'views/gone') - expect(resolveActiveTab(null, dead, [], false)).toBe('overview') - }) - - test('split mode: agent is not a workspace tab, a visible tab is derived', () => { - expect(resolveActiveTab('agent', state, views, true)).toBe('overview') - const agentDefault = tabs(['agent', 'views/orders'], 'agent') - expect(resolveActiveTab(null, agentDefault, views, true)).toBe('views/orders') - }) - - test('split mode: non-agent URL tabs still win', () => { - expect(resolveActiveTab('views/orders', state, views, true)).toBe('views/orders') + expect(resolveActiveTab(null, dead, [])).toBe('overview') }) }) diff --git a/client/features/workspace/tab-resolution.ts b/client/features/workspace/tab-resolution.ts index 0851a153..11c73dfa 100644 --- a/client/features/workspace/tab-resolution.ts +++ b/client/features/workspace/tab-resolution.ts @@ -15,7 +15,7 @@ export function normalizeTabsState(tabs: WorkspaceTabsState | undefined): Worksp // Whether a tab id points at something that exists: static tabs always do, // view tabs only while their pending or compiled view exists. export function tabAvailable(tab: WorkspaceTabId, views: ViewInfo[]) { - if (tab === 'agent' || tab === 'overview' || tab === 'scratchpad') return true + if (tab === 'overview' || tab === 'scratchpad') return true const viewId = viewIdFromTab(tab) return viewId ? views.some(view => view.id === viewId) : false } @@ -28,18 +28,16 @@ export function effectiveOpenTabs(tabs: WorkspaceTabsState, views: ViewInfo[]): } // An explicit URL keeps its destination, including a missing view so the host -// can show recovery and report the correct address to the agent. Only a bare -// URL or the singleton chat hidden by split mode uses the saved default. +// can show recovery and report the correct address to the agent. A bare URL +// uses the saved default. export function resolveActiveTab( requested: WorkspaceTabId | null, tabs: WorkspaceTabsState, - views: ViewInfo[], - split: boolean + views: ViewInfo[] ): WorkspaceTabId { - if (requested !== null && !(split && requested === 'agent')) { + if (requested !== null) { return requested } const open = effectiveOpenTabs(tabs, views) - const visible = split ? open.filter(tab => tab !== 'agent') : open - return visible.includes(tabs.active) ? tabs.active : (visible[0] ?? 'overview') + return open.includes(tabs.active) ? tabs.active : (open[0] ?? 'overview') } diff --git a/client/features/workspace/useWorkspaceNavigation.test.tsx b/client/features/workspace/useWorkspaceNavigation.test.tsx index bb8b8818..b166ed19 100644 --- a/client/features/workspace/useWorkspaceNavigation.test.tsx +++ b/client/features/workspace/useWorkspaceNavigation.test.tsx @@ -25,8 +25,7 @@ function readNavigation( const prefix = base === '/' ? '' : base function Probe() { const { activeTab, appletParams, isUnavailable } = useWorkspaceNavigation({ - views, - split: false + views }) return (