Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
independently scrolling login region.

### Added
- **Same-named Workbench tabs now show their source only when disambiguation is
needed** (#464). Colliding Dashboard tabs receive a compact Dashboard icon and
the shortest readable title abbreviation; Library and draft collisions receive
explicit labels, while unique tabs keep the existing compact layout. Every tab
exposes its full source through a semantic tab control and tooltip, including
Dashboard-variable tabs. Roving Arrow/Home/End navigation preserves focus
through activation and close-driven strip replacement, and accessible names
announce unsaved, externally changed, and externally deleted state. Source
presentation is derived from the canonical saved-query/Dashboard ownership
graph rather than stored as a second identity, and repaints immediately after
Dashboard renames. Dirty, conflict, deleted, and close controls retain their
established order and behavior.
- **A Dashboard panel tile head now carries duplicate, widen and expand
actions** (#535). All three are revealed on tile hover or focus.
*Duplicate* (edit mode) places a copy of the panel immediately after the
Expand Down
29 changes: 29 additions & 0 deletions docs/ADR-0003-dashboard-viewing.md
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,35 @@ option batch supports at most 1000 options.
before the success bookkeeping in `runVariableSql`, so an over-cap response
creates neither History nor a detached-result source.

## Addendum (#464, 2026-07-29): tab origin is derived, but its presentation is explicit

The #471 addendum above settled query-tab identity: a Dashboard tile's dedicated
query id is already the stable document identity, and `savedId` already drives
tab reuse and Save targeting. This change adds the missing visible and accessible
source context without introducing a reverse origin field.

- **Ownership remains the one source of truth.** Query-tab source is projected
from `savedId` through `buildQueryOwnershipIndex`; Dashboard-variable tabs use
their existing `(dashboardId, variableName)` document binding. No
`QueryTabOrigin`, saved-query back-pointer, or recovery payload field mirrors
those references. Workspace commits repaint the strip because a Dashboard
rename can change presentation without changing any tab signal.
- **Badges exist only inside a visible name collision.** Every tab in an exact
same-name group receives a source label, while unique names reserve no badge
space. Multi-word Dashboard titles start with their initialism and expand
words in place; single-word titles start with a three-character prefix.
Distinct titles expand through their readable full text before the exceptional
case of identical titles adds the shortest unique Dashboard-id prefix.
- **Full context does not depend on a visible badge.** The selectable surface is
a native, keyboard-focusable `tab` inside the tab-list, named and titled
`<Dashboard, Library, or Draft> / <document>`. A roving tab stop supports
Left/Right/Home/End, and an app-scoped focus handoff survives the strip's
replacement render after activation or close (including a confirmed dirty
close). The accessible name appends unsaved/conflict/deleted state rather than
hiding those visual indicators behind the explicit source label. The close
button remains a separate sibling control, and source badges precede the
existing conflict/deleted and dirty indicators.

## Alternatives considered

- **Durable detached snapshots:** rejected because they silently diverge from
Expand Down
194 changes: 194 additions & 0 deletions src/dashboard/model/tab-origin-badges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Collision-only editor-tab origin labels (#464). This is a pure projection of
// the established #427 ownership reference: `savedId` identifies the open
// document and Dashboard tile references identify its source. It deliberately
// stores no reverse origin on a tab or saved query.

import { buildQueryOwnershipIndex } from './query-ownership.js';
import type { StoredWorkspaceV5 } from '../../generated/json-schema.types.js';

export interface OriginBadgeTab {
id: string;
name: string;
savedId: string | null;
/** Dashboard-variable tabs have no saved query id, but their Dashboard
* binding is already the canonical document identity (#457). */
doc?: { kind: 'query' } | { kind: 'dashboard-variable'; dashboardId: string; variableName: string };
}

export type TabOriginKind = 'dashboard' | 'library' | 'draft';

export interface TabOriginBadge {
tabId: string;
kind: TabOriginKind;
/** Full, always-available source text (not the compact collision badge). */
context: string;
/** Present only while another visible tab has the same displayed name. */
badge: string | null;
}

interface ResolvedOrigin {
kind: TabOriginKind;
context: string;
dashboardId?: string;
dashboardTitle?: string;
}

const untitledDashboard = 'Untitled Dashboard';

/**
* User-visible abbreviation operates on grapheme clusters, never UTF-16 code
* units. ES2022 includes `Intl.Segmenter`, which keeps emoji, combining marks,
* and zero-width-joiner sequences intact.
*/
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
const graphemes = (value: string): string[] => Array.from(
graphemeSegmenter.segment(value),
({ segment }) => segment,
);

const firstGrapheme = (value: string): string => graphemes(value)[0] ?? '';
const graphemePrefix = (value: string, length: number): string => graphemes(value).slice(0, length).join('');

/** Every readable abbreviation, shortest first. Multi-word names start as an
* initialism, then expand one word in place; a one-word name starts with a
* three-character prefix. */
function abbreviationCandidates(title: string): string[] {
const words = title.trim().split(/\s+/).filter(Boolean);
if (words.length === 1) {
const length = graphemes(words[0]).length;
return Array.from(
{ length: Math.max(1, length - Math.min(3, length) + 1) },
(_, index) => graphemePrefix(words[0], Math.min(3, length) + index),
);
}
const candidates = [words.map(firstGrapheme).join('')];
for (let wordIndex = 0; wordIndex < words.length; wordIndex += 1) {
const wordLength = graphemes(words[wordIndex]).length;
for (let length = 2; length <= wordLength; length += 1) {
candidates.push(words.map((word, index) => (
index < wordIndex ? word : index === wordIndex ? graphemePrefix(word, length) : firstGrapheme(word)
)).join(''));
}
}
// Initialisms can collide with a single-word title (or with another
// differently-spaced title). The readable whole title is still a source
// label, and must be tried before falling back to opaque identity text.
const readableTitle = words.join(' ');
if (!candidates.includes(readableTitle)) candidates.push(readableTitle);
return candidates;
}

/**
* Compact, deterministic Dashboard labels. Only labels within the same visible
* name collision group compete. A duplicate Dashboard title cannot become
* unique by extension alone, so the shortest unique prefix of its stable
* Dashboard id is appended as the final, exceptional tie-breaker.
*/
function dashboardBadges(origins: readonly ResolvedOrigin[]): string[] {
const candidates = origins.map((origin) => abbreviationCandidates(origin.dashboardTitle!));
const candidateIndexes = origins.map(() => 0);
const labels = (): string[] => candidates.map((options, index) => options[candidateIndexes[index]]);
let current = labels();
let changed = true;
while (changed) {
const counts = new Map<string, number>();
current.forEach((label) => counts.set(label, (counts.get(label) ?? 0) + 1));
const nextIndexes = current.map((label, index) => {
if ((counts.get(label) ?? 0) <= 1) return candidateIndexes[index];
return Math.min(candidateIndexes[index] + 1, candidates[index].length - 1);
});
changed = nextIndexes.some((value, index) => value !== candidateIndexes[index]);
candidateIndexes.splice(0, candidateIndexes.length, ...nextIndexes);
current = labels();
}
const counts = new Map<string, number>();
current.forEach((label) => counts.set(label, (counts.get(label) ?? 0) + 1));
return current.map((label, index) => {
if ((counts.get(label) ?? 0) <= 1) return label;
const peerIndexes = current
.map((candidate, peerIndex) => ({ candidate, peerIndex }))
.filter(({ candidate }) => candidate === label)
.map(({ peerIndex }) => peerIndex);
// A full readable title is in every candidate sequence, so different
// normalized titles should never reach this point. Keep that contract
// explicit: an id is only an exceptional tie-breaker for truly identical
// Dashboard titles, never a substitute for title disambiguation.
const ids = peerIndexes.map((peerIndex) => origins[peerIndex].dashboardId!);
let prefixLength = 1;
while (prefixLength < Math.max(...ids.map((id) => id.length))
&& new Set(ids.map((id) => id.slice(0, prefixLength))).size < ids.length) {
prefixLength += 1;
}
return `${label} · ${origins[index].dashboardId!.slice(0, prefixLength)}`;
});
}

function resolveOrigins(
tabs: readonly OriginBadgeTab[], workspace: StoredWorkspaceV5 | null,
): ResolvedOrigin[] {
if (workspace === null) {
return tabs.map((tab) => (tab.savedId === null
? { kind: 'draft' as const, context: 'Draft' }
: { kind: 'library' as const, context: 'Library' }));
}
const ownership = buildQueryOwnershipIndex(workspace);
return tabs.map((tab) => {
const variable = tab.doc?.kind === 'dashboard-variable' ? tab.doc : null;
if (variable !== null) {
const dashboards = workspace.dashboards.filter((dashboard) => dashboard.id === variable.dashboardId);
if (dashboards.length !== 1) return { kind: 'draft' as const, context: 'Draft' };
const title = dashboards[0].title.trim() || untitledDashboard;
return {
kind: 'dashboard' as const,
context: title,
dashboardId: variable.dashboardId,
dashboardTitle: title,
};
}
if (tab.savedId === null) return { kind: 'draft' as const, context: 'Draft' };
const owners = ownership.ownersByQueryId.get(tab.savedId) ?? [];
if (owners.length !== 1) return { kind: 'library' as const, context: 'Library' };
const dashboards = workspace.dashboards.filter((dashboard) => dashboard.id === owners[0].dashboardId);
if (dashboards.length !== 1) return { kind: 'library' as const, context: 'Library' };
const title = dashboards[0].title.trim() || untitledDashboard;
return {
kind: 'dashboard' as const,
context: title,
dashboardId: owners[0].dashboardId,
dashboardTitle: title,
};
});
}

/**
* Derive every tab's full source context and collision-only compact badge.
* Names decide only whether a visible label is needed; document classification
* always comes from `savedId` and the canonical Dashboard ownership index.
*/
export function planTabOriginBadges(
tabs: readonly OriginBadgeTab[], workspace: StoredWorkspaceV5 | null,
): TabOriginBadge[] {
const origins = resolveOrigins(tabs, workspace);
const badges = tabs.map(() => null as string | null);
const byName = new Map<string, number[]>();
tabs.forEach((tab, index) => {
const indexes = byName.get(tab.name);
if (indexes) indexes.push(index);
else byName.set(tab.name, [index]);
});
for (const indexes of byName.values()) {
if (indexes.length < 2) continue;
const dashboardIndexes = indexes.filter((index) => origins[index].kind === 'dashboard');
const dashboardLabels = dashboardBadges(dashboardIndexes.map((index) => origins[index]));
const dashboardLabelByIndex = new Map(dashboardIndexes.map((index, labelIndex) => [index, dashboardLabels[labelIndex]]));
for (const index of indexes) {
const origin = origins[index];
badges[index] = origin.kind === 'dashboard'
? dashboardLabelByIndex.get(index)!
: origin.kind === 'library' ? 'Library' : 'Draft';
}
}
return tabs.map((tab, index) => ({
tabId: tab.id, kind: origins[index].kind, context: origins[index].context, badge: badges[index],
}));
}
25 changes: 21 additions & 4 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1917,16 +1917,23 @@ body.detached-tab .graph-overlay-panel {
}
.qtabs-inner { display: flex; flex: 1; overflow: auto; height: 100%; }
.qtab {
display: flex; align-items: center; gap: 8px;
padding: 0 8px 0 12px;
display: flex; align-items: center;
height: 100%;
border-right: 1px solid var(--border);
cursor: pointer;
font-size: var(--text-label);
color: var(--fg-mute);
position: relative; white-space: nowrap;
min-width: 100px;
}
.qtab-select {
min-width: 0; flex: 1;
display: flex; align-items: center; gap: 8px;
align-self: stretch;
padding: 0 8px 0 12px;
border: 0; background: transparent; color: inherit;
font: inherit; text-align: left; cursor: pointer;
}
.qtab-select:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.qtab.active {
background: var(--bg-editor);
color: var(--fg); font-weight: var(--fw-medium);
Expand All @@ -1935,7 +1942,16 @@ body.detached-tab .graph-overlay-panel {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: var(--accent);
}
.qtab .name { flex: 1; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
.qtab .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
.qtab-origin {
flex-shrink: 0;
display: inline-flex; align-items: center; gap: 3px;
padding: 1px 4px;
border: 1px solid var(--border); border-radius: var(--r-xs);
background: var(--bg-chip); color: var(--fg-mute);
font-size: var(--text-micro); font-weight: var(--fw-medium); line-height: var(--lh-flush);
}
.qtab-origin svg { flex-shrink: 0; }
.qtab .dirty { width: 5px; height: 5px; border-radius: var(--r-xs); background: var(--fg-mute); flex-shrink: 0; }
/* #343: the marker saying this tab's linked query changed ('!') or was deleted
('⌫') in another browser tab. Like the conflict chooser it reaches, this
Expand All @@ -1962,6 +1978,7 @@ body.detached-tab .graph-overlay-panel {
}
.qtab .close {
width: 16px; height: 16px; border-radius: var(--r-xs); padding: 0;
margin-right: 8px;
border: none; background: transparent;
color: var(--fg-faint); cursor: pointer;
display: flex; align-items: center; justify-content: center;
Expand Down
5 changes: 5 additions & 0 deletions src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2288,6 +2288,11 @@ export function createApp(env: CreateAppEnv = {}): App {
// and stays put rather than navigating nowhere.)
cancelDashboardTreeClicks(app);
invalidateDashboardTree();
// #464: Dashboard titles and ownership are presentation inputs for the
// Query tab strip. A workspace commit can change either without changing a
// tab signal (for example, renaming a Dashboard), so repaint explicitly
// after the complete projection rather than waiting for a later tab edit.
renderTabs(app);
// #425: COMPLETE the fallback, don't just record it. Rewriting the route and
// leaving the Dashboard host exposed wedges the app: every path back —
// `showQuerySurface`, the header switch, `g w`, a Library click — early-returns
Expand Down
Loading