From 4b37f34310143357f697f46dc72b86a69b906986 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 20 Apr 2026 21:05:23 -0400 Subject: [PATCH 1/4] feat(web): URL-addressable Space views + overlay history + /settings route - Add /settings route with dedicated URL, back/forward support, and proper signal cleanup in router and App URL-sync - Add pushOverlayHistory/closeOverlayHistory for Space Agent Overlay, pushing a marker history entry (no URL change) so browser back closes the overlay - Replace bare signal assignments with router calls in SpaceTaskPane, SpaceTaskCardFeed, SpaceIsland, Inbox, and ChatContainer - Add overlay + settings cases to popstate handler and initializeRouter - Add unit tests for overlay history and settings route (overlay-history, settings-router) --- packages/web/src/App.tsx | 7 +- packages/web/src/components/inbox/Inbox.tsx | 10 +- .../src/components/space/SpaceTaskPane.tsx | 14 +- .../space/__tests__/SpaceTaskPane.test.tsx | 9 +- .../thread/compact/SpaceTaskCardFeed.tsx | 5 +- packages/web/src/islands/ChatContainer.tsx | 5 +- packages/web/src/islands/SpaceIsland.tsx | 16 +- .../islands/__tests__/SpaceIsland.test.tsx | 2 + .../src/lib/__tests__/overlay-history.test.ts | 158 ++++++++++++++++++ .../src/lib/__tests__/settings-router.test.ts | 137 +++++++++++++++ packages/web/src/lib/router.ts | 129 +++++++++++++- 11 files changed, 457 insertions(+), 35 deletions(-) create mode 100644 packages/web/src/lib/__tests__/overlay-history.test.ts create mode 100644 packages/web/src/lib/__tests__/settings-router.test.ts diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index b531ad8b3..147d67535 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -43,6 +43,7 @@ import { navigateToSpaceAgent, navigateToSpaceSession, navigateToSpaceTask, + navigateToSettings, createSessionPath, createRoomPath, createRoomAgentPath, @@ -166,7 +167,9 @@ export function App() { ? '/spaces' : navSection === 'chats' ? '/sessions' - : '/'; + : navSection === 'settings' + ? '/settings' + : '/'; // Only update URL if it's out of sync // This prevents unnecessary history updates and loops @@ -201,6 +204,8 @@ export function App() { navigateToSpacesPage(true); } else if (navSection === 'chats') { // Already at /sessions or no navigation needed + } else if (navSection === 'settings') { + navigateToSettings(true); } else { navigateToHome(true); } diff --git a/packages/web/src/components/inbox/Inbox.tsx b/packages/web/src/components/inbox/Inbox.tsx index b8b2cbfb4..4f4841142 100644 --- a/packages/web/src/components/inbox/Inbox.tsx +++ b/packages/web/src/components/inbox/Inbox.tsx @@ -2,11 +2,7 @@ import { useEffect, useState } from 'preact/hooks'; import { inboxStore, type InboxTask } from '../../lib/inbox-store.ts'; import { Spinner } from '../ui/Spinner.tsx'; import { toast } from '../../lib/toast.ts'; -import { - currentRoomIdSignal, - currentRoomTaskIdSignal, - navSectionSignal, -} from '../../lib/signals.ts'; +import { navigateToRoomTask } from '../../lib/router.ts'; function InboxTaskCard({ item, @@ -25,9 +21,7 @@ function InboxTaskCard({ const anyApproving = approvingId !== null; const handleView = () => { - navSectionSignal.value = 'rooms'; - currentRoomIdSignal.value = item.roomId; - currentRoomTaskIdSignal.value = item.task.id; + navigateToRoomTask(item.roomId, item.task.id); }; const handleApprove = async () => { diff --git a/packages/web/src/components/space/SpaceTaskPane.tsx b/packages/web/src/components/space/SpaceTaskPane.tsx index 45a753233..42a31b1fb 100644 --- a/packages/web/src/components/space/SpaceTaskPane.tsx +++ b/packages/web/src/components/space/SpaceTaskPane.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'preact/hooks'; import { spaceStore } from '../../lib/space-store'; -import { spaceOverlaySessionIdSignal, spaceOverlayAgentNameSignal } from '../../lib/signals'; +import { pushOverlayHistory } from '../../lib/router'; import type { SpaceTaskActivityMember, SpaceTaskActivityState, @@ -198,23 +198,20 @@ export function SpaceTaskPane({ taskId, spaceId, onClose }: SpaceTaskPaneProps) (m) => m.kind === 'node_agent' && agentDisplayNames.includes(m.label) ); if (nodeMember) { - spaceOverlayAgentNameSignal.value = nodeMember.label; - spaceOverlaySessionIdSignal.value = nodeMember.sessionId; + pushOverlayHistory(nodeMember.sessionId, nodeMember.label); return; } // Fall back to the task agent session (coordinator/leader) const taskAgentMember = activityMembers.find((m) => m.kind === 'task_agent'); if (taskAgentMember) { - spaceOverlayAgentNameSignal.value = taskAgentMember.label; - spaceOverlaySessionIdSignal.value = taskAgentMember.sessionId; + pushOverlayHistory(taskAgentMember.sessionId, taskAgentMember.label); return; } // Last resort: use the task’s own agentSessionId if (agentSessionId) { - spaceOverlayAgentNameSignal.value = agentActionLabel; - spaceOverlaySessionIdSignal.value = agentSessionId; + pushOverlayHistory(agentSessionId, agentActionLabel); } }; @@ -262,8 +259,7 @@ export function SpaceTaskPane({ taskId, spaceId, onClose }: SpaceTaskPaneProps) ...activityMembers.map((member) => ({ label: `Open ${member.label} (${ACTIVITY_STATE_LABELS[member.state]})`, onClick: () => { - spaceOverlayAgentNameSignal.value = member.label; - spaceOverlaySessionIdSignal.value = member.sessionId; + pushOverlayHistory(member.sessionId, member.label); }, })) ); diff --git a/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx b/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx index ca552ae52..789bdd381 100644 --- a/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx +++ b/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx @@ -10,9 +10,16 @@ import type { SpaceWorkflowRun, } from '@neokai/shared'; -const { mockNavigateToSpaceAgent } = vi.hoisted(() => ({ mockNavigateToSpaceAgent: vi.fn() })); +const { mockNavigateToSpaceAgent, mockPushOverlayHistory } = vi.hoisted(() => ({ + mockNavigateToSpaceAgent: vi.fn(), + mockPushOverlayHistory: vi.fn((sessionId: string, agentName?: string) => { + mockSpaceOverlaySessionIdSignal.value = sessionId; + mockSpaceOverlayAgentNameSignal.value = agentName ?? null; + }), +})); vi.mock('../../../lib/router', () => ({ navigateToSpaceAgent: mockNavigateToSpaceAgent, + pushOverlayHistory: mockPushOverlayHistory, })); // Plain signal-like holders for the overlay signals — hoisted so the mock factory can reference them diff --git a/packages/web/src/components/space/thread/compact/SpaceTaskCardFeed.tsx b/packages/web/src/components/space/thread/compact/SpaceTaskCardFeed.tsx index 6b2074030..5773a6863 100644 --- a/packages/web/src/components/space/thread/compact/SpaceTaskCardFeed.tsx +++ b/packages/web/src/components/space/thread/compact/SpaceTaskCardFeed.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'preact/hooks'; import { isSDKSystemInit, isSDKUserMessage } from '@neokai/shared/sdk/type-guards'; import type { ParsedThreadRow } from '../space-task-thread-events'; import type { UseMessageMapsResult } from '../../../../hooks/useMessageMaps'; -import { spaceOverlayAgentNameSignal, spaceOverlaySessionIdSignal } from '../../../../lib/signals'; +import { pushOverlayHistory } from '../../../../lib/router'; import { SDKMessageRenderer } from '../../../sdk/SDKMessageRenderer'; import { getAgentColor } from '../space-task-thread-agent-colors'; import { SpaceSystemInitCard } from './SpaceSystemInitCard'; @@ -149,8 +149,7 @@ function renderAgentBlock( const handleOpenAgentOverlay = () => { if (!block.sessionId) return; - spaceOverlayAgentNameSignal.value = block.label; - spaceOverlaySessionIdSignal.value = block.sessionId; + pushOverlayHistory(block.sessionId, block.label); }; const handleHeaderKeyDown = (e: KeyboardEvent) => { diff --git a/packages/web/src/islands/ChatContainer.tsx b/packages/web/src/islands/ChatContainer.tsx index 22415df0f..ed6a2ab64 100644 --- a/packages/web/src/islands/ChatContainer.tsx +++ b/packages/web/src/islands/ChatContainer.tsx @@ -64,7 +64,8 @@ import { toast } from '../lib/toast.ts'; import { lobbyStore } from '../lib/lobby-store.ts'; import type { RoomContext } from '../components/ChatHeader.tsx'; -import { navSectionSignal, settingsSectionSignal } from '../lib/signals.ts'; +import { settingsSectionSignal } from '../lib/signals.ts'; +import { navigateToSettings } from '../lib/router.ts'; import { ErrorCategory } from '../types/error.ts'; import type { StructuredError } from '../types/error.ts'; import { getProviderLabel } from '../hooks/index.ts'; @@ -742,7 +743,7 @@ export default function ChatContainer({ { label: `Re-authenticate ${providerLabel}`, onClick: () => { - navSectionSignal.value = 'settings'; + navigateToSettings(); settingsSectionSignal.value = 'providers'; }, }, diff --git a/packages/web/src/islands/SpaceIsland.tsx b/packages/web/src/islands/SpaceIsland.tsx index 9d1215892..2e0df897a 100644 --- a/packages/web/src/islands/SpaceIsland.tsx +++ b/packages/web/src/islands/SpaceIsland.tsx @@ -17,7 +17,12 @@ import { spaceOverlaySessionIdSignal, spaceOverlayAgentNameSignal } from '../lib import { SpacePageHeader } from '../components/space/SpacePageHeader'; import { AgentOverlayChat } from '../components/space/AgentOverlayChat'; import { spaceStore } from '../lib/space-store'; -import { navigateToSpace, navigateToSpaceTask } from '../lib/router'; +import { + navigateToSpace, + navigateToSpaceTask, + pushOverlayHistory, + closeOverlayHistory, +} from '../lib/router'; import ChatContainer from './ChatContainer'; const SpaceConfigurePage = lazy(() => @@ -60,8 +65,7 @@ export default function SpaceIsland({ const overlaySessionId = spaceOverlaySessionIdSignal.value; const overlayAgentName = spaceOverlayAgentNameSignal.value; const handleOverlayClose = useCallback(() => { - spaceOverlaySessionIdSignal.value = null; - spaceOverlayAgentNameSignal.value = null; + closeOverlayHistory(); }, []); // Test hook: expose overlay controls on window.__neokai_space_overlay so E2E @@ -72,12 +76,10 @@ export default function SpaceIsland({ const w = window as typeof window & { __neokai_space_overlay?: OverlayApi }; w.__neokai_space_overlay = { open(sessionId, agentName) { - spaceOverlayAgentNameSignal.value = agentName ?? null; - spaceOverlaySessionIdSignal.value = sessionId; + pushOverlayHistory(sessionId, agentName); }, close() { - spaceOverlaySessionIdSignal.value = null; - spaceOverlayAgentNameSignal.value = null; + closeOverlayHistory(); }, }; return () => { diff --git a/packages/web/src/islands/__tests__/SpaceIsland.test.tsx b/packages/web/src/islands/__tests__/SpaceIsland.test.tsx index 751f8425c..35f16115a 100644 --- a/packages/web/src/islands/__tests__/SpaceIsland.test.tsx +++ b/packages/web/src/islands/__tests__/SpaceIsland.test.tsx @@ -103,6 +103,8 @@ vi.mock('../../lib/space-store', () => ({ vi.mock('../../lib/router', () => ({ navigateToSpace: vi.fn(), navigateToSpaceTask: vi.fn(), + pushOverlayHistory: vi.fn(), + closeOverlayHistory: vi.fn(), })); import SpaceIsland from '../SpaceIsland'; diff --git a/packages/web/src/lib/__tests__/overlay-history.test.ts b/packages/web/src/lib/__tests__/overlay-history.test.ts new file mode 100644 index 000000000..e77186bdc --- /dev/null +++ b/packages/web/src/lib/__tests__/overlay-history.test.ts @@ -0,0 +1,158 @@ +// @ts-nocheck +/** + * Tests for the Space Agent Overlay history integration + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + pushOverlayHistory, + closeOverlayHistory, + initializeRouter, + cleanupRouter, +} from '../router'; +import { + navSectionSignal, + spaceOverlaySessionIdSignal, + spaceOverlayAgentNameSignal, + currentSpaceIdSignal, +} from '../signals'; + +let originalHistory: unknown; +let originalLocation: unknown; +let mockHistory: unknown; +let mockLocation: unknown; + +describe('Overlay history', () => { + beforeEach(() => { + originalHistory = window.history; + originalLocation = window.location; + + mockHistory = { + pushState: vi.fn(), + replaceState: vi.fn(), + back: vi.fn(), + state: {}, + }; + mockLocation = { pathname: '/space/my-space' }; + + Object.defineProperty(window, 'history', { + value: mockHistory, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, + configurable: true, + }); + + // Reset signals + spaceOverlaySessionIdSignal.value = null; + spaceOverlayAgentNameSignal.value = null; + navSectionSignal.value = 'spaces'; + currentSpaceIdSignal.value = null; + + vi.clearAllMocks(); + cleanupRouter(); + }); + + afterEach(() => { + Object.defineProperty(window, 'history', { + value: originalHistory, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + + cleanupRouter(); + spaceOverlaySessionIdSignal.value = null; + spaceOverlayAgentNameSignal.value = null; + }); + + describe('pushOverlayHistory', () => { + it('should push a history entry with overlay marker and set overlay signals', () => { + pushOverlayHistory('session-abc', 'Task Agent'); + + expect(mockHistory.pushState).toHaveBeenCalledWith( + expect.objectContaining({ overlaySessionId: 'session-abc' }), + '', + '/space/my-space' // URL does NOT change + ); + expect(spaceOverlaySessionIdSignal.value).toBe('session-abc'); + expect(spaceOverlayAgentNameSignal.value).toBe('Task Agent'); + }); + + it('should set agentName to null when not provided', () => { + pushOverlayHistory('session-abc'); + + expect(spaceOverlayAgentNameSignal.value).toBeNull(); + }); + + it('should preserve existing history state', () => { + mockHistory.state = { spaceId: 'my-space' }; + + pushOverlayHistory('session-abc'); + + expect(mockHistory.pushState).toHaveBeenCalledWith( + expect.objectContaining({ + spaceId: 'my-space', + overlaySessionId: 'session-abc', + }), + '', + '/space/my-space' + ); + }); + }); + + describe('closeOverlayHistory', () => { + it('should call history.back() and clear overlay signals when overlay is open', () => { + mockHistory.state = { overlaySessionId: 'session-abc' }; + + closeOverlayHistory(); + + expect(mockHistory.back).toHaveBeenCalled(); + expect(spaceOverlaySessionIdSignal.value).toBeNull(); + expect(spaceOverlayAgentNameSignal.value).toBeNull(); + }); + + it('should just clear signals when no overlay history entry exists', () => { + mockHistory.state = {}; // no overlaySessionId + + closeOverlayHistory(); + + expect(mockHistory.back).not.toHaveBeenCalled(); + expect(spaceOverlaySessionIdSignal.value).toBeNull(); + expect(spaceOverlayAgentNameSignal.value).toBeNull(); + }); + }); + + describe('popstate with overlay', () => { + it('should close overlay signals when popstate removes overlay marker', () => { + // Simulate: overlay was opened (signal set), user presses back + spaceOverlaySessionIdSignal.value = 'session-abc'; + spaceOverlayAgentNameSignal.value = 'Task Agent'; + mockHistory.state = {}; // no overlay marker (user went back) + + initializeRouter(); + window.dispatchEvent(new PopStateEvent('popstate', {})); + + expect(spaceOverlaySessionIdSignal.value).toBeNull(); + expect(spaceOverlayAgentNameSignal.value).toBeNull(); + }); + + it('should not interfere with normal navigation when overlay is closed', () => { + spaceOverlaySessionIdSignal.value = null; + mockHistory.state = {}; + + initializeRouter(); + mockLocation.pathname = '/space/other-space'; + window.dispatchEvent(new PopStateEvent('popstate', {})); + + expect(navSectionSignal.value).toBe('spaces'); + expect(currentSpaceIdSignal.value).toBe('other-space'); + }); + }); +}); diff --git a/packages/web/src/lib/__tests__/settings-router.test.ts b/packages/web/src/lib/__tests__/settings-router.test.ts new file mode 100644 index 000000000..c1cd5a22a --- /dev/null +++ b/packages/web/src/lib/__tests__/settings-router.test.ts @@ -0,0 +1,137 @@ +// @ts-nocheck +/** + * Tests for the /settings route + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { navigateToSettings, initializeRouter, cleanupRouter } from '../router'; +import { navSectionSignal, currentSpaceIdSignal, currentRoomIdSignal } from '../signals'; + +let originalHistory: unknown; +let originalLocation: unknown; +let mockHistory: unknown; +let mockLocation: unknown; + +describe('/settings route', () => { + beforeEach(() => { + originalHistory = window.history; + originalLocation = window.location; + + mockHistory = { + pushState: vi.fn(), + replaceState: vi.fn(), + state: null, + }; + mockLocation = { pathname: '/' }; + + Object.defineProperty(window, 'history', { + value: mockHistory, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, + configurable: true, + }); + + // Reset signals + navSectionSignal.value = 'rooms'; + currentSpaceIdSignal.value = null; + currentRoomIdSignal.value = null; + + vi.clearAllMocks(); + cleanupRouter(); + }); + + afterEach(() => { + Object.defineProperty(window, 'history', { + value: originalHistory, + writable: true, + configurable: true, + }); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + + cleanupRouter(); + navSectionSignal.value = 'rooms'; + currentSpaceIdSignal.value = null; + currentRoomIdSignal.value = null; + }); + + describe('navigateToSettings', () => { + it('should push /settings URL and set navSection to settings', () => { + navigateToSettings(); + + expect(mockHistory.pushState).toHaveBeenCalledWith({ path: '/settings' }, '', '/settings'); + expect(navSectionSignal.value).toBe('settings'); + }); + + it('should clear all context signals', () => { + currentSpaceIdSignal.value = 'space-1'; + currentRoomIdSignal.value = 'room-1'; + + navigateToSettings(); + + expect(currentSpaceIdSignal.value).toBeNull(); + expect(currentRoomIdSignal.value).toBeNull(); + }); + + it('should use replaceState when replace is true', () => { + navigateToSettings(true); + + expect(mockHistory.replaceState).toHaveBeenCalledWith({ path: '/settings' }, '', '/settings'); + expect(mockHistory.pushState).not.toHaveBeenCalled(); + }); + + it('should not push history if already on /settings', () => { + mockLocation.pathname = '/settings'; + + navigateToSettings(); + + expect(mockHistory.pushState).not.toHaveBeenCalled(); + expect(mockHistory.replaceState).not.toHaveBeenCalled(); + }); + }); + + describe('initializeRouter with /settings', () => { + it('should set navSection to settings when page loads at /settings', () => { + mockLocation.pathname = '/settings'; + + initializeRouter(); + + expect(navSectionSignal.value).toBe('settings'); + expect(currentSpaceIdSignal.value).toBeNull(); + expect(currentRoomIdSignal.value).toBeNull(); + }); + }); + + describe('popstate with /settings', () => { + it('should restore navSection to settings on popstate', () => { + mockLocation.pathname = '/'; + initializeRouter(); + expect(navSectionSignal.value).toBe('rooms'); + + // Simulate navigating to settings via URL bar + mockLocation.pathname = '/settings'; + window.dispatchEvent(new PopStateEvent('popstate', {})); + + expect(navSectionSignal.value).toBe('settings'); + }); + + it('should clear navSection when navigating away from /settings', () => { + mockLocation.pathname = '/settings'; + initializeRouter(); + expect(navSectionSignal.value).toBe('settings'); + + // Simulate navigating back to a space + mockLocation.pathname = '/space/my-space'; + window.dispatchEvent(new PopStateEvent('popstate', {})); + + expect(navSectionSignal.value).toBe('spaces'); + expect(currentSpaceIdSignal.value).toBe('my-space'); + }); + }); +}); diff --git a/packages/web/src/lib/router.ts b/packages/web/src/lib/router.ts index 0ab637d98..34d9ec142 100644 --- a/packages/web/src/lib/router.ts +++ b/packages/web/src/lib/router.ts @@ -28,6 +28,8 @@ import { currentSpaceTaskIdSignal, currentSpaceViewModeSignal, navSectionSignal, + spaceOverlaySessionIdSignal, + spaceOverlayAgentNameSignal, } from './signals.ts'; /** Route patterns */ @@ -45,6 +47,7 @@ const ROOM_SETTINGS_ROUTE_PATTERN = /^\/room\/([a-f0-9-]+)\/settings$/; const SESSIONS_ROUTE_PATTERN = /^\/sessions$/; const INBOX_ROUTE_PATTERN = /^\/inbox$/; const SPACES_ROUTE_PATTERN = /^\/spaces$/; +const SETTINGS_ROUTE_PATTERN = /^\/settings$/; /** Legacy: /room/:id/chat was removed — treat as plain room route for backwards compat */ const ROOM_CHAT_COMPAT_PATTERN = /^\/room\/([a-f0-9-]+)\/chat$/; /** Space routes accept both UUIDs (a-f0-9-) and slugs (a-z0-9-) — slugs are always lowercase */ @@ -392,6 +395,11 @@ export function createSpaceAgentPath(spaceId: string): string { return `/space/${spaceId}/agent`; } +/** Create settings URL path */ +export function createSettingsPath(): string { + return '/settings'; +} + /** * Navigate to a session * Updates both the URL and the signal @@ -993,11 +1001,52 @@ export function navigateToInbox(replace = false): void { /** * Navigate to Settings section - * Sets nav section to 'settings' and navigates home + * Sets nav section to 'settings' and updates URL to /settings */ -export function navigateToSettings(): void { - navSectionSignal.value = 'settings'; - navigateToHome(); +export function navigateToSettings(replace = false): void { + if (routerState.isNavigating) { + return; + } + + const targetPath = '/settings'; + const currentPath = getCurrentPath(); + if (currentPath === targetPath) { + navSectionSignal.value = 'settings'; + currentSessionIdSignal.value = null; + currentRoomIdSignal.value = null; + currentRoomSessionIdSignal.value = null; + currentRoomTaskIdSignal.value = null; + currentRoomGoalIdSignal.value = null; + currentRoomAgentActiveSignal.value = false; + currentRoomActiveTabSignal.value = null; + currentSpaceIdSignal.value = null; + currentSpaceSessionIdSignal.value = null; + currentSpaceTaskIdSignal.value = null; + return; + } + + routerState.isNavigating = true; + + try { + const historyMethod = replace ? 'replaceState' : 'pushState'; + window.history[historyMethod]({ path: targetPath }, '', targetPath); + + navSectionSignal.value = 'settings'; + currentSessionIdSignal.value = null; + currentRoomIdSignal.value = null; + currentRoomSessionIdSignal.value = null; + currentRoomTaskIdSignal.value = null; + currentRoomGoalIdSignal.value = null; + currentRoomAgentActiveSignal.value = false; + currentRoomActiveTabSignal.value = null; + currentSpaceIdSignal.value = null; + currentSpaceSessionIdSignal.value = null; + currentSpaceTaskIdSignal.value = null; + } finally { + setTimeout(() => { + routerState.isNavigating = false; + }, 0); + } } /** @@ -1442,6 +1491,14 @@ function handlePopState(_event: PopStateEvent): void { return; } + // If the overlay is open and the user pressed back, close the overlay + // instead of navigating away from the current view. + if (spaceOverlaySessionIdSignal.value && !window.history.state?.overlaySessionId) { + spaceOverlaySessionIdSignal.value = null; + spaceOverlayAgentNameSignal.value = null; + return; + } + const path = getCurrentPath(); const sessionId = getSessionIdFromPath(path); const roomId = getRoomIdFromPath(path); @@ -1680,6 +1737,19 @@ function handlePopState(_event: PopStateEvent): void { currentRoomActiveTabSignal.value = null; currentSessionIdSignal.value = null; navSectionSignal.value = 'spaces'; + } else if (SETTINGS_ROUTE_PATTERN.test(path)) { + currentSpaceIdSignal.value = null; + currentSpaceViewModeSignal.value = 'overview'; + currentSpaceSessionIdSignal.value = null; + currentSpaceTaskIdSignal.value = null; + currentRoomIdSignal.value = null; + currentRoomSessionIdSignal.value = null; + currentRoomTaskIdSignal.value = null; + currentRoomGoalIdSignal.value = null; + currentRoomAgentActiveSignal.value = false; + currentRoomActiveTabSignal.value = null; + currentSessionIdSignal.value = null; + navSectionSignal.value = 'settings'; } else { currentSpaceIdSignal.value = null; currentSpaceViewModeSignal.value = 'overview'; @@ -1937,6 +2007,19 @@ export function initializeRouter(): string | null { currentRoomActiveTabSignal.value = null; currentSessionIdSignal.value = null; navSectionSignal.value = 'spaces'; + } else if (SETTINGS_ROUTE_PATTERN.test(initialPath)) { + currentSpaceIdSignal.value = null; + currentSpaceViewModeSignal.value = 'overview'; + currentSpaceSessionIdSignal.value = null; + currentSpaceTaskIdSignal.value = null; + currentRoomIdSignal.value = null; + currentRoomSessionIdSignal.value = null; + currentRoomTaskIdSignal.value = null; + currentRoomGoalIdSignal.value = null; + currentRoomAgentActiveSignal.value = false; + currentRoomActiveTabSignal.value = null; + currentSessionIdSignal.value = null; + navSectionSignal.value = 'settings'; } else { currentSpaceIdSignal.value = null; currentSpaceViewModeSignal.value = 'overview'; @@ -1963,6 +2046,44 @@ export function initializeRouter(): string | null { return initialSessionId; } +/** + * Push a history entry for the Space Agent Overlay. + * + * When the overlay panel opens, we push a marker state so the browser back + * button dismisses the overlay instead of navigating away from the parent view. + * The URL itself does NOT change — we push a state entry with the same path + * but tagged with `overlaySessionId`. + * + * Call `closeOverlayHistory()` when the overlay is dismissed by the user + * (Escape, backdrop click, etc.) to go back in history. + */ +export function pushOverlayHistory(sessionId: string, agentName?: string): void { + const currentPath = getCurrentPath(); + window.history.pushState( + { ...window.history.state, overlaySessionId: sessionId }, + '', + currentPath + ); + // Set overlay signals after pushing history so the effect doesn't race + spaceOverlaySessionIdSignal.value = sessionId; + spaceOverlayAgentNameSignal.value = agentName ?? null; +} + +/** + * Close the overlay and go back in history to the pre-overlay entry. + */ +export function closeOverlayHistory(): void { + if (window.history.state?.overlaySessionId) { + spaceOverlaySessionIdSignal.value = null; + spaceOverlayAgentNameSignal.value = null; + window.history.back(); + } else { + // No overlay history entry — just close the overlay signal + spaceOverlaySessionIdSignal.value = null; + spaceOverlayAgentNameSignal.value = null; + } +} + /** * Cleanup router (mainly for testing) */ From 235466a0b1e23b500bf0c478d75b8330822d2dd6 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 20 Apr 2026 21:15:29 -0400 Subject: [PATCH 2/4] test: add @vitest-environment happy-dom to new router test files Consistent with existing router test convention (router.test.ts, router-short-id.test.ts, router-space-slug.test.ts). --- packages/web/src/lib/__tests__/overlay-history.test.ts | 1 + packages/web/src/lib/__tests__/settings-router.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/web/src/lib/__tests__/overlay-history.test.ts b/packages/web/src/lib/__tests__/overlay-history.test.ts index e77186bdc..41ce07a1e 100644 --- a/packages/web/src/lib/__tests__/overlay-history.test.ts +++ b/packages/web/src/lib/__tests__/overlay-history.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom // @ts-nocheck /** * Tests for the Space Agent Overlay history integration diff --git a/packages/web/src/lib/__tests__/settings-router.test.ts b/packages/web/src/lib/__tests__/settings-router.test.ts index c1cd5a22a..a903914f2 100644 --- a/packages/web/src/lib/__tests__/settings-router.test.ts +++ b/packages/web/src/lib/__tests__/settings-router.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment happy-dom // @ts-nocheck /** * Tests for the /settings route From a0c042ad9b36c34d8dd129129b214e4be83fe540 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 20 Apr 2026 23:51:50 -0400 Subject: [PATCH 3/4] test(web): update signal-based navigation mocks in Space tests Components now read navigation state from shared signals instead of local useState. Update test mocks to use real Preact signals (via bridge pattern) so that navigateToSpaceTasks/navigateToSpaceTask/navigateToSpaceConfigure calls trigger reactive re-renders during tests. --- .../space/__tests__/SpaceTaskPane.test.tsx | 67 ++++++++++++++++--- .../space/__tests__/SpaceTasks.test.tsx | 55 +++++++++++++++ .../__tests__/TaskArtifactsPanel.test.tsx | 51 ++++++++++++++ .../islands/__tests__/SpaceIsland.test.tsx | 41 ++++++++++++ 4 files changed, 203 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx b/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx index 789bdd381..23d3e44ed 100644 --- a/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx +++ b/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx @@ -10,23 +10,54 @@ import type { SpaceWorkflowRun, } from '@neokai/shared'; -const { mockNavigateToSpaceAgent, mockPushOverlayHistory } = vi.hoisted(() => ({ - mockNavigateToSpaceAgent: vi.fn(), - mockPushOverlayHistory: vi.fn((sessionId: string, agentName?: string) => { - mockSpaceOverlaySessionIdSignal.value = sessionId; - mockSpaceOverlayAgentNameSignal.value = agentName ?? null; - }), +// Bridge objects: created in vi.hoisted so mock factories can reference them. +// Real Preact signals are assigned to .signal after module init so that mock +// functions called at test-runtime can update the reactive signal. +const { + mockSpaceOverlaySessionIdSignal, + mockSpaceOverlayAgentNameSignal, + viewTabBridge, + idBridge, +} = vi.hoisted(() => ({ + mockSpaceOverlaySessionIdSignal: { value: null as string | null }, + mockSpaceOverlayAgentNameSignal: { value: null as string | null }, + // Bridges to hold real signals for reactive tab/id updates + viewTabBridge: { signal: null as ReturnType> | null }, + idBridge: { signal: null as ReturnType> | null }, })); + +const { mockNavigateToSpaceAgent, mockPushOverlayHistory, mockNavigateToSpaceTask } = vi.hoisted( + () => ({ + mockNavigateToSpaceAgent: vi.fn(), + mockPushOverlayHistory: vi.fn((sessionId: string, agentName?: string) => { + mockSpaceOverlaySessionIdSignal.value = sessionId; + mockSpaceOverlayAgentNameSignal.value = agentName ?? null; + }), + mockNavigateToSpaceTask: vi.fn((_spaceId: string, _taskId: string, view: string) => { + if (viewTabBridge.signal) { + viewTabBridge.signal.value = view ?? 'thread'; + } + if (idBridge.signal) { + idBridge.signal.value = _spaceId; + } + }), + }) +); + +// Real Preact signals — these enable reactivity for values read during render +const mockCurrentSpaceTaskViewTabSignal = signal('thread'); +const mockCurrentSpaceIdSignal = signal(null); + +// Wire bridges so mockNavigateToSpaceTask can update the real signals +viewTabBridge.signal = mockCurrentSpaceTaskViewTabSignal; +idBridge.signal = mockCurrentSpaceIdSignal; + vi.mock('../../../lib/router', () => ({ navigateToSpaceAgent: mockNavigateToSpaceAgent, pushOverlayHistory: mockPushOverlayHistory, + navigateToSpaceTask: mockNavigateToSpaceTask, })); -// Plain signal-like holders for the overlay signals — hoisted so the mock factory can reference them -const { mockSpaceOverlaySessionIdSignal, mockSpaceOverlayAgentNameSignal } = vi.hoisted(() => ({ - mockSpaceOverlaySessionIdSignal: { value: null as string | null }, - mockSpaceOverlayAgentNameSignal: { value: null as string | null }, -})); vi.mock('../../../lib/signals', async (importOriginal) => { const actual = await importOriginal(); return { @@ -37,6 +68,12 @@ vi.mock('../../../lib/signals', async (importOriginal) => { get spaceOverlayAgentNameSignal() { return mockSpaceOverlayAgentNameSignal; }, + get currentSpaceTaskViewTabSignal() { + return mockCurrentSpaceTaskViewTabSignal; + }, + get currentSpaceIdSignal() { + return mockCurrentSpaceIdSignal; + }, }; }); @@ -170,10 +207,13 @@ describe('SpaceTaskPane', () => { ); mockSendTaskMessage.mockClear(); mockNavigateToSpaceAgent.mockClear(); + mockNavigateToSpaceTask.mockClear(); mockSubscribeTaskActivity.mockClear(); mockUnsubscribeTaskActivity.mockClear(); mockSpaceOverlaySessionIdSignal.value = null; mockSpaceOverlayAgentNameSignal.value = null; + mockCurrentSpaceTaskViewTabSignal.value = 'thread'; + mockCurrentSpaceIdSignal.value = null; mockWorkflowCanvasOnNodeClick.mockClear(); }); @@ -377,8 +417,11 @@ describe('SpaceTaskPane — canvas toggle', () => { makeTask({ status: 'in_progress', taskAgentSessionId: 'session-ensured' }) ); mockWorkflowCanvasOnNodeClick.mockClear(); + mockNavigateToSpaceTask.mockClear(); mockSpaceOverlaySessionIdSignal.value = null; mockSpaceOverlayAgentNameSignal.value = null; + mockCurrentSpaceTaskViewTabSignal.value = 'thread'; + mockCurrentSpaceIdSignal.value = null; }); afterEach(() => { @@ -684,6 +727,8 @@ describe('SpaceTaskPane — activity members actions', () => { mockUnsubscribeTaskActivity.mockClear(); mockSpaceOverlaySessionIdSignal.value = null; mockSpaceOverlayAgentNameSignal.value = null; + mockCurrentSpaceTaskViewTabSignal.value = 'thread'; + mockCurrentSpaceIdSignal.value = null; }); afterEach(() => { diff --git a/packages/web/src/components/space/__tests__/SpaceTasks.test.tsx b/packages/web/src/components/space/__tests__/SpaceTasks.test.tsx index b533ca557..49358d048 100644 --- a/packages/web/src/components/space/__tests__/SpaceTasks.test.tsx +++ b/packages/web/src/components/space/__tests__/SpaceTasks.test.tsx @@ -11,6 +11,57 @@ import type { SpaceTask } from '@neokai/shared'; let mockTasks: ReturnType>; let mockAttentionCount: ReturnType>; +// Bridge pattern: hoisted bridge objects allow mockNavigateToSpaceTasks to update +// the real Preact signals (which are created after import). +const { filterTabBridge, filterBridge, idBridge } = vi.hoisted(() => ({ + filterTabBridge: { signal: null as ReturnType> | null }, + filterBridge: { signal: null as ReturnType> | null }, + idBridge: { signal: null as ReturnType> | null }, +})); + +// Hoisted mock for navigateToSpaceTasks — updates the real signal at call time +const { mockNavigateToSpaceTasks } = vi.hoisted(() => ({ + mockNavigateToSpaceTasks: vi.fn((_spaceId: string, tab: string) => { + if (filterTabBridge.signal) { + filterTabBridge.signal.value = tab; + } + }), +})); + +// Plain holders for non-reactive signals (only read in useEffect, not render) +const { mockCurrentSpaceTasksFilterSignal, mockCurrentSpaceIdSignal } = vi.hoisted(() => ({ + mockCurrentSpaceTasksFilterSignal: { value: null as string | null }, + mockCurrentSpaceIdSignal: { value: null as string | null }, +})); + +// Real Preact signal for the filter tab (read during render — needs reactivity) +const mockCurrentSpaceTasksFilterTabSignal = signal('active'); + +// Wire bridge so mockNavigateToSpaceTasks can update the real signal +filterTabBridge.signal = mockCurrentSpaceTasksFilterTabSignal; +filterBridge.signal = mockCurrentSpaceTasksFilterSignal; +idBridge.signal = mockCurrentSpaceIdSignal; + +vi.mock('../../../lib/signals', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get currentSpaceTasksFilterTabSignal() { + return mockCurrentSpaceTasksFilterTabSignal; + }, + get currentSpaceTasksFilterSignal() { + return mockCurrentSpaceTasksFilterSignal; + }, + get currentSpaceIdSignal() { + return mockCurrentSpaceIdSignal; + }, + }; +}); + +vi.mock('../../../lib/router', () => ({ + navigateToSpaceTasks: mockNavigateToSpaceTasks, +})); + vi.mock('../../../lib/space-store', () => ({ get spaceStore() { return { tasks: mockTasks, attentionCount: mockAttentionCount }; @@ -57,6 +108,10 @@ describe('SpaceTasks', () => { cleanup(); mockTasks.value = []; mockAttentionCount.value = 0; + mockCurrentSpaceTasksFilterTabSignal.value = 'active'; + mockCurrentSpaceTasksFilterSignal.value = null; + mockCurrentSpaceIdSignal.value = null; + mockNavigateToSpaceTasks.mockClear(); }); afterEach(() => { diff --git a/packages/web/src/components/space/__tests__/TaskArtifactsPanel.test.tsx b/packages/web/src/components/space/__tests__/TaskArtifactsPanel.test.tsx index 907da56b4..6f04dffbd 100644 --- a/packages/web/src/components/space/__tests__/TaskArtifactsPanel.test.tsx +++ b/packages/web/src/components/space/__tests__/TaskArtifactsPanel.test.tsx @@ -249,10 +249,16 @@ describe('TaskArtifactsPanel', () => { describe('SpaceTaskPane — artifacts toggle', () => { // Import SpaceTaskPane with signal mocks to test the toggle button let mockTasks: ReturnType; + let mockCurrentSpaceTaskViewTabSignal: ReturnType; + let mockCurrentSpaceIdSignal: ReturnType; beforeEach(async () => { cleanup(); vi.resetModules(); + // Create fresh real Preact signals so the component gets reactivity + const { signal } = await import('@preact/signals'); + mockCurrentSpaceTaskViewTabSignal = signal('thread'); + mockCurrentSpaceIdSignal = signal(null); }); afterEach(() => { @@ -313,7 +319,22 @@ describe('SpaceTaskPane — artifacts toggle', () => { vi.doMock('../../../lib/router', () => ({ navigateToSpaceSession: vi.fn(), navigateToSpaceAgent: vi.fn(), + navigateToSpaceTask: vi.fn((_spaceId: string, _taskId: string, view: string) => { + mockCurrentSpaceTaskViewTabSignal.value = view ?? 'thread'; + }), })); + vi.doMock('../../../lib/signals', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get currentSpaceTaskViewTabSignal() { + return mockCurrentSpaceTaskViewTabSignal; + }, + get currentSpaceIdSignal() { + return mockCurrentSpaceIdSignal; + }, + }; + }); vi.doMock('../../../lib/utils', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })); @@ -372,7 +393,22 @@ describe('SpaceTaskPane — artifacts toggle', () => { vi.doMock('../../../lib/router', () => ({ navigateToSpaceSession: vi.fn(), navigateToSpaceAgent: vi.fn(), + navigateToSpaceTask: vi.fn((_spaceId: string, _taskId: string, view: string) => { + mockCurrentSpaceTaskViewTabSignal.value = view ?? 'thread'; + }), })); + vi.doMock('../../../lib/signals', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get currentSpaceTaskViewTabSignal() { + return mockCurrentSpaceTaskViewTabSignal; + }, + get currentSpaceIdSignal() { + return mockCurrentSpaceIdSignal; + }, + }; + }); vi.doMock('../../../lib/utils', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })); @@ -440,7 +476,22 @@ describe('SpaceTaskPane — artifacts toggle', () => { vi.doMock('../../../lib/router', () => ({ navigateToSpaceSession: vi.fn(), navigateToSpaceAgent: vi.fn(), + navigateToSpaceTask: vi.fn((_spaceId: string, _taskId: string, view: string) => { + mockCurrentSpaceTaskViewTabSignal.value = view ?? 'thread'; + }), })); + vi.doMock('../../../lib/signals', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get currentSpaceTaskViewTabSignal() { + return mockCurrentSpaceTaskViewTabSignal; + }, + get currentSpaceIdSignal() { + return mockCurrentSpaceIdSignal; + }, + }; + }); vi.doMock('../../../lib/utils', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })); diff --git a/packages/web/src/islands/__tests__/SpaceIsland.test.tsx b/packages/web/src/islands/__tests__/SpaceIsland.test.tsx index 35f16115a..e59c9ef9e 100644 --- a/packages/web/src/islands/__tests__/SpaceIsland.test.tsx +++ b/packages/web/src/islands/__tests__/SpaceIsland.test.tsx @@ -21,6 +21,43 @@ let mockAgents = signal([]); const mockSelectSpace = vi.fn().mockResolvedValue(undefined); +// Bridge pattern: hoisted bridge so mockNavigateToSpaceConfigure can update +// the real Preact signal (created after import) for reactivity. +const { configureTabBridge, idBridge } = vi.hoisted(() => ({ + configureTabBridge: { signal: null as ReturnType> | null }, + idBridge: { signal: null as ReturnType> | null }, +})); + +// Hoisted mock for navigateToSpaceConfigure — updates real signal at call time +const { mockNavigateToSpaceConfigure } = vi.hoisted(() => ({ + mockNavigateToSpaceConfigure: vi.fn((_spaceId: string, tab?: string) => { + if (configureTabBridge.signal) { + configureTabBridge.signal.value = tab ?? 'agents'; + } + }), +})); + +// Real Preact signal for the configure tab (read during render — needs reactivity) +const mockCurrentSpaceConfigureTabSignal = signal('agents'); +const mockCurrentSpaceIdSignal = signal(null); + +// Wire bridge so mockNavigateToSpaceConfigure can update the real signal +configureTabBridge.signal = mockCurrentSpaceConfigureTabSignal; +idBridge.signal = mockCurrentSpaceIdSignal; + +vi.mock('../../lib/signals', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get currentSpaceConfigureTabSignal() { + return mockCurrentSpaceConfigureTabSignal; + }, + get currentSpaceIdSignal() { + return mockCurrentSpaceIdSignal; + }, + }; +}); + vi.mock('../../components/space/WorkflowList', () => ({ WorkflowList: (props: { onCreateWorkflow: () => void; onEditWorkflow: (id: string) => void }) => (
@@ -103,6 +140,7 @@ vi.mock('../../lib/space-store', () => ({ vi.mock('../../lib/router', () => ({ navigateToSpace: vi.fn(), navigateToSpaceTask: vi.fn(), + navigateToSpaceConfigure: mockNavigateToSpaceConfigure, pushOverlayHistory: vi.fn(), closeOverlayHistory: vi.fn(), })); @@ -145,6 +183,9 @@ beforeEach(() => { mockWorkflows = signal([makeWorkflow()]); mockAgents = signal([]); capturedVisualEditorProps = {}; + configureTabBridge.signal.value = 'agents'; + idBridge.signal.value = null; + mockNavigateToSpaceConfigure.mockClear(); }); afterEach(() => { From 091e0ad4b298108b0c75773c58cd02eb3ed25043 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Mon, 20 Apr 2026 23:54:33 -0400 Subject: [PATCH 4/4] feat(web): URL-addressable Space sub-tabs (configure, tasks, task detail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add URL routing for Space sub-tab views so they are shareable and support back/forward navigation: - /space/:id/configure/agents|workflows|settings — configure sub-tabs - /space/:id/tasks/action|active|completed|archived — tasks filter tabs - /space/:id/task/:taskId/thread|canvas|artifacts — task detail sub-views Components now read tab state from signals (driven by router) instead of local useState. Tab switches pushState, popstate restores the correct tab, and initializeRouter handles direct URL loads. New signals: currentSpaceConfigureTabSignal, currentSpaceTasksFilterTabSignal, currentSpaceTaskViewTabSignal. --- packages/web/src/App.tsx | 28 +++- .../components/space/SpaceConfigurePage.tsx | 9 +- .../src/components/space/SpaceTaskPane.tsx | 23 ++-- .../web/src/components/space/SpaceTasks.tsx | 21 +-- .../src/lib/__tests__/space-router.test.ts | 2 +- packages/web/src/lib/router.ts | 130 +++++++++++++++--- packages/web/src/lib/signals.ts | 12 ++ 7 files changed, 178 insertions(+), 47 deletions(-) diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 147d67535..c72135260 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -21,6 +21,9 @@ import { currentSpaceSessionIdSignal, currentSpaceTaskIdSignal, currentSpaceViewModeSignal, + currentSpaceConfigureTabSignal, + currentSpaceTasksFilterTabSignal, + currentSpaceTaskViewTabSignal, navSectionSignal, } from './lib/signals.ts'; import { initSessionStatusTracking } from './lib/session-status.ts'; @@ -125,6 +128,9 @@ export function App() { const spaceSessionId = currentSpaceSessionIdSignal.value; const spaceTaskId = currentSpaceTaskIdSignal.value; const spaceViewMode = currentSpaceViewModeSignal.value; + const spaceConfigureTab = currentSpaceConfigureTabSignal.value; + const spaceTasksFilterTab = currentSpaceTasksFilterTabSignal.value; + const spaceTaskViewTab = currentSpaceTaskViewTabSignal.value; const navSection = navSectionSignal.value; const currentPath = window.location.pathname; // Detect agent routes: new signal-based detection, with legacy session ID fallback @@ -140,7 +146,11 @@ export function App() { const expectedPath = sessionId ? createSessionPath(sessionId) : spaceTaskId && spaceId - ? createSpaceTaskPath(spaceId, spaceTaskId) + ? createSpaceTaskPath( + spaceId, + spaceTaskId, + spaceTaskViewTab !== 'thread' ? spaceTaskViewTab : undefined + ) : isSpaceAgentRoute ? createSpaceAgentPath(spaceId) : spaceSessionId && spaceId @@ -148,9 +158,15 @@ export function App() { : spaceId && spaceViewMode === 'sessions' ? createSpaceSessionsPath(spaceId) : spaceId && spaceViewMode === 'tasks' - ? createSpaceTasksPath(spaceId) + ? createSpaceTasksPath( + spaceId, + spaceTasksFilterTab !== 'active' ? spaceTasksFilterTab : undefined + ) : spaceId && spaceViewMode === 'configure' - ? createSpaceConfigurePath(spaceId) + ? createSpaceConfigurePath( + spaceId, + spaceConfigureTab !== 'agents' ? spaceConfigureTab : undefined + ) : spaceId ? createSpacePath(spaceId) : roomTaskId && roomId @@ -177,7 +193,7 @@ export function App() { if (sessionId) { navigateToSession(sessionId, true); // replace=true to avoid polluting history } else if (spaceTaskId && spaceId) { - navigateToSpaceTask(spaceId, spaceTaskId, true); + navigateToSpaceTask(spaceId, spaceTaskId, undefined, true); } else if (isSpaceAgentRoute) { navigateToSpaceAgent(spaceId, true); } else if (spaceSessionId && spaceId) { @@ -185,9 +201,9 @@ export function App() { } else if (spaceId && spaceViewMode === 'sessions') { navigateToSpaceSessions(spaceId, true); } else if (spaceId && spaceViewMode === 'tasks') { - navigateToSpaceTasks(spaceId, true); + navigateToSpaceTasks(spaceId, undefined, true); } else if (spaceId && spaceViewMode === 'configure') { - navigateToSpaceConfigure(spaceId, true); + navigateToSpaceConfigure(spaceId, undefined, true); } else if (spaceId) { navigateToSpace(spaceId, true); } else if (roomTaskId && roomId) { diff --git a/packages/web/src/components/space/SpaceConfigurePage.tsx b/packages/web/src/components/space/SpaceConfigurePage.tsx index 93923431e..ec12a8159 100644 --- a/packages/web/src/components/space/SpaceConfigurePage.tsx +++ b/packages/web/src/components/space/SpaceConfigurePage.tsx @@ -3,6 +3,8 @@ import { lazy, Suspense } from 'preact/compat'; import type { Space } from '@neokai/shared'; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@neokai/ui'; import { spaceStore } from '../../lib/space-store'; +import { currentSpaceConfigureTabSignal, currentSpaceIdSignal } from '../../lib/signals'; +import { navigateToSpaceConfigure } from '../../lib/router'; import { cn } from '../../lib/utils'; const SpaceAgentList = lazy(() => @@ -51,7 +53,8 @@ export function SpaceConfigurePage({ space }: SpaceConfigurePageProps) { spaceStore.ensureConfigData().catch(() => {}); spaceStore.ensureNodeExecutions().catch(() => {}); }, [space.id]); - const [activeTab, setActiveTab] = useState('agents'); + const activeTab = currentSpaceConfigureTabSignal.value; + const spaceId = currentSpaceIdSignal.value ?? ''; /** null = list view; 'new' = create editor; = edit editor */ const [workflowEditId, setWorkflowEditId] = useState(null); @@ -80,7 +83,9 @@ export function SpaceConfigurePage({ space }: SpaceConfigurePageProps) { {!showWorkflowEditor && ( setActiveTab(CONFIGURE_TABS[index]?.id ?? 'agents')} + onChange={(index: number) => + navigateToSpaceConfigure(spaceId, CONFIGURE_TABS[index]?.id ?? 'agents') + } > (null); const [sendingThread, setSendingThread] = useState(false); const [statusTransitioning, setStatusTransitioning] = useState(false); - const [activeView, setActiveView] = useState<'thread' | 'canvas' | 'artifacts'>('thread'); + const activeView = currentSpaceTaskViewTabSignal.value; + const _spaceId = currentSpaceIdSignal.value ?? ''; useEffect(() => { setThreadSendError(null); - setActiveView('thread'); }, [taskId]); useEffect(() => { @@ -174,11 +175,11 @@ export function SpaceTaskPane({ taskId, spaceId, onClose }: SpaceTaskPaneProps) useEffect(() => { if (activeView === 'canvas' && !canShowCanvasTab) { - setActiveView('thread'); + navigateToSpaceTask(_spaceId, taskId, 'thread'); return; } if (activeView === 'artifacts' && !canShowArtifactsTab) { - setActiveView('thread'); + navigateToSpaceTask(_spaceId, taskId, 'thread'); } }, [activeView, canShowCanvasTab, canShowArtifactsTab]); @@ -339,7 +340,7 @@ export function SpaceTaskPane({ taskId, spaceId, onClose }: SpaceTaskPaneProps)
diff --git a/packages/web/src/lib/__tests__/space-router.test.ts b/packages/web/src/lib/__tests__/space-router.test.ts index a486e3fa9..28f9e4887 100644 --- a/packages/web/src/lib/__tests__/space-router.test.ts +++ b/packages/web/src/lib/__tests__/space-router.test.ts @@ -273,7 +273,7 @@ describe('navigateToSpaceTask', () => { }); it('uses replaceState when replace=true', () => { - navigateToSpaceTask(SPACE_ID, TASK_ID, true); + navigateToSpaceTask(SPACE_ID, TASK_ID, undefined, true); expect(mockHistory.replaceState).toHaveBeenCalled(); }); }); diff --git a/packages/web/src/lib/router.ts b/packages/web/src/lib/router.ts index 34d9ec142..795c25f72 100644 --- a/packages/web/src/lib/router.ts +++ b/packages/web/src/lib/router.ts @@ -27,6 +27,9 @@ import { currentSpaceSessionIdSignal, currentSpaceTaskIdSignal, currentSpaceViewModeSignal, + currentSpaceConfigureTabSignal, + currentSpaceTasksFilterTabSignal, + currentSpaceTaskViewTabSignal, navSectionSignal, spaceOverlaySessionIdSignal, spaceOverlayAgentNameSignal, @@ -53,10 +56,16 @@ const ROOM_CHAT_COMPAT_PATTERN = /^\/room\/([a-f0-9-]+)\/chat$/; /** Space routes accept both UUIDs (a-f0-9-) and slugs (a-z0-9-) — slugs are always lowercase */ const SPACE_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)$/; const SPACE_CONFIGURE_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/configure$/; +const SPACE_CONFIGURE_TAB_ROUTE_PATTERN = + /^\/space\/([a-z0-9-]+)\/configure\/(agents|workflows|settings)$/; const SPACE_TASKS_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/tasks$/; +const SPACE_TASKS_TAB_ROUTE_PATTERN = + /^\/space\/([a-z0-9-]+)\/tasks\/(action|active|completed|archived)$/; const SPACE_AGENT_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/agent$/; const SPACE_SESSION_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/session\/([a-fA-F0-9-]+)$/; const SPACE_TASK_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/task\/([a-fA-F0-9-]+|[a-z]-[1-9]\d*)$/; +const SPACE_TASK_VIEW_ROUTE_PATTERN = + /^\/space\/([a-z0-9-]+)\/task\/([a-fA-F0-9-]+|[a-z]-[1-9]\d*)\/(thread|canvas|artifacts)$/; const SPACE_SESSIONS_ROUTE_PATTERN = /^\/space\/([a-z0-9-]+)\/sessions$/; /** @@ -206,6 +215,18 @@ export function getSpaceConfigureFromPath(path: string): string | null { return match ? match[1] : null; } +/** + * Extract space ID + configure tab from /space/:id/configure/:tab route. + * Checked BEFORE the generic configure pattern so it takes priority. + */ +export function getSpaceConfigureTabFromPath( + path: string +): { spaceId: string; tab: 'agents' | 'workflows' | 'settings' } | null { + const match = path.match(SPACE_CONFIGURE_TAB_ROUTE_PATTERN); + if (!match) return null; + return { spaceId: match[1], tab: match[2] as 'agents' | 'workflows' | 'settings' }; +} + /** * Extract space ID from /space/:id/tasks route * Returns null if not on a space tasks route @@ -215,6 +236,18 @@ export function getSpaceTasksFromPath(path: string): string | null { return match ? match[1] : null; } +/** + * Extract space ID + tasks filter tab from /space/:id/tasks/:tab route. + * Checked BEFORE the generic tasks pattern so it takes priority. + */ +export function getSpaceTasksTabFromPath( + path: string +): { spaceId: string; tab: 'action' | 'active' | 'completed' | 'archived' } | null { + const match = path.match(SPACE_TASKS_TAB_ROUTE_PATTERN); + if (!match) return null; + return { spaceId: match[1], tab: match[2] as 'action' | 'active' | 'completed' | 'archived' }; +} + /** * Extract space ID from /space/:id/sessions route * Returns null if not on a space sessions list route @@ -246,6 +279,22 @@ export function getSpaceTaskIdFromPath(path: string): { spaceId: string; taskId: return { spaceId: match[1], taskId: match[2] }; } +/** + * Extract space task IDs + view tab from /space/:id/task/:taskId/:view route. + * Checked BEFORE the generic task pattern so it takes priority. + */ +export function getSpaceTaskViewFromPath( + path: string +): { spaceId: string; taskId: string; view: 'thread' | 'canvas' | 'artifacts' } | null { + const match = path.match(SPACE_TASK_VIEW_ROUTE_PATTERN); + if (!match) return null; + return { + spaceId: match[1], + taskId: match[2], + view: match[3] as 'thread' | 'canvas' | 'artifacts', + }; +} + /** * Get current path from window.location */ @@ -356,15 +405,15 @@ export function createSpacePath(spaceId: string): string { /** * Create space configure URL path */ -export function createSpaceConfigurePath(spaceId: string): string { - return `/space/${spaceId}/configure`; +export function createSpaceConfigurePath(spaceId: string, tab?: string): string { + return tab ? `/space/${spaceId}/configure/${tab}` : `/space/${spaceId}/configure`; } /** * Create space tasks URL path */ -export function createSpaceTasksPath(spaceId: string): string { - return `/space/${spaceId}/tasks`; +export function createSpaceTasksPath(spaceId: string, tab?: string): string { + return tab ? `/space/${spaceId}/tasks/${tab}` : `/space/${spaceId}/tasks`; } /** @@ -377,8 +426,8 @@ export function createSpaceSessionPath(spaceId: string, sessionId: string): stri /** * Create space task URL path (task detail viewed within space layout) */ -export function createSpaceTaskPath(spaceId: string, taskId: string): string { - return `/space/${spaceId}/task/${taskId}`; +export function createSpaceTaskPath(spaceId: string, taskId: string, view?: string): string { + return view ? `/space/${spaceId}/task/${taskId}/${view}` : `/space/${spaceId}/task/${taskId}`; } /** @@ -1168,19 +1217,24 @@ export function navigateToSpace(spaceId: string, replace = false): void { /** * Navigate to the Space configure view - * Updates URL to /space/:spaceId/configure and keeps the space context panel active + * Updates URL to /space/:spaceId/configure[/tab] and keeps the space context panel active */ -export function navigateToSpaceConfigure(spaceId: string, replace = false): void { +export function navigateToSpaceConfigure( + spaceId: string, + tab?: 'agents' | 'workflows' | 'settings', + replace = false +): void { if (routerState.isNavigating) { return; } - const targetPath = createSpaceConfigurePath(spaceId); + const targetPath = createSpaceConfigurePath(spaceId, tab); const currentPath = getCurrentPath(); if (currentPath === targetPath) { currentSpaceIdSignal.value = spaceId; currentSpaceViewModeSignal.value = 'configure'; + currentSpaceConfigureTabSignal.value = tab ?? 'agents'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentSessionIdSignal.value = null; @@ -1202,6 +1256,7 @@ export function navigateToSpaceConfigure(spaceId: string, replace = false): void currentSpaceIdSignal.value = spaceId; currentSpaceViewModeSignal.value = 'configure'; + currentSpaceConfigureTabSignal.value = tab ?? 'agents'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentSessionIdSignal.value = null; @@ -1221,19 +1276,24 @@ export function navigateToSpaceConfigure(spaceId: string, replace = false): void /** * Navigate to the Space tasks view - * Updates URL to /space/:spaceId/tasks and keeps the space context panel active + * Updates URL to /space/:spaceId/tasks[/tab] and keeps the space context panel active */ -export function navigateToSpaceTasks(spaceId: string, replace = false): void { +export function navigateToSpaceTasks( + spaceId: string, + tab?: 'action' | 'active' | 'completed' | 'archived', + replace = false +): void { if (routerState.isNavigating) { return; } - const targetPath = createSpaceTasksPath(spaceId); + const targetPath = createSpaceTasksPath(spaceId, tab); const currentPath = getCurrentPath(); if (currentPath === targetPath) { currentSpaceIdSignal.value = spaceId; currentSpaceViewModeSignal.value = 'tasks'; + currentSpaceTasksFilterTabSignal.value = tab ?? 'active'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentSessionIdSignal.value = null; @@ -1255,6 +1315,7 @@ export function navigateToSpaceTasks(spaceId: string, replace = false): void { currentSpaceIdSignal.value = spaceId; currentSpaceViewModeSignal.value = 'tasks'; + currentSpaceTasksFilterTabSignal.value = tab ?? 'active'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentSessionIdSignal.value = null; @@ -1378,18 +1439,24 @@ export function navigateToSpaceSession(spaceId: string, sessionId: string, repla /** * Navigate to a task within a space layout */ -export function navigateToSpaceTask(spaceId: string, taskId: string, replace = false): void { +export function navigateToSpaceTask( + spaceId: string, + taskId: string, + view?: 'thread' | 'canvas' | 'artifacts', + replace = false +): void { if (routerState.isNavigating) { return; } - const targetPath = createSpaceTaskPath(spaceId, taskId); + const targetPath = createSpaceTaskPath(spaceId, taskId, view); const currentPath = getCurrentPath(); if (currentPath === targetPath) { currentSpaceIdSignal.value = spaceId; currentSpaceViewModeSignal.value = 'overview'; currentSpaceTaskIdSignal.value = taskId; + currentSpaceTaskViewTabSignal.value = view ?? 'thread'; currentSpaceSessionIdSignal.value = null; currentSessionIdSignal.value = null; currentRoomIdSignal.value = null; @@ -1411,6 +1478,7 @@ export function navigateToSpaceTask(spaceId: string, taskId: string, replace = f currentSpaceIdSignal.value = spaceId; currentSpaceViewModeSignal.value = 'overview'; currentSpaceTaskIdSignal.value = taskId; + currentSpaceTaskViewTabSignal.value = view ?? 'thread'; currentSpaceSessionIdSignal.value = null; currentSessionIdSignal.value = null; currentRoomIdSignal.value = null; @@ -1507,10 +1575,17 @@ function handlePopState(_event: PopStateEvent): void { const roomMission = getRoomMissionIdFromPath(path); const roomSession = getRoomSessionIdFromPath(path); const roomTask = getRoomTaskIdFromPath(path); - const spaceConfigure = getSpaceConfigureFromPath(path); - const spaceTasks = getSpaceTasksFromPath(path); + const spaceConfigureTab = getSpaceConfigureTabFromPath(path); + const spaceConfigure = spaceConfigureTab + ? spaceConfigureTab.spaceId + : getSpaceConfigureFromPath(path); + const spaceTasksTab = getSpaceTasksTabFromPath(path); + const spaceTasks = spaceTasksTab ? spaceTasksTab.spaceId : getSpaceTasksFromPath(path); + const spaceTaskView = getSpaceTaskViewFromPath(path); + const spaceTask = spaceTaskView + ? { spaceId: spaceTaskView.spaceId, taskId: spaceTaskView.taskId } + : getSpaceTaskIdFromPath(path); const spaceSessions = getSpaceSessionsListFromPath(path); - const spaceTask = getSpaceTaskIdFromPath(path); const spaceSession = getSpaceSessionIdFromPath(path); const spaceAgent = getSpaceAgentFromPath(path); const spaceId = getSpaceIdFromPath(path); @@ -1528,6 +1603,7 @@ function handlePopState(_event: PopStateEvent): void { currentSpaceIdSignal.value = spaceTask.spaceId; currentSpaceViewModeSignal.value = 'overview'; currentSpaceTaskIdSignal.value = spaceTask.taskId; + currentSpaceTaskViewTabSignal.value = spaceTaskView?.view ?? 'thread'; currentSpaceSessionIdSignal.value = null; currentRoomIdSignal.value = null; currentRoomSessionIdSignal.value = null; @@ -1566,6 +1642,7 @@ function handlePopState(_event: PopStateEvent): void { } else if (spaceTasks) { currentSpaceIdSignal.value = spaceTasks; currentSpaceViewModeSignal.value = 'tasks'; + currentSpaceTasksFilterTabSignal.value = spaceTasksTab?.tab ?? 'active'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentRoomIdSignal.value = null; @@ -1592,6 +1669,7 @@ function handlePopState(_event: PopStateEvent): void { } else if (spaceConfigure) { currentSpaceIdSignal.value = spaceConfigure; currentSpaceViewModeSignal.value = 'configure'; + currentSpaceConfigureTabSignal.value = spaceConfigureTab?.tab ?? 'agents'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentRoomIdSignal.value = null; @@ -1792,10 +1870,19 @@ export function initializeRouter(): string | null { const initialRoomMission = getRoomMissionIdFromPath(initialPath); const initialRoomSession = getRoomSessionIdFromPath(initialPath); const initialRoomTask = getRoomTaskIdFromPath(initialPath); - const initialSpaceConfigure = getSpaceConfigureFromPath(initialPath); - const initialSpaceTasks = getSpaceTasksFromPath(initialPath); + const initialSpaceConfigureTab = getSpaceConfigureTabFromPath(initialPath); + const initialSpaceConfigure = initialSpaceConfigureTab + ? initialSpaceConfigureTab.spaceId + : getSpaceConfigureFromPath(initialPath); + const initialSpaceTasksTab = getSpaceTasksTabFromPath(initialPath); + const initialSpaceTasks = initialSpaceTasksTab + ? initialSpaceTasksTab.spaceId + : getSpaceTasksFromPath(initialPath); const initialSpaceSessions = getSpaceSessionsListFromPath(initialPath); - const initialSpaceTask = getSpaceTaskIdFromPath(initialPath); + const initialSpaceTaskView = getSpaceTaskViewFromPath(initialPath); + const initialSpaceTask = initialSpaceTaskView + ? { spaceId: initialSpaceTaskView.spaceId, taskId: initialSpaceTaskView.taskId } + : getSpaceTaskIdFromPath(initialPath); const initialSpaceSession = getSpaceSessionIdFromPath(initialPath); const initialSpaceAgent = getSpaceAgentFromPath(initialPath); const initialSpaceId = getSpaceIdFromPath(initialPath); @@ -1806,6 +1893,7 @@ export function initializeRouter(): string | null { currentSpaceIdSignal.value = initialSpaceTask.spaceId; currentSpaceViewModeSignal.value = 'overview'; currentSpaceTaskIdSignal.value = initialSpaceTask.taskId; + currentSpaceTaskViewTabSignal.value = initialSpaceTaskView?.view ?? 'thread'; currentSpaceSessionIdSignal.value = null; currentRoomIdSignal.value = null; currentRoomSessionIdSignal.value = null; @@ -1844,6 +1932,7 @@ export function initializeRouter(): string | null { } else if (initialSpaceConfigure) { currentSpaceIdSignal.value = initialSpaceConfigure; currentSpaceViewModeSignal.value = 'configure'; + currentSpaceConfigureTabSignal.value = initialSpaceConfigureTab?.tab ?? 'agents'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentRoomIdSignal.value = null; @@ -1857,6 +1946,7 @@ export function initializeRouter(): string | null { } else if (initialSpaceTasks) { currentSpaceIdSignal.value = initialSpaceTasks; currentSpaceViewModeSignal.value = 'tasks'; + currentSpaceTasksFilterTabSignal.value = initialSpaceTasksTab?.tab ?? 'active'; currentSpaceSessionIdSignal.value = null; currentSpaceTaskIdSignal.value = null; currentRoomIdSignal.value = null; diff --git a/packages/web/src/lib/signals.ts b/packages/web/src/lib/signals.ts index 8c9ac3999..7c4155257 100644 --- a/packages/web/src/lib/signals.ts +++ b/packages/web/src/lib/signals.ts @@ -46,6 +46,18 @@ export const currentSpaceTaskIdSignal = signal(null); export type SpaceViewMode = 'overview' | 'tasks' | 'sessions' | 'configure'; export const currentSpaceViewModeSignal = signal('overview'); +// Configure sub-tab (agents | workflows | settings) — driven by URL +export type SpaceConfigureTab = 'agents' | 'workflows' | 'settings'; +export const currentSpaceConfigureTabSignal = signal('agents'); + +// Tasks filter tab (action | active | completed | archived) — driven by URL +export type SpaceTasksFilterTab = 'action' | 'active' | 'completed' | 'archived'; +export const currentSpaceTasksFilterTabSignal = signal('active'); + +// Task detail sub-view (thread | canvas | artifacts) — driven by URL +export type SpaceTaskViewTab = 'thread' | 'canvas' | 'artifacts'; +export const currentSpaceTaskViewTabSignal = signal('thread'); + // Tasks-view pre-filter, used by callers like the SpaceOverview awaiting-approval // summary to deep-link into a filtered tasks list (e.g. "completion-action pauses // only"). Set on navigation; SpaceTasks consumes it and reverts to null on the