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 ;
+}
+
+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 ;
+}
+
+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 (