diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index b531ad8b3..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'; @@ -43,6 +46,7 @@ import { navigateToSpaceAgent, navigateToSpaceSession, navigateToSpaceTask, + navigateToSettings, createSessionPath, createRoomPath, createRoomAgentPath, @@ -124,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 @@ -139,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 @@ -147,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 @@ -166,7 +183,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 @@ -174,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) { @@ -182,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) { @@ -201,6 +220,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/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]); @@ -198,23 +199,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 +260,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); }, })) ); @@ -343,7 +340,7 @@ export function SpaceTaskPane({ taskId, spaceId, onClose }: SpaceTaskPaneProps)
diff --git a/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx b/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx index ca552ae52..23d3e44ed 100644 --- a/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx +++ b/packages/web/src/components/space/__tests__/SpaceTaskPane.test.tsx @@ -10,16 +10,54 @@ import type { SpaceWorkflowRun, } from '@neokai/shared'; -const { mockNavigateToSpaceAgent } = vi.hoisted(() => ({ mockNavigateToSpaceAgent: vi.fn() })); +// 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 { @@ -30,6 +68,12 @@ vi.mock('../../../lib/signals', async (importOriginal) => { get spaceOverlayAgentNameSignal() { return mockSpaceOverlayAgentNameSignal; }, + get currentSpaceTaskViewTabSignal() { + return mockCurrentSpaceTaskViewTabSignal; + }, + get currentSpaceIdSignal() { + return mockCurrentSpaceIdSignal; + }, }; }); @@ -163,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(); }); @@ -370,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(() => { @@ -677,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/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..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,9 @@ vi.mock('../../lib/space-store', () => ({ vi.mock('../../lib/router', () => ({ navigateToSpace: vi.fn(), navigateToSpaceTask: vi.fn(), + navigateToSpaceConfigure: mockNavigateToSpaceConfigure, + pushOverlayHistory: vi.fn(), + closeOverlayHistory: vi.fn(), })); import SpaceIsland from '../SpaceIsland'; @@ -143,6 +183,9 @@ beforeEach(() => { mockWorkflows = signal([makeWorkflow()]); mockAgents = signal([]); capturedVisualEditorProps = {}; + configureTabBridge.signal.value = 'agents'; + idBridge.signal.value = null; + mockNavigateToSpaceConfigure.mockClear(); }); afterEach(() => { 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..41ce07a1e --- /dev/null +++ b/packages/web/src/lib/__tests__/overlay-history.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom +// @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..a903914f2 --- /dev/null +++ b/packages/web/src/lib/__tests__/settings-router.test.ts @@ -0,0 +1,138 @@ +// @vitest-environment happy-dom +// @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/__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 0ab637d98..795c25f72 100644 --- a/packages/web/src/lib/router.ts +++ b/packages/web/src/lib/router.ts @@ -27,7 +27,12 @@ import { currentSpaceSessionIdSignal, currentSpaceTaskIdSignal, currentSpaceViewModeSignal, + currentSpaceConfigureTabSignal, + currentSpaceTasksFilterTabSignal, + currentSpaceTaskViewTabSignal, navSectionSignal, + spaceOverlaySessionIdSignal, + spaceOverlayAgentNameSignal, } from './signals.ts'; /** Route patterns */ @@ -45,15 +50,22 @@ 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 */ 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$/; /** @@ -203,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 @@ -212,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 @@ -243,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 */ @@ -353,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`; } /** @@ -374,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}`; } /** @@ -392,6 +444,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 +1050,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); + } } /** @@ -1119,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; @@ -1153,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; @@ -1172,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; @@ -1206,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; @@ -1329,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; @@ -1362,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; @@ -1442,6 +1559,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); @@ -1450,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); @@ -1471,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; @@ -1509,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; @@ -1535,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; @@ -1680,6 +1815,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'; @@ -1722,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); @@ -1736,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; @@ -1774,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; @@ -1787,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; @@ -1937,6 +2097,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 +2136,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) */ 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