diff --git a/crates/tracedecay/tests/mcp_suite/serve_harness.rs b/crates/tracedecay/tests/mcp_suite/serve_harness.rs index 69cb5fa007e..8ab7e1afbc4 100644 --- a/crates/tracedecay/tests/mcp_suite/serve_harness.rs +++ b/crates/tracedecay/tests/mcp_suite/serve_harness.rs @@ -105,8 +105,10 @@ pub fn run_serve_after_ready( "params": initialize_params })); session.await_project_ready(); + // This is a connected host journey. Let each operation deliver its reply + // before timing the proxy's exit on EOF. for request in requests { - session.send(request); + session.request(request.clone()); } session.finish(SERVE_CHILD_TIMEOUT) } diff --git a/dashboard/src/app/channels.test.ts b/dashboard/src/app/channels.test.ts index ed5162fbec2..a2c6eb3dd25 100644 --- a/dashboard/src/app/channels.test.ts +++ b/dashboard/src/app/channels.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { channelForPathname, channelNumber } from './channels.ts'; +import { + CHANNELS, + channelForPathname, + channelNumber, + paletteMatchesQuery, + workspaceSearchText, +} from './channels.ts'; describe('channelNumber', () => { it('never invents a channel for a path it does not have', () => { @@ -26,3 +32,31 @@ describe('channelForPathname', () => { expect(channelForPathname('/tools')).toBeNull(); }); }); + +describe('command-palette task discovery', () => { + const matches = (query: string) => + CHANNELS.filter((channel) => + paletteMatchesQuery(workspaceSearchText(channel), query), + ).map((channel) => channel.path); + + it('finds task destinations when their workspace names are unknown', () => { + expect(matches('temporal replay')).toContain('loom'); + expect(matches('transcripts')).toContain('sessions'); + expect(matches('source symbols')).toContain('code'); + expect(matches(' PR REVIEWS ')).toContain('delivery'); + expect(matches('task board')).toContain('work'); + expect(matches('scheduled jobs')).toContain('automations'); + }); + + it('keeps existing workspace-name and project-name matches', () => { + expect(matches('code')).toContain('code'); + expect(matches('code')).toContain('explorer'); + expect(paletteMatchesQuery('garden project', ' GARDEN ')).toBe(true); + expect(paletteMatchesQuery('garden project', 'temporal')).toBe(false); + }); + + it('preserves every existing route on empty input and does not invent destinations', () => { + expect(matches('')).toEqual(CHANNELS.map((channel) => channel.path)); + expect(matches('not-a-real-destination')).toEqual([]); + }); +}); diff --git a/dashboard/src/app/channels.ts b/dashboard/src/app/channels.ts index f29ed45ede8..1dbe0a68345 100644 --- a/dashboard/src/app/channels.ts +++ b/dashboard/src/app/channels.ts @@ -17,25 +17,100 @@ export interface Channel { readonly path: string; readonly label: string; + /** Visible task hint, also searchable from the command palette. */ + readonly taskHint: string; } export const CHANNELS: readonly Channel[] = [ - { path: 'brain', label: 'Brain' }, - { path: 'explorer', label: 'Explorer' }, - { path: 'loom', label: 'Loom' }, - { path: 'sessions', label: 'Sessions' }, - { path: 'agents', label: 'Agents' }, - { path: 'code', label: 'Code' }, - { path: 'knowledge', label: 'Knowledge' }, - { path: 'delivery', label: 'Delivery' }, - { path: 'automations', label: 'Automations' }, - { path: 'observatory', label: 'Observatory' }, - { path: 'costs', label: 'Costs' }, - { path: 'settings', label: 'Settings' }, - { path: 'work', label: 'Work' }, - { path: 'workflows', label: 'Workflows' }, + { + path: 'brain', + label: 'Brain', + taskHint: 'Project health, scope and relationships', + }, + { + path: 'explorer', + label: 'Explorer', + taskHint: 'Search code, sessions and knowledge together', + }, + { + path: 'loom', + label: 'Loom', + taskHint: 'Agent timelines and temporal replay', + }, + { + path: 'sessions', + label: 'Sessions', + taskHint: 'Session transcripts and history', + }, + { + path: 'agents', + label: 'Agents', + taskHint: 'Agent identities and activity', + }, + { + path: 'code', + label: 'Code', + taskHint: 'Source files, symbols and dependencies', + }, + { + path: 'knowledge', + label: 'Knowledge', + taskHint: 'Memory facts and citations', + }, + { + path: 'delivery', + label: 'Delivery', + taskHint: 'PR reviews, CI checks and releases', + }, + { + path: 'automations', + label: 'Automations', + taskHint: 'Scheduled jobs, skills and runs', + }, + { + path: 'observatory', + label: 'Observatory', + taskHint: 'Diagnostics, storage health and telemetry', + }, + { + path: 'costs', + label: 'Costs', + taskHint: 'Token usage, savings and spending', + }, + { + path: 'settings', + label: 'Settings', + taskHint: 'Configuration, integrations and permissions', + }, + { + path: 'work', + label: 'Work', + taskHint: 'Task board, plans and progress', + }, + { + path: 'workflows', + label: 'Workflows', + taskHint: 'Workflow definitions, execution and runs', + }, ] as const; +/** Search the task described by a workspace, rather than only its proper name. + * The rail continues to use the established label and route unchanged. */ +export function workspaceSearchText(channel: Channel): string { + return `${channel.label} ${channel.taskHint}`.toLowerCase(); +} + +/** Match all typed words, regardless of whitespace/case or the order in which + * the workspace's label and task description supply them. Empty queries show + * every available entry, including registered projects. */ +export function paletteMatchesQuery(searchText: string, query: string): boolean { + return query + .trim() + .toLowerCase() + .split(/\s+/) + .every((term) => searchText.includes(term)); +} + /** Zero-padded channel number for a workspace path (`code` → `06`). Unknown * paths get `--`: the instrument never invents a channel it does not have. */ export function channelNumber(path: string): string { diff --git a/dashboard/src/app/shell/CommandPalette.dom.test.tsx b/dashboard/src/app/shell/CommandPalette.dom.test.tsx index fd49e43461c..c4a69255c7d 100644 --- a/dashboard/src/app/shell/CommandPalette.dom.test.tsx +++ b/dashboard/src/app/shell/CommandPalette.dom.test.tsx @@ -20,7 +20,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router'; +import { MemoryRouter, useLocation } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CommandPalette } from './CommandPalette.tsx'; @@ -110,14 +110,20 @@ function stubListing(payload: ProjectsPayloadV1) { ); } -function renderPalette() { +function PaletteLocation() { + const location = useLocation(); + return {location.pathname}{location.search}{location.hash}; +} + +function renderPalette(initialEntry = '/') { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return { client, ...render( - + {}} /> + , ), @@ -153,6 +159,138 @@ afterEach(() => { }); describe('CommandPalette keyboard operation', () => { + it.each([ + ['/loom?loomRenderer=rails&session=run-7', 'Loom'], + ['/Loom/?loomRenderer=rails&session=run-7', 'Loom'], + ['/brain?field=sigma&node=node-3', 'Brain'], + ['/?field=sigma&node=node-3', 'Brain'], + ['/code?render=relief&symbol=symbol-2', 'Code'], + ])('reselects %s without resetting its active view', async (entry, label) => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(entry); + await findByRole('listbox'); + + await user.keyboard(label); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + + expect(getByTestId('palette-location').textContent).toBe(entry); + }); + + it('keeps the selected-project view only for the same scoped route', async () => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const original = '/loom?scope=project-1&scopeLabel=Alpha&loomRenderer=strata'; + const { findByRole, getByTestId } = renderPalette(original); + await findByRole('listbox'); + + await user.keyboard('loom{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(original); + }); + + it.each([ + [ + '/loom?scope=project-1&loomRenderer=strata&session=run-4', + 'Loom', + '/loom?scope=project-1&loomRenderer=strata&session=run-4&scopeLabel=Alpha', + ], + [ + '/brain?scope=project-1&scopeLabel=OldName&field=sigma&node=node-3#selection', + 'Brain', + '/brain?scope=project-1&scopeLabel=Alpha&field=sigma&node=node-3#selection', + ], + ])('reconciles the label in %s without dropping its selected view', async (url, label, expected) => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(url); + await findByRole('listbox'); + + await user.keyboard(label); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(expected); + }); + + it('does not retain another project\'s view when switching to the selected scope', async () => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette( + '/loom?scope=project-2&scopeLabel=Beta&loomRenderer=rails', + ); + await findByRole('listbox'); + + await user.keyboard('loom{Enter}'); + expect(getByTestId('palette-location').textContent).toBe( + '/loom?scope=project-1&scopeLabel=Alpha', + ); + }); + + it.each([ + [ + '/?scopeLabel=Ghost&field=sigma&node=node-3#selection', + 'Brain', + '/?field=sigma&node=node-3#selection', + ], + [ + '/Loom/?scopeLabel=Ghost&loomRenderer=strata&session=run-4#replay', + 'Loom', + '/Loom/?loomRenderer=strata&session=run-4#replay', + ], + ])('removes a stray label from %s without resetting the view', async (url, label, expected) => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(url); + await findByRole('listbox'); + + await user.keyboard(label); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(expected); + }); + + it('does not carry a workspace view into a different workspace', async () => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette('/loom?loomRenderer=rails'); + await findByRole('listbox'); + + await user.keyboard('code{Enter}'); + expect(getByTestId('palette-location').textContent).toBe('/code'); + }); + + it('updates project scope rather than retaining an unscoped view', async () => { + const user = userEvent.setup(); + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette('/loom?loomRenderer=rails'); + await findByRole('listbox'); + + await user.keyboard('loom{Enter}'); + expect(getByTestId('palette-location').textContent).toBe('/loom?scope=project-1&scopeLabel=Alpha'); + }); + + it.each([ + ['code', 'Code'], + ['sessions', 'Sessions'], + ['knowledge', 'Knowledge'], + ])('opens the exact %s workspace instead of a matching task hint', async (query, label) => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId } = renderPalette(); + await findByRole('listbox'); + + // Explorer occurs earlier in CHANNELS and mentions each term in its + // hint. Enter must still navigate to the exact named workspace. + await user.keyboard(query); + await waitFor(() => expect(activeOptionLabel()).toBe(label)); + await user.keyboard('{Enter}'); + expect(getByTestId('palette-location').textContent).toBe(`/${query}`); + }); + it('focuses the search box on open, so the first keystroke reaches it', async () => { stubListing(listing([])); renderPalette(); @@ -336,6 +474,103 @@ describe('CommandPalette long and shrinking lists', () => { expect(active?.getAttribute('aria-selected')).toBe('true'); }); + it('keeps keyboard activation when a project appears after an empty search', async () => { + const user = userEvent.setup(); + stubListing(listing([])); + const { findByRole, getByTestId, client } = renderPalette(); + await findByRole('listbox'); + + await user.keyboard('RareProject'); + await waitFor(() => expect(activeOptionLabel()).toBe('(nothing is announced as active)')); + await user.keyboard('{ArrowDown}'); + + // The registry can finish a new read without the user typing again. + // Before the fix ArrowDown wrote -1 while results were empty; the matching + // row then existed but lacked an active IDREF and Enter could not select it. + act(() => { + client.setQueryData([...projectRegistryListKey, UNSCOPED_CACHE_KEY], { + outcome: 'envelope', + envelope: fixtureEnvelope(listing([registryEntry('proj-new', 'RareProject', true)])), + }); + }); + await waitFor(() => expect(activeOptionLabel()).toBe('RareProject')); + const id = combobox().getAttribute('aria-activedescendant'); + expect(id).toBeTruthy(); + expect(document.getElementById(id!)?.getAttribute('aria-selected')).toBe('true'); + + await user.keyboard('{Enter}'); + expect(useScope.getState().scope).toMatchObject({ + kind: 'project', + projectId: 'proj-new', + activation: 'active', + }); + // Project activation is a scoped navigation, not a plain workspace visit. + expect(getByTestId('palette-location').textContent).toBe( + '/brain?scope=proj-new&scopeLabel=RareProject', + ); + }); + + it('keeps the selected project identity when a registry refresh reorders results', async () => { + const user = userEvent.setup(); + const alpha = registryEntry('proj-alpha', 'Rare Alpha', false); + const beta = registryEntry('proj-beta', 'Rare Beta', true); + stubListing(listing([alpha, beta])); + const { findByText, client } = renderPalette(); + await findByText('Rare Beta'); + + await user.keyboard('Rare'); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Alpha')); + await user.keyboard('{ArrowDown}'); + expect(activeOptionLabel()).toBe('Rare Beta'); + + // Reordered registration results must not silently select a different + // project while the keyboard is stationary. + act(() => { + client.setQueryData([...projectRegistryListKey, UNSCOPED_CACHE_KEY], { + outcome: 'envelope', + envelope: fixtureEnvelope(listing([beta, alpha])), + }); + }); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Beta')); + const id = combobox().getAttribute('aria-activedescendant'); + expect(document.getElementById(id ?? '')?.getAttribute('aria-selected')).toBe('true'); + + await user.keyboard('{Enter}'); + expect(useScope.getState().scope).toMatchObject({ + kind: 'project', + projectId: 'proj-beta', + activation: 'active', + }); + }); + + it('forgets a chosen project when the search is edited and then restored', async () => { + const user = userEvent.setup(); + const alpha = registryEntry('proj-alpha', 'Rare Alpha', false); + const beta = registryEntry('proj-beta', 'Rare Beta', true); + stubListing(listing([alpha, beta])); + const { findByText } = renderPalette(); + await findByText('Rare Beta'); + + await user.keyboard('Rare'); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Alpha')); + await user.keyboard('{ArrowDown}'); + expect(activeOptionLabel()).toBe('Rare Beta'); + + await user.keyboard('x'); + await waitFor(() => + expect(activeOptionLabel()).toBe('(nothing is announced as active)'), + ); + await user.keyboard('{Backspace}'); + await waitFor(() => expect(activeOptionLabel()).toBe('Rare Alpha')); + + await user.keyboard('{Enter}'); + expect(useScope.getState().scope).toMatchObject({ + kind: 'project', + projectId: 'proj-alpha', + activation: 'selected', + }); + }); + it('clamps the active row when the listing shrinks, and Enter still fires', async () => { const user = userEvent.setup(); stubListing(listing(MANY)); diff --git a/dashboard/src/app/shell/CommandPalette.tsx b/dashboard/src/app/shell/CommandPalette.tsx index 807d9534bfd..aa61c76a331 100644 --- a/dashboard/src/app/shell/CommandPalette.tsx +++ b/dashboard/src/app/shell/CommandPalette.tsx @@ -1,9 +1,13 @@ import * as Dialog from '@radix-ui/react-dialog'; import { Command as CommandIcon, CornerDownLeft, Search } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router'; -import { WORKSPACES } from '../routes'; -import { channelNumber } from '../channels.ts'; +import { matchPath, useLocation, useNavigate } from 'react-router'; +import { + CHANNELS, + channelNumber, + paletteMatchesQuery, + workspaceSearchText, +} from '../channels.ts'; import { cn } from '../../ui/cn'; import { useProjectRegistry, projectRegistryPayload } from '../../data/query/projectRegistry.ts'; import { @@ -16,6 +20,7 @@ interface CommandPaletteRow { id: string; label: string; hint: string; + searchText: string; /** The channel number for a workspace row; project rows have none. */ channel: string | null; action: () => void; @@ -49,10 +54,15 @@ export function CommandPalette({ onOpenChange: (open: boolean) => void; }) { const navigate = useNavigate(); + const location = useLocation(); const scope = useScope((state) => state.scope); const selectProject = useScope((s) => s.selectProject); const [query, setQuery] = useState(''); - const [active, setActive] = useState(0); + const [selection, setSelection] = useState<{ + query: string; + index: number; + rowId: string | null; + }>({ query: '', index: 0, rowId: null }); // Entities: registered projects become scope-setting results. Fetched only // while the palette is open; a failed registry read simply yields no // project rows (workspace navigation never depends on it). @@ -62,13 +72,42 @@ export function CommandPalette({ const projects = useProjectRegistry({ enabled: open }); const entries = useMemo(() => { - const workspaceEntries = WORKSPACES.map((w) => ({ - id: `nav:${w.path}`, - label: w.label, - hint: 'workspace', - channel: channelNumber(w.path), + const workspaceEntries = CHANNELS.map((channel) => ({ + id: `nav:${channel.path}`, + label: channel.label, + hint: channel.taskHint, + searchText: workspaceSearchText(channel), + channel: channelNumber(channel.path), action: () => { - navigate(scopedWorkspacePath(scope, w.path)); + // Reselecting this workspace is not a renderer or selection reset. + // Only an actual workspace/scope transition replaces view-owned state. + const params = new URLSearchParams(location.search); + // A deep link's scopeLabel is only a display claim. Project identity + // is the admitted scope ID; a missing/stale label must not erase the + // selected renderer, replay window, or entity in that same project. + const sameScope = scope.kind === 'all' + ? !params.has('scope') + : params.get('scope') === scope.projectId; + // The index route mounts Brain too. Reopening it through the palette + // must preserve field=sigma and entity selection just like /brain. + const sameWorkspace = matchPath(`/${channel.path}`, location.pathname) !== null || + (channel.path === 'brain' && location.pathname === '/'); + if (!sameWorkspace || !sameScope) { + navigate(scopedWorkspacePath(scope, channel.path)); + } else if ( + (scope.kind === 'all' && params.has('scopeLabel')) || + (scope.kind === 'project' && params.get('scopeLabel') !== scope.label) + ) { + // A scopeLabel without a scope ID is not a project selection. Remove + // its unverified claim without resetting the current view or entity. + // For a real project, refresh only the display label. + if (scope.kind === 'project') params.set('scopeLabel', scope.label); + else params.delete('scopeLabel'); + navigate( + { pathname: location.pathname, search: `?${params.toString()}`, hash: location.hash }, + { replace: true }, + ); + } onOpenChange(false); }, })); @@ -80,6 +119,7 @@ export function CommandPalette({ id: `scope:${project.project_id}:${project.canonical_root}`, label: project.label, hint: `project · ${group.label}`, + searchText: project.label.toLowerCase(), channel: null, action: () => { // The listing already measured `is_active`, against the same @@ -104,34 +144,50 @@ export function CommandPalette({ ) : []; return [...workspaceEntries, ...projectEntries]; - }, [navigate, onOpenChange, projects.data, scope, selectProject]); + }, [navigate, location.pathname, location.search, location.hash, onOpenChange, projects.data, scope, selectProject]); const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return entries; - return entries.filter((entry) => entry.label.toLowerCase().includes(q)); + const needle = query.trim().toLowerCase(); + const matchRank = (entry: CommandPaletteRow) => { + const label = entry.label.toLowerCase(); + if (label === needle) return 0; + if (label.includes(needle)) return 1; + return 2; + }; + // Preserve the rail/project order inside each rank while ensuring that + // typing a workspace's exact name selects that workspace on Enter. + return entries + .filter((entry) => paletteMatchesQuery(entry.searchText, query)) + .sort((left, right) => matchRank(left) - matchRank(right)); }, [entries, query]); - useEffect(() => setActive(0), [query, open]); useEffect(() => { - if (open) setQuery(''); + if (open) { + setQuery(''); + setSelection({ query: '', index: 0, rowId: null }); + } }, [open]); /** - * The row actually being pointed at, which is not always the one `active` - * holds. - * - * `active` is only reset by a query change or a reopen, and the list has a - * second input: the registry read behind the project rows. A listing that - * shrinks, a refetch returning fewer projects, or an `ok` result becoming a - * failure, which drops every project row at once, leaves the stored index - * past the end. That row does not exist, so `aria-activedescendant` referred - * to nothing and Enter fired nothing: a palette that looked navigable and was - * inert. Clamped on read rather than corrected in an effect, so there is no - * render in which the index is out of range. + * Preserve the selected row's identity when a registry refresh reorders + * entries. A numeric index alone can silently activate a different project + * after the registry changes. When that identity disappears, clamp its last + * position to an extant row. Query changes reset selection synchronously. */ - const activeIndex = filtered.length === 0 ? 0 : Math.min(active, filtered.length - 1); + const currentSelection = + selection.query === query ? selection : { query, index: 0, rowId: null }; + const matchingIndex = currentSelection.rowId === null + ? -1 + : filtered.findIndex((entry) => entry.id === currentSelection.rowId); + const activeIndex = filtered.length === 0 + ? 0 + : matchingIndex >= 0 + ? matchingIndex + : Math.max(0, Math.min(currentSelection.index, filtered.length - 1)); const activeEntry = filtered[activeIndex]; + const selectIndex = (index: number) => { + setSelection({ query, index, rowId: filtered[index]?.id ?? null }); + }; const listRef = useRef(null); useEffect(() => { @@ -154,10 +210,12 @@ export function CommandPalette({ e.preventDefault(); // From the clamped index, so a shrunk list steps from its last real row // rather than from a stale index further down. - setActive(Math.min(activeIndex + 1, filtered.length - 1)); + // An empty result set has no final row. Setting -1 here would leave + // a newly fetched matching project invisible to Enter and screen readers. + if (filtered.length > 0) selectIndex(Math.min(activeIndex + 1, filtered.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setActive(Math.max(activeIndex - 1, 0)); + if (filtered.length > 0) selectIndex(Math.max(activeIndex - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); activeEntry?.action(); @@ -185,7 +243,13 @@ export function CommandPalette({ setQuery(e.target.value)} + onChange={(e) => { + const nextQuery = e.target.value; + // A query edit starts a new search even if the text later + // returns to an earlier value. Registry refreshes do not. + setSelection({ query: nextQuery, index: 0, rowId: null }); + setQuery(nextQuery); + }} placeholder="Go to workspace or project…" className="min-h-[var(--touch-target-min)] w-full bg-transparent text-body outline-none placeholder:text-text-muted" role="combobox" @@ -212,7 +276,7 @@ export function CommandPalette({ id={optionId(i)} role="option" aria-selected={i === activeIndex} - onMouseEnter={() => setActive(i)} + onMouseEnter={() => selectIndex(i)} onClick={entry.action} className={cn( // The same selection grammar as the rail: a cyan position @@ -234,10 +298,12 @@ export function CommandPalette({ > {entry.channel ?? ''} - {entry.label} - - {entry.hint} - {i === activeIndex ? : null} + {entry.label} + + {entry.hint} + {i === activeIndex ? ( + + ) : null} )) diff --git a/dashboard/src/app/shell/ScopeBar.tsx b/dashboard/src/app/shell/ScopeBar.tsx index 180f11c8484..5e791524c45 100644 --- a/dashboard/src/app/shell/ScopeBar.tsx +++ b/dashboard/src/app/shell/ScopeBar.tsx @@ -34,7 +34,7 @@ export function ScopeBar({ channel, onOpenPalette, }: { - channel?: Channel | null; + channel?: Pick | null; onOpenPalette?: () => void; }) { const scope = useScope((s) => s.scope); diff --git a/dashboard/src/workspaces/delivery/DeliveryPage.dom.test.tsx b/dashboard/src/workspaces/delivery/DeliveryPage.dom.test.tsx index 29cce9dc84d..3f0e5e04206 100644 --- a/dashboard/src/workspaces/delivery/DeliveryPage.dom.test.tsx +++ b/dashboard/src/workspaces/delivery/DeliveryPage.dom.test.tsx @@ -75,7 +75,7 @@ describe('DeliveryPage · inbox', () => { expect(within(correlation).getByText('WORK / EXPLICIT')).toBeTruthy(); expect(within(correlation).getByText('TRANSCRIPT / INFERRED')).toBeTruthy(); expect(within(correlation).getByRole('link', { name: /Open in Loom/ }).getAttribute('href')).toBe( - '/loom?loomSession=session.alpha.1', + '/loom?loomBareSession=session.alpha.1', ); }); diff --git a/dashboard/src/workspaces/delivery/JourneyWorkspace.dom.test.tsx b/dashboard/src/workspaces/delivery/JourneyWorkspace.dom.test.tsx index a992d1616dd..cb14568a2bf 100644 --- a/dashboard/src/workspaces/delivery/JourneyWorkspace.dom.test.tsx +++ b/dashboard/src/workspaces/delivery/JourneyWorkspace.dom.test.tsx @@ -81,7 +81,7 @@ describe('JourneyWorkspace', () => { const session = within(table).getByRole('button', { name: 'Session session.alpha.1' }).closest('tr')!; expect(within(session).getByText('TRANSCRIPT / INFERRED')).toBeTruthy(); - expect(within(session).getByRole('link').getAttribute('href')).toBe('/loom?loomSession=session.alpha.1'); + expect(within(session).getByRole('link').getAttribute('href')).toBe('/loom?loomBareSession=session.alpha.1'); const rows = within(table).getAllByRole('row').slice(1); const undatedIndex = rows.findIndex((row) => row.textContent?.startsWith('undated')); diff --git a/dashboard/src/workspaces/delivery/journey.test.ts b/dashboard/src/workspaces/delivery/journey.test.ts index bddcb68a9a2..c96f7fabf01 100644 --- a/dashboard/src/workspaces/delivery/journey.test.ts +++ b/dashboard/src/workspaces/delivery/journey.test.ts @@ -83,7 +83,7 @@ describe('buildJourney', () => { expect(byLane.get('sessions')!.episodes[0]).toMatchObject({ grade: 'inferred', at: null, - href: '/loom?loomSession=session.alpha.1', + href: '/loom?loomBareSession=session.alpha.1', }); // Objective, session, and the two agent-usage rows carry no event time. expect(model.undated).toBe(4); @@ -101,7 +101,7 @@ describe('buildJourney', () => { expect(Object.fromEntries(url.searchParams)).toEqual({ scope: 'project.alpha', scopeLabel: 'Alpha', - loomSession: 'session.alpha.1', + loomBareSession: 'session.alpha.1', }); }); diff --git a/dashboard/src/workspaces/loom/LoomPage.dom.test.tsx b/dashboard/src/workspaces/loom/LoomPage.dom.test.tsx index acdd0c0acb1..c1b2d395b4d 100644 --- a/dashboard/src/workspaces/loom/LoomPage.dom.test.tsx +++ b/dashboard/src/workspaces/loom/LoomPage.dom.test.tsx @@ -829,24 +829,46 @@ describe('LoomPage', () => { it('opens the thread a Delivery link names by its bare session id', async () => { const temporal = structuredClone(TEMPORAL); temporal.payload.sessions[0]!.session_id = 'session.alpha.1'; - renderLoom({ ...HAPPY, '/api/loom/temporal': { status: 200, body: temporal } }, '/loom?loomSession=session.alpha.1'); + renderLoom({ ...HAPPY, '/api/loom/temporal': { status: 200, body: temporal } }, '/loom?loomBareSession=session.alpha.1'); const thread = await screen.findByRole('region', { name: 'Loaded execution' }); expect(thread.textContent).toContain('Deliver Git primitive runtime'); expect(screen.queryByText(/outside this loaded page/)).toBeNull(); - expect(screen.getByTestId('loom-url').textContent).toBe('?loomSession=session.alpha.1'); + expect(screen.getByTestId('loom-url').textContent).toBe('?loomBareSession=session.alpha.1'); + }); + + it('does not open a matching thread through the removed raw lane parameter', async () => { + renderLoom(HAPPY, '/loom?loomSession=sess-open'); + await screen.findByRole('button', { name: 'Select session Deliver Git primitive runtime' }); + expect(screen.queryByRole('region', { name: 'Loaded execution' })).toBeNull(); + expect(screen.queryByText(/outside this loaded page/)).toBeNull(); + }); + + it('keeps a tuple-shaped bare ID on its actual session instead of another provider lane', async () => { + const temporal = structuredClone(TEMPORAL); + const literalId = '["claude","sess-closed"]'; + temporal.payload.sessions[0]!.session_id = literalId; + renderLoom( + { ...HAPPY, '/api/loom/temporal': { status: 200, body: temporal } }, + sessionHref('loom', { provider: null, sessionId: literalId }, useScope.getState().scope), + ); + const thread = await screen.findByRole('region', { name: 'Loaded execution' }); + expect(thread.textContent).toContain('Deliver Git primitive runtime'); + expect(thread.textContent).not.toContain('Verify QUERY scheduler'); + expect(screen.getByTestId('loom-url').textContent).toContain('loomBareSession='); + expect(screen.queryByText(/outside this loaded page/)).toBeNull(); }); it('opens no thread for a bare id two providers answer to and says so', async () => { const temporal = structuredClone(TEMPORAL); temporal.payload.sessions[1]!.session_id = 'sess-open'; - renderLoom({ ...HAPPY, '/api/loom/temporal': { status: 200, body: temporal } }, '/loom?loomSession=sess-open'); + renderLoom({ ...HAPPY, '/api/loom/temporal': { status: 200, body: temporal } }, '/loom?loomBareSession=sess-open'); expect(await screen.findByText(/names 2 sessions on this loaded page/)).toBeTruthy(); expect(screen.queryByRole('region', { name: 'Loaded execution' })).toBeNull(); expect(screen.queryByText(/outside this loaded page/)).toBeNull(); }); it('offers the session\'s transcript and delegation when a link names one outside the loaded page', async () => { - renderLoom(HAPPY, '/loom?loomSession=session.old'); + renderLoom(HAPPY, '/loom?loomBareSession=session.old'); expect(await screen.findByText(/outside this loaded page/)).toBeTruthy(); const links = screen.getByRole('navigation', { name: 'Open this session in' }); const href = (name: RegExp) => { diff --git a/dashboard/src/workspaces/loom/LoomPage.tsx b/dashboard/src/workspaces/loom/LoomPage.tsx index f67c90e6d75..3b7d877f0c7 100644 --- a/dashboard/src/workspaces/loom/LoomPage.tsx +++ b/dashboard/src/workspaces/loom/LoomPage.tsx @@ -85,8 +85,11 @@ export function LoomPage() { const proximity = useLoomProximity(); const [params, setParams] = useSearchParams(); const selectedId = params.get(LOOM_PARAMS.session); + const selectedBareId = params.get(LOOM_PARAMS.bareSession); const setSelectedId = (id: string | null) => { const next = new URLSearchParams(params); + // Scene selection is a qualified lane, superseding any bare deep link. + next.delete(LOOM_PARAMS.bareSession); if (id == null) next.delete(LOOM_PARAMS.session); else next.set(LOOM_PARAMS.session, id); next.delete(LOOM_PARAMS.event); @@ -118,6 +121,7 @@ export function LoomPage() { timelinePending={timeline.isPending} timelineServed={timeline.data?.outcome === 'envelope'} selectedId={selectedId} + selectedBareId={selectedBareId} selectedEncounterId={proximity.selectedEncounterId} proximity={proximity.result} proximityEncounters={proximity.encounters} @@ -143,6 +147,7 @@ function TemporalBody({ timelinePending, timelineServed, selectedId, + selectedBareId, selectedEncounterId, proximity, proximityEncounters, @@ -156,6 +161,7 @@ function TemporalBody({ timelinePending: boolean; timelineServed: boolean; selectedId: string | null; + selectedBareId: string | null; selectedEncounterId: string | null; proximity: LoomProximityState['result']; proximityEncounters: LoomProximityState['encounters']; @@ -168,7 +174,10 @@ function TemporalBody({ const data = envelope.payload; const rows = data.sessions ?? []; - const selection = useMemo(() => resolveLoomSession(rows, parseLoomSession(selectedId)), [rows, selectedId]); + const selection = useMemo( + () => resolveLoomSession(rows, parseLoomSession(selectedId, selectedBareId)), + [rows, selectedId, selectedBareId], + ); const selectedLane = useMemo( () => (selection.kind === 'lane' ? { id: selection.laneId, sessionId: selection.row.session_id } : null), [selection], diff --git a/dashboard/src/workspaces/loom/loomUrl.test.ts b/dashboard/src/workspaces/loom/loomUrl.test.ts index 448737b023d..cf964bb9fba 100644 --- a/dashboard/src/workspaces/loom/loomUrl.test.ts +++ b/dashboard/src/workspaces/loom/loomUrl.test.ts @@ -72,28 +72,40 @@ describe('loomUrl', () => { expect(parseZoom(null)).toBe('agent'); }); - it('reads loomSession as a lane tuple or as the bare id Delivery links with', () => { - expect(parseLoomSession(null)).toBeNull(); - expect(parseLoomSession('')).toBeNull(); - expect(parseLoomSession('["claude","a"]')).toEqual({ + it('reads qualified lanes and explicit bare identities without a legacy fallback', () => { + expect(parseLoomSession(null, null)).toBeNull(); + expect(parseLoomSession('', null)).toBeNull(); + expect(parseLoomSession(null, '')).toBeNull(); + expect(parseLoomSession('["claude","a"]', null)).toEqual({ kind: 'lane', laneId: '["claude","a"]', selection: { provider: 'claude', sessionId: 'a' }, }); - expect(parseLoomSession('["unknown","a"]')).toEqual({ + expect(parseLoomSession('["unknown","a"]', null)).toEqual({ kind: 'lane', laneId: '["unknown","a"]', selection: { provider: null, sessionId: 'a' }, }); - expect(parseLoomSession('session.alpha.1')).toEqual({ + expect(parseLoomSession(null, 'session.alpha.1')).toEqual({ kind: 'bare_id', selection: { provider: null, sessionId: 'session.alpha.1' }, }); - expect(parseLoomSession('["claude"')).toEqual({ + expect(parseLoomSession(null, '["claude","other"]')).toEqual({ kind: 'bare_id', - selection: { provider: null, sessionId: '["claude"' }, + selection: { provider: null, sessionId: '["claude","other"]' }, }); - expect(parseLoomSession('["claude"]')).toMatchObject({ kind: 'bare_id' }); + // The explicit bare identity outranks a stale/foreign lane param. + expect(parseLoomSession('["claude","other"]', '["codex","literal"]')).toEqual({ + kind: 'bare_id', + selection: { provider: null, sessionId: '["codex","literal"]' }, + }); + for (const literalId of ['session.alpha.1', '["claude"', '["claude"]', '{"sessionId":"a"}']) { + expect(parseLoomSession(literalId, null)).toBeNull(); + expect(parseLoomSession(null, literalId)).toEqual({ + kind: 'bare_id', + selection: { provider: null, sessionId: literalId }, + }); + } }); it('resolves a link against the loaded page without guessing between providers', () => { @@ -101,23 +113,32 @@ describe('loomUrl', () => { const codex = row('codex', 'shared'); const unrecorded = row('', 'quiet'); const rows = [claude, codex, unrecorded]; - const resolve = (raw: string | null) => resolveLoomSession(rows, parseLoomSession(raw)); + const resolve = (raw: string | null, bareId: string | null) => resolveLoomSession(rows, parseLoomSession(raw, bareId)); - expect(resolve(null)).toEqual({ kind: 'none' }); - expect(resolve('["codex","shared"]')).toEqual({ kind: 'lane', laneId: '["codex","shared"]', row: codex }); - expect(resolve('["unknown","quiet"]')).toEqual({ kind: 'lane', laneId: '["unknown","quiet"]', row: unrecorded }); - expect(resolve('quiet')).toEqual({ kind: 'lane', laneId: '["unknown","quiet"]', row: unrecorded }); - expect(resolve('["","quiet"]')).toEqual({ kind: 'lane', laneId: '["unknown","quiet"]', row: unrecorded }); - expect(resolve('shared')).toEqual({ + expect(resolve(null, null)).toEqual({ kind: 'none' }); + expect(resolve('["codex","shared"]', null)).toEqual({ kind: 'lane', laneId: '["codex","shared"]', row: codex }); + expect(resolve('["unknown","quiet"]', null)).toEqual({ kind: 'lane', laneId: '["unknown","quiet"]', row: unrecorded }); + expect(resolve(null, 'quiet')).toEqual({ kind: 'lane', laneId: '["unknown","quiet"]', row: unrecorded }); + expect(resolve('["","quiet"]', null)).toEqual({ kind: 'lane', laneId: '["unknown","quiet"]', row: unrecorded }); + expect(resolve(null, 'shared')).toEqual({ kind: 'ambiguous', selection: { provider: null, sessionId: 'shared' }, count: 2, }); - expect(resolve('elsewhere')).toEqual({ + // A matching row does not make the obsolete raw lane shape valid. + expect(resolve('quiet', null)).toEqual({ kind: 'none' }); + expect(resolve('shared', null)).toEqual({ kind: 'none' }); + const literalId = row('codex', '["claude","shared"]'); + expect(resolveLoomSession([...rows, literalId], parseLoomSession(null, literalId.session_id))).toEqual({ + kind: 'lane', + laneId: JSON.stringify(['codex', literalId.session_id]), + row: literalId, + }); + expect(resolve(null, 'elsewhere')).toEqual({ kind: 'outside_page', selection: { provider: null, sessionId: 'elsewhere' }, }); - expect(resolve('["claude","elsewhere"]')).toEqual({ + expect(resolve('["claude","elsewhere"]', null)).toEqual({ kind: 'outside_page', selection: { provider: 'claude', sessionId: 'elsewhere' }, }); diff --git a/dashboard/src/workspaces/loom/loomUrl.ts b/dashboard/src/workspaces/loom/loomUrl.ts index e6af09d2d82..3f4e1f99eb3 100644 --- a/dashboard/src/workspaces/loom/loomUrl.ts +++ b/dashboard/src/workspaces/loom/loomUrl.ts @@ -19,6 +19,7 @@ import { export const LOOM_PARAMS = { session: 'loomSession', + bareSession: 'loomBareSession', event: 'loomEvent', window: 'loomWindow', collapsed: 'loomCollapsed', @@ -41,10 +42,20 @@ type LoomSessionParam = | { readonly kind: 'lane'; readonly laneId: string; readonly selection: SessionSelection } | { readonly kind: 'bare_id'; readonly selection: SessionSelection }; -export function parseLoomSession(raw: string | null): LoomSessionParam | null { +export function parseLoomSession( + raw: string | null, + bareId: string | null, +): LoomSessionParam | null { + // A provider-unknown session ID may be serialized JSON shaped like a lane. + // A dedicated URL field preserves that identity without guessing a provider. + if (bareId !== null && bareId !== '') { + return { kind: 'bare_id', selection: { provider: null, sessionId: bareId } }; + } if (raw == null || raw === '') return null; const tuple = parseTuple(raw); - if (tuple === null) return { kind: 'bare_id', selection: { provider: null, sessionId: raw } }; + // loomSession names only a qualified lane. Bare identities must use their + // own field; malformed or obsolete lane values never become bare IDs. + if (tuple === null) return null; const [provider, sessionId] = tuple; return { kind: 'lane', diff --git a/dashboard/src/workspaces/sessions/SessionInspector.dom.test.tsx b/dashboard/src/workspaces/sessions/SessionInspector.dom.test.tsx index 6a4fbdde269..9a1250ecff1 100644 --- a/dashboard/src/workspaces/sessions/SessionInspector.dom.test.tsx +++ b/dashboard/src/workspaces/sessions/SessionInspector.dom.test.tsx @@ -317,7 +317,13 @@ describe('Session provenance inspector', () => { const identity = screen.getByRole('region', { name: 'Identity' }); expect(identity.querySelector('[data-identity="ambiguous"]')).not.toBeNull(); expect(identity.querySelector('[data-evidence-grade="AMBIGUOUS"]')).not.toBeNull(); - expect(screen.queryByRole('link', { name: /Open in Loom/ })).toBeNull(); + // The link preserves the unknown provider, so Loom reports ambiguity + // instead of silently selecting one of the competing provider lanes. + const ambiguousLoom = screen.getByRole('link', { name: /Open in Loom/ }); + const ambiguousTarget = new URL(ambiguousLoom.getAttribute('href') ?? '', 'http://dashboard.test'); + expect(ambiguousTarget.pathname).toBe('/loom'); + expect(ambiguousTarget.searchParams.get('loomBareSession')).toBe('claude:035c8f3c'); + expect(ambiguousTarget.searchParams.has('loomSession')).toBe(false); expect(screen.getByRole('link', { name: /Open in Agents/ }).getAttribute('href')).toBe( '/agents?sessionId=claude%3A035c8f3c', ); @@ -325,21 +331,40 @@ describe('Session provenance inspector', () => { expect(onSelectProvider).toHaveBeenCalledWith('codex'); }); - it('links a session whose provider was never recorded to the lane Loom draws for it', () => { + it('links a provider-less session absent from the loaded page through Loom bare-ID', () => { stubTranscript(); renderWith( {}} onClose={() => {}} />, ); + expect(screen.getByRole('region', { name: 'Identity' }).querySelector('[data-identity="absent"]')).not.toBeNull(); const pivot = screen.getByRole('link', { name: /Open in Loom/ }); - expect(new URL(pivot.getAttribute('href') ?? '', 'http://dashboard.test').searchParams.get('loomSession')).toBe( - '["unknown","quiet"]', + const target = new URL(pivot.getAttribute('href') ?? '', 'http://dashboard.test'); + expect(target.pathname).toBe('/loom'); + expect(target.searchParams.get('loomBareSession')).toBe('quiet'); + expect(target.searchParams.has('loomSession')).toBe(false); + }); + + it('keeps a recorded unknown-provider row on its explicit unknown Loom lane', () => { + stubTranscript(); + renderWith( + {}} + onClose={() => {}} + />, ); + const pivot = screen.getByRole('link', { name: /Open in Loom/ }); + const target = new URL(pivot.getAttribute('href') ?? '', 'http://dashboard.test'); + expect(target.searchParams.get('loomSession')).toBe('["unknown","quiet"]'); + expect(target.searchParams.has('loomBareSession')).toBe(false); }); it('reports a blocked index read as the reason identity and relations are missing', () => { diff --git a/dashboard/src/workspaces/sessions/SessionPivots.dom.test.tsx b/dashboard/src/workspaces/sessions/SessionPivots.dom.test.tsx new file mode 100644 index 00000000000..f34f09c3a04 --- /dev/null +++ b/dashboard/src/workspaces/sessions/SessionPivots.dom.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, useLocation } from 'react-router'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useScope } from '../../data/scope/store.ts'; +import { SessionPivots, type SessionWorkspace } from './SessionPivots.tsx'; +import type { SessionSelection } from './model.ts'; + +function CurrentLocation() { + const location = useLocation(); + return {location.pathname}{location.search}; +} + +function renderPivots(session: SessionSelection, from: SessionWorkspace = 'sessions') { + return render( + + + + , + ); +} + +beforeEach(() => { + useScope.getState().selectAllProjects(); +}); + +describe('session cross-workspace navigation', () => { + it('opens Loom by a bare session ID when the recorded provider is unknown', async () => { + const user = userEvent.setup(); + renderPivots({ provider: null, sessionId: 'session.alpha.1' }); + + const loom = screen.getByRole('link', { name: 'Open in Loom' }); + expect(loom.getAttribute('href')).toBe('/loom?loomBareSession=session.alpha.1'); + await user.click(loom); + expect(screen.getByTestId('session-pivot-location').textContent).toBe( + '/loom?loomBareSession=session.alpha.1', + ); + }); + + it('retains a tuple-shaped bare ID after real navigation', async () => { + const user = userEvent.setup(); + const id = '["claude","other"]'; + renderPivots({ provider: null, sessionId: id }); + const link = screen.getByRole('link', { name: 'Open in Loom' }); + const target = new URL(link.getAttribute('href')!, 'http://dashboard.test'); + expect(target.searchParams.get('loomBareSession')).toBe(id); + expect(target.searchParams.has('loomSession')).toBe(false); + await user.click(link); + const location = screen.getByTestId('session-pivot-location').textContent!; + expect(new URL(location, 'http://dashboard.test').searchParams.get('loomBareSession')).toBe(id); + }); + + it('keeps an admitted project scope and a provider-qualified Loom identity', () => { + useScope.getState().selectProject('project-1', 'Alpha', 'active'); + renderPivots({ provider: 'codex', sessionId: 'shared' }); + + const link = screen.getByRole('link', { name: 'Open in Loom' }); + const href = link.getAttribute('href'); + expect(href).not.toBeNull(); + const destination = new URL(href!, 'http://dashboard.test'); + expect(destination.pathname).toBe('/loom'); + expect(destination.searchParams.get('scope')).toBe('project-1'); + expect(destination.searchParams.get('scopeLabel')).toBe('Alpha'); + expect(destination.searchParams.get('loomSession')).toBe('["codex","shared"]'); + }); + + it('does not offer a self-link or guess a provider for the other destinations', () => { + renderPivots({ provider: null, sessionId: 'shared' }, 'loom'); + + expect(screen.queryByRole('link', { name: 'Open in Loom' })).toBeNull(); + const sessions = screen.getByRole('link', { name: 'Open in Sessions' }); + const destination = new URL(sessions.getAttribute('href')!, 'http://dashboard.test'); + expect(destination.searchParams.get('sessionId')).toBe('shared'); + expect(destination.searchParams.has('sessionProvider')).toBe(false); + }); +}); diff --git a/dashboard/src/workspaces/sessions/SessionPivots.test.ts b/dashboard/src/workspaces/sessions/SessionPivots.test.ts index 1c1a3bcbf0e..13452422aa5 100644 --- a/dashboard/src/workspaces/sessions/SessionPivots.test.ts +++ b/dashboard/src/workspaces/sessions/SessionPivots.test.ts @@ -45,10 +45,14 @@ describe('sessionHref', () => { expect(read(sessionHref('sessions', session, ALL)).params).toEqual({ sessionId: 'abc' }); }); - it('links a provider-less id to Loom by the bare id Delivery has always sent', () => { + it('links a provider-less id to Loom without reinterpreting tuple-shaped IDs', () => { expect(sessionHref('loom', { provider: null, sessionId: 'session.alpha.1' }, ALL)).toBe( - '/loom?loomSession=session.alpha.1', + '/loom?loomBareSession=session.alpha.1', ); + const tupleShaped = read( + sessionHref('loom', { provider: null, sessionId: '["claude","other"]' }, ALL), + ); + expect(tupleShaped.params).toEqual({ loomBareSession: '["claude","other"]' }); expect(read(sessionHref('agents', { provider: null, sessionId: 'abc' }, ALL)).params).toEqual({ sessionId: 'abc', }); diff --git a/dashboard/src/workspaces/sessions/SessionPivots.tsx b/dashboard/src/workspaces/sessions/SessionPivots.tsx index 111d99cbc3c..c8f75cac688 100644 --- a/dashboard/src/workspaces/sessions/SessionPivots.tsx +++ b/dashboard/src/workspaces/sessions/SessionPivots.tsx @@ -22,12 +22,14 @@ export function sessionHref( const [pathname, search = ''] = scopedWorkspacePath(scope, target).split('?'); const params = new URLSearchParams(search); if (target !== 'loom') return `${pathname}?${writeSessionSelection(params, session).toString()}`; - params.set( - LOOM_PARAMS.session, - session.provider === null - ? session.sessionId - : sessionLaneId(session.provider, session.sessionId), - ); + if (session.provider === null) { + // Bare IDs can look like lane tuples; never reinterpret one as a provider. + params.set(LOOM_PARAMS.bareSession, session.sessionId); + params.delete(LOOM_PARAMS.session); + } else { + params.set(LOOM_PARAMS.session, sessionLaneId(session.provider, session.sessionId)); + params.delete(LOOM_PARAMS.bareSession); + } return `${pathname}?${params.toString()}`; } @@ -39,8 +41,10 @@ export function SessionPivots({ from: SessionWorkspace; }) { const scope = useScope((state) => state.scope); + // Loom resolves provider-less IDs without inventing a lane; ambiguous + // matches are reported rather than silently selecting another provider. const targets = (Object.keys(PIVOTS) as SessionWorkspace[]).filter( - (target) => target !== from && (target !== 'loom' || session.provider !== null), + (target) => target !== from, ); return (