Files
openchamber/packages/ui/src/lib/surfaces/registry.ts
T

228 lines
6.9 KiB
TypeScript

import type { IconName } from '@/components/icon/icons';
import type { I18nKey } from '@/lib/i18n';
import type { ContextPanelMode } from '@/stores/useUIStore';
export type ContextSurfaceId =
| 'editor'
| 'git'
| 'pr'
| 'diff'
| 'walkthrough'
| 'terminal'
| 'plan'
| 'notes'
| 'context'
| 'browser'
| 'chat';
export type ContextSurfaceDescriptor = {
id: ContextSurfaceId;
/** The context panel tab mode this surface activates. 1:1 in the current model. */
mode: ContextPanelMode;
icon: IconName;
labelKey: I18nKey;
/**
* 'always' surfaces can be opened empty from the rail.
* 'has-content' surfaces are content-driven: they need an existing tab of
* their mode (a split session, a diff to show) and stay hidden on the rail
* until one exists.
*/
availability: 'always' | 'has-content';
/** Short tooltip explanation shown on the rail. */
descriptionKey: I18nKey;
/**
* Default panel width as a fraction of the available content area, used
* until the user manually resizes this surface.
*/
defaultWidthFraction: number;
};
export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
{
id: 'context',
descriptionKey: 'contextRail.surface.context.description',
defaultWidthFraction: 0.45,
mode: 'context',
icon: 'donut-chart-fill',
labelKey: 'contextPanel.mode.context',
availability: 'always',
},
{
id: 'git',
descriptionKey: 'contextRail.surface.git.description',
defaultWidthFraction: 2 / 5,
mode: 'git',
icon: 'git-branch',
labelKey: 'layout.rightSidebar.git',
availability: 'always',
},
{
id: 'pr',
descriptionKey: 'contextRail.surface.pr.description',
defaultWidthFraction: 0.45,
mode: 'pr',
icon: 'github',
labelKey: 'contextPanel.mode.pr',
availability: 'always',
},
{
id: 'diff',
descriptionKey: 'contextRail.surface.diff.description',
defaultWidthFraction: 3 / 5,
mode: 'diff',
icon: 'arrow-left-right',
labelKey: 'contextPanel.mode.diff',
availability: 'always',
},
{
id: 'walkthrough',
descriptionKey: 'contextRail.surface.walkthrough.description',
defaultWidthFraction: 3 / 5,
mode: 'walkthrough',
icon: 'route',
labelKey: 'contextPanel.mode.walkthrough',
availability: 'always',
},
{
id: 'editor',
descriptionKey: 'contextRail.surface.editor.description',
defaultWidthFraction: 3 / 5,
mode: 'file',
icon: 'file-edit',
labelKey: 'contextPanel.mode.files',
availability: 'always',
},
{
id: 'terminal',
descriptionKey: 'contextRail.surface.terminal.description',
defaultWidthFraction: 3 / 5,
mode: 'terminal',
icon: 'terminal-box',
labelKey: 'layout.mainTab.terminal',
availability: 'always',
},
{
id: 'notes',
descriptionKey: 'contextRail.surface.notes.description',
// As wide as the files surface: this panel now carries a sidebar and a
// content column, and a third of the window leaves the content column too
// narrow to read a note in.
defaultWidthFraction: 3 / 5,
mode: 'notes',
icon: 'book-marked',
labelKey: 'contextRail.surface.notes',
availability: 'always',
},
{
id: 'plan',
descriptionKey: 'contextRail.surface.plan.description',
defaultWidthFraction: 0.45,
mode: 'plan',
icon: 'file-text',
labelKey: 'contextPanel.mode.plan',
availability: 'always',
},
{
id: 'browser',
descriptionKey: 'contextRail.surface.browser.description',
defaultWidthFraction: 0.45,
mode: 'browser',
icon: 'global',
labelKey: 'contextPanel.mode.browser',
availability: 'always',
},
{
id: 'chat',
descriptionKey: 'contextRail.surface.chat.description',
defaultWidthFraction: 0.45,
mode: 'chat',
icon: 'chat-4',
labelKey: 'contextPanel.mode.chat',
availability: 'has-content',
},
];
const SURFACE_BY_ID = new Map(CONTEXT_SURFACES.map((surface) => [surface.id, surface]));
const FRACTION_BY_MODE = new Map(CONTEXT_SURFACES.map((surface) => [surface.mode, surface.defaultWidthFraction]));
// Tablet width and up: below this the walkthrough cannot show a stop and its
// code side by side, which is the whole point of the surface.
export const WALKTHROUGH_MIN_WIDTH = 768;
export const getContextSurfaceWidthFraction = (mode: ContextPanelMode): number => {
return FRACTION_BY_MODE.get(mode) ?? 1 / 2;
};
const isContextSurfaceId = (value: unknown): value is ContextSurfaceId => {
return typeof value === 'string' && SURFACE_BY_ID.has(value as ContextSurfaceId);
};
/**
* Applies a persisted user reorder on top of the default registry order:
* unknown ids are dropped, missing surfaces are appended in default order.
*/
export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfaceDescriptor[] => {
const ordered: ContextSurfaceDescriptor[] = [];
const seen = new Set<ContextSurfaceId>();
for (const id of railOrder) {
if (!isContextSurfaceId(id) || seen.has(id)) {
continue;
}
const surface = SURFACE_BY_ID.get(id);
if (surface) {
seen.add(id);
ordered.push(surface);
}
}
for (const surface of CONTEXT_SURFACES) {
if (!seen.has(surface.id)) {
ordered.push(surface);
}
}
return ordered;
};
type VisibleRailSurfacesOptions = {
railOrder: readonly string[];
planModeEnabled: boolean;
isVSCode: boolean;
screenWidth: number;
tabs: readonly { mode: ContextPanelMode }[];
};
/**
* The context panel rail's visible, user-ordered surfaces. Shared by the rail
* (for rendering and number badges) and the global surface-switch shortcut so
* both agree on which surface each digit maps to.
*
* Content-driven surfaces are hidden (not disabled) until content exists; an
* existing tab keeps them visible even if the content source went away.
*/
export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => {
return sortContextSurfaces(options.railOrder).filter((surface) => {
if (surface.id === 'plan' && !options.planModeEnabled) {
return false;
}
// The walkthrough needs room for a stop list beside real code, and its
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
if (surface.id === 'walkthrough' && (options.isVSCode || options.screenWidth < WALKTHROUGH_MIN_WIDTH)) {
return false;
}
// VS Code already is an editor with a browser next to it. What OpenChamber
// could add there is a bare frame: no annotation, no agent control, no
// remote dev servers — all of which need a Chromium host the extension does
// not have. Offering the surface anyway would promise the panel people see
// on the desktop.
if (surface.id === 'browser' && options.isVSCode) {
return false;
}
if (surface.availability === 'has-content') {
return options.tabs.some((tab) => tab.mode === surface.mode);
}
return true;
});
};