Files
2026-08-31 21:42:12 +00:00

519 lines
17 KiB
TypeScript

import {
getPresetLayout,
normalizeReusableSessionPanels,
PANEL_REGISTRY,
REUSABLE_PANEL_IDS,
type BuiltInPreset,
type LayoutGroup,
type LayoutNode,
type LayoutPanel,
type LayoutState,
} from "@/lib/state/layout-manager";
import type { SavedLayout } from "@/lib/types/http";
import { generateUUID } from "@/lib/utils";
export type BuiltInLayoutProfileId = Exclude<BuiltInPreset, "compact">;
/** Identity of the profile that produced the currently rendered layout. */
export type LayoutProfileIdentity =
| { kind: "built-in"; id: BuiltInPreset }
| { kind: "custom"; id: string };
export type BuiltInLayoutProfileDescriptor = {
id: BuiltInLayoutProfileId;
/** Canonical English; persisted into a saved override. Not for display. */
name: string;
/** Catalog key the settings list renders. Absent for product names. */
nameKey?: string;
description: string;
descriptionKey?: string;
};
/**
* Display name for a built-in profile, in the active locale.
*
* `descriptor.name` is the canonical English value `upsertBuiltInLayoutOverride`
* persists; every surface that shows the name to a user goes through here so the
* two cannot drift.
*/
export function builtInLayoutProfileName(
descriptor: Pick<BuiltInLayoutProfileDescriptor, "name" | "nameKey">,
translate: (key: string) => string,
): string {
return descriptor.nameKey ? translate(descriptor.nameKey) : descriptor.name;
}
export type BuiltInLayoutProfile = BuiltInLayoutProfileDescriptor & {
layout: LayoutState;
};
/**
* `name` is BOTH display copy and persisted data: `upsertBuiltInLayoutOverride`
* copies it into the saved record the first time a built-in is customized. It
* therefore stays canonical English, and `nameKey`/`descriptionKey` are what the
* settings list renders — the same split the dockview panel registry uses. A
* translated `name` here would write the current locale into a user's saved
* layouts and leave it there after a switch.
*
* "VS Code" is a product name and so has no `nameKey`.
*/
// i18n-exempt: canonical English persisted in saved layouts; `descriptionKey` beside it is what renders.
export const BUILT_IN_LAYOUT_PROFILES: readonly BuiltInLayoutProfileDescriptor[] = [
{
id: "default",
name: "Default",
nameKey: "settings:layoutProfileDefault",
description: "Agent with Files, Changes, and Terminal",
descriptionKey: "settings:layoutProfileDefaultDescription",
},
{
id: "plan",
name: "Plan Mode",
nameKey: "settings:layoutProfilePlanMode",
description: "Agent and Plan side by side",
descriptionKey: "settings:layoutProfilePlanModeDescription",
},
{
id: "preview",
name: "Preview Mode",
nameKey: "settings:layoutProfilePreviewMode",
description: "Agent and Browser side by side",
descriptionKey: "settings:layoutProfilePreviewModeDescription",
},
{
id: "vscode",
name: "VS Code",
description: "Agent and VS Code side by side",
descriptionKey: "settings:layoutProfileVsCodeDescription",
},
];
export type ReusableLayoutIssueCode =
| "invalid-layout"
| "empty-group"
| "missing-agent"
| "duplicate-panel"
| "unsupported-panel"
| "invalid-panel"
| "invalid-active-panel";
export type ReusableLayoutIssue = {
code: ReusableLayoutIssueCode;
path: string;
message: string;
};
export type ReusableLayoutValidation =
| { valid: true; layout: LayoutState; issues: [] }
| { valid: false; issues: ReusableLayoutIssue[] };
export type LayoutProfileCompatibility =
| { status: "editable"; profile: SavedLayout; layout: LayoutState; issues: [] }
| { status: "legacy"; profile: SavedLayout; issues: ReusableLayoutIssue[] };
export type EffectiveDefaultLayout =
| { source: "built-in"; profile: BuiltInLayoutProfile; layout: LayoutState }
| { source: "custom"; profile: SavedLayout; layout: LayoutState };
export type CreateLayoutProfileInput = {
id: string;
name: string;
layout: Record<string, unknown>;
createdAt: string;
isDefault?: boolean;
};
export type DuplicateLayoutProfileInput = {
id: string;
name: string;
createdAt: string;
};
export type UpsertBuiltInLayoutOverrideOptions = {
createdAt: string;
isDefault?: boolean;
};
const REUSABLE_PANEL_ID_SET = new Set<string>(REUSABLE_PANEL_IDS);
const BUILT_IN_OVERRIDE_PREFIX = "layout-override-";
export function createLayoutProfileId(): string {
return `layout-${generateUUID()}`;
}
export function getBuiltInLayoutProfile(id: BuiltInLayoutProfileId): BuiltInLayoutProfile {
const descriptor = BUILT_IN_LAYOUT_PROFILES.find((profile) => profile.id === id);
if (!descriptor) throw new Error(`Unknown built-in layout profile: ${id}`);
return { ...descriptor, layout: getPresetLayout(id) };
}
export function getBuiltInLayoutOverrideId(id: BuiltInLayoutProfileId): string {
return `${BUILT_IN_OVERRIDE_PREFIX}${id}`;
}
export function getBuiltInLayoutOverrideSourceId(
profile: Pick<SavedLayout, "id">,
): BuiltInLayoutProfileId | null {
return (
BUILT_IN_LAYOUT_PROFILES.find(({ id }) => profile.id === getBuiltInLayoutOverrideId(id))?.id ??
null
);
}
/** Resolve the identity represented by a saved layout profile ID. */
export function getLayoutProfileIdentity(profile: Pick<SavedLayout, "id">): LayoutProfileIdentity {
const builtInSource = getBuiltInLayoutOverrideSourceId(profile);
return builtInSource
? { kind: "built-in", id: builtInSource }
: { kind: "custom", id: profile.id };
}
export function isBuiltInLayoutOverride(profile: Pick<SavedLayout, "id">): boolean {
return getBuiltInLayoutOverrideSourceId(profile) !== null;
}
export function getBuiltInLayoutOverride(
profiles: SavedLayout[],
id: BuiltInLayoutProfileId,
): SavedLayout | null {
const overrideId = getBuiltInLayoutOverrideId(id);
return profiles.find((profile) => profile.id === overrideId) ?? null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasOptionalNumber(record: Record<string, unknown>, key: string): boolean {
return record[key] === undefined || typeof record[key] === "number";
}
function isPanelShape(value: unknown): value is LayoutPanel {
if (!isRecord(value)) return false;
return (
typeof value.id === "string" &&
typeof value.component === "string" &&
typeof value.title === "string" &&
(value.tabComponent === undefined || typeof value.tabComponent === "string") &&
(value.params === undefined || isRecord(value.params))
);
}
function isGroupShape(value: unknown): value is LayoutGroup {
if (!isRecord(value) || !Array.isArray(value.panels) || !value.panels.every(isPanelShape)) {
return false;
}
return (
(value.id === undefined || typeof value.id === "string") &&
(value.activePanel === undefined || typeof value.activePanel === "string")
);
}
function isTreeShape(value: unknown, seen: Set<unknown>): value is LayoutNode {
if (!isRecord(value) || seen.has(value)) return false;
seen.add(value);
if (!hasOptionalNumber(value, "size")) return false;
if (value.type === "leaf") return isGroupShape(value.group);
return (
value.type === "branch" &&
Array.isArray(value.children) &&
value.children.length > 0 &&
value.children.every((child) => isTreeShape(child, seen))
);
}
function isLayoutState(value: unknown): value is LayoutState {
if (!isRecord(value) || !Array.isArray(value.columns) || value.columns.length === 0) return false;
return value.columns.every((column) => {
if (!isRecord(column) || typeof column.id !== "string" || !Array.isArray(column.groups)) {
return false;
}
return (
column.groups.length > 0 &&
column.groups.every(isGroupShape) &&
(column.pinned === undefined || typeof column.pinned === "boolean") &&
hasOptionalNumber(column, "width") &&
hasOptionalNumber(column, "maxWidth") &&
hasOptionalNumber(column, "minWidth") &&
(column.tree === undefined || isTreeShape(column.tree, new Set()))
);
});
}
function issue(code: ReusableLayoutIssueCode, path: string, message: string): ReusableLayoutIssue {
return { code, path, message };
}
function groupsInNode(node: LayoutNode, path: string): Array<{ group: LayoutGroup; path: string }> {
if (node.type === "leaf") return [{ group: node.group, path: `${path}.group` }];
return node.children.flatMap((child, index) => groupsInNode(child, `${path}.children[${index}]`));
}
function groupsIn(layout: LayoutState): Array<{ group: LayoutGroup; path: string }> {
return layout.columns.flatMap((column, columnIndex) => {
if (column.tree) return groupsInNode(column.tree, `columns[${columnIndex}].tree`);
return column.groups.map((group, groupIndex) => ({
group,
path: `columns[${columnIndex}].groups[${groupIndex}]`,
}));
});
}
function validateGroup(
group: LayoutGroup,
path: string,
counts: Map<string, number>,
): ReusableLayoutIssue[] {
if (group.panels.length === 0) {
return [issue("empty-group", path, "Layout groups must contain at least one panel")];
}
const issues: ReusableLayoutIssue[] = [];
if (group.activePanel && !group.panels.some((current) => current.id === group.activePanel)) {
issues.push(
issue("invalid-active-panel", `${path}.activePanel`, "Active panel must belong to its group"),
);
}
group.panels.forEach((current, panelIndex) => {
const panelPath = `${path}.panels[${panelIndex}]`;
if (!REUSABLE_PANEL_ID_SET.has(current.id)) {
issues.push(issue("unsupported-panel", panelPath, `Panel ${current.id} is not reusable`));
return;
}
const expected = PANEL_REGISTRY[current.id];
if (!expected || current.component !== expected.component) {
issues.push(
issue("invalid-panel", panelPath, `Panel ${current.id} has an invalid component`),
);
return;
}
counts.set(current.id, (counts.get(current.id) ?? 0) + 1);
});
return issues;
}
function canonicalAgentCount(layout: LayoutState): number {
return groupsIn(layout).reduce(
(count, { group }) => count + group.panels.filter((current) => current.id === "chat").length,
0,
);
}
function invalidActivePanelIssues(layout: LayoutState): ReusableLayoutIssue[] {
return groupsIn(layout).flatMap(({ group, path }) =>
group.activePanel && !group.panels.some((current) => current.id === group.activePanel)
? [
issue(
"invalid-active-panel",
`${path}.activePanel`,
"Active panel must belong to its group",
),
]
: [],
);
}
export function validateReusableLayout(layout: unknown): ReusableLayoutValidation {
if (!isLayoutState(layout)) {
return {
valid: false,
issues: [issue("invalid-layout", "layout", "Layout payload is not readable")],
};
}
if (canonicalAgentCount(layout) > 1) {
return {
valid: false,
issues: [issue("duplicate-panel", "layout", "Panel chat can only appear once")],
};
}
const normalized = normalizeReusableSessionPanels(layout);
const counts = new Map<string, number>();
const issues = invalidActivePanelIssues(layout);
issues.push(
...groupsIn(normalized).flatMap(({ group, path }) => validateGroup(group, path, counts)),
);
for (const [panelId, count] of counts) {
if (count > 1) {
issues.push(issue("duplicate-panel", "layout", `Panel ${panelId} can only appear once`));
}
}
if ((counts.get("chat") ?? 0) !== 1) {
issues.push(issue("missing-agent", "layout", "Layout must contain exactly one Agent panel"));
}
return issues.length === 0
? { valid: true, layout: normalized, issues: [] }
: { valid: false, issues };
}
export function getLayoutProfileCompatibility(profile: SavedLayout): LayoutProfileCompatibility {
const validation = validateReusableLayout(profile.layout);
return validation.valid
? { status: "editable", profile, layout: validation.layout, issues: [] }
: { status: "legacy", profile, issues: validation.issues };
}
export function resolveEffectiveDefaultLayout(profiles: SavedLayout[]): EffectiveDefaultLayout {
const profile = profiles.find((candidate) => candidate.is_default);
if (profile) {
const layout = Array.isArray(profile.layout.columns)
? {
...profile.layout,
columns: profile.layout.columns.filter(
(column) => !isRecord(column) || column.id !== "sidebar",
),
}
: profile.layout;
const validation = validateReusableLayout(layout);
if (validation.valid) {
return { source: "custom", profile, layout: validation.layout };
}
}
const builtIn = getBuiltInLayoutProfile("default");
return { source: "built-in", profile: builtIn, layout: builtIn.layout };
}
function requireId(id: string): string {
const trimmed = id.trim();
if (!trimmed) throw new Error("Layout profile ID must not be empty");
return trimmed;
}
function requireName(name: string): string {
const trimmed = name.trim();
if (!trimmed) throw new Error("Layout profile name must not be empty");
return trimmed;
}
function ensureUniqueId(profiles: SavedLayout[], id: string): void {
if (profiles.some((profile) => profile.id === id)) {
throw new Error("Layout profile ID must be unique");
}
}
function cloneLayout(layout: Record<string, unknown>): Record<string, unknown> {
return structuredClone(layout);
}
export function createLayoutProfile(
profiles: SavedLayout[],
input: CreateLayoutProfileInput,
): SavedLayout[] {
const id = requireId(input.id);
const name = requireName(input.name);
ensureUniqueId(profiles, id);
if (input.isDefault && !validateReusableLayout(input.layout).valid) {
throw new Error("Only a valid reusable layout can be the default");
}
const existing = input.isDefault
? profiles.map((profile) => (profile.is_default ? { ...profile, is_default: false } : profile))
: [...profiles];
return [
...existing,
{
id,
name,
is_default: input.isDefault ?? false,
layout: cloneLayout(input.layout),
created_at: input.createdAt,
},
];
}
export function upsertBuiltInLayoutOverride(
profiles: SavedLayout[],
id: BuiltInLayoutProfileId,
layout: Record<string, unknown>,
options: UpsertBuiltInLayoutOverrideOptions,
): SavedLayout[] {
const validation = validateReusableLayout(layout);
if (!validation.valid) {
throw new Error("Only a valid reusable layout can override a built-in layout");
}
const normalizedLayout = validation.layout as unknown as Record<string, unknown>;
const builtIn = getBuiltInLayoutProfile(id);
const overrideId = getBuiltInLayoutOverrideId(id);
const existing = getBuiltInLayoutOverride(profiles, id);
const isDefault = options.isDefault ?? existing?.is_default ?? false;
if (!existing) {
return createLayoutProfile(profiles, {
id: overrideId,
name: builtIn.name,
layout: normalizedLayout,
createdAt: options.createdAt,
isDefault,
});
}
return profiles.map((profile) => {
if (profile.id === overrideId) {
return {
...profile,
name: builtIn.name,
layout: cloneLayout(normalizedLayout),
is_default: isDefault,
};
}
return isDefault && profile.is_default ? { ...profile, is_default: false } : profile;
});
}
export function resetBuiltInLayoutOverride(
profiles: SavedLayout[],
id: BuiltInLayoutProfileId,
): SavedLayout[] {
const overrideId = getBuiltInLayoutOverrideId(id);
return profiles.filter((profile) => profile.id !== overrideId);
}
function findProfile(profiles: SavedLayout[], profileId: string): SavedLayout {
const profile = profiles.find((candidate) => candidate.id === profileId);
if (!profile) throw new Error(`Layout profile not found: ${profileId}`);
return profile;
}
export function renameLayoutProfile(
profiles: SavedLayout[],
profileId: string,
name: string,
): SavedLayout[] {
findProfile(profiles, profileId);
const trimmed = requireName(name);
return profiles.map((profile) =>
profile.id === profileId ? { ...profile, name: trimmed } : profile,
);
}
export function duplicateLayoutProfile(
profiles: SavedLayout[],
source: string | Pick<SavedLayout, "layout"> | BuiltInLayoutProfile,
input: DuplicateLayoutProfileInput,
): SavedLayout[] {
const original = typeof source === "string" ? findProfile(profiles, source) : source;
return createLayoutProfile(profiles, {
...input,
layout: original.layout,
isDefault: false,
});
}
export function deleteLayoutProfile(profiles: SavedLayout[], profileId: string): SavedLayout[] {
findProfile(profiles, profileId);
return profiles.filter((profile) => profile.id !== profileId);
}
export function setDefaultLayoutProfile(
profiles: SavedLayout[],
profileId: string | null,
): SavedLayout[] {
if (profileId) {
const selected = findProfile(profiles, profileId);
if (!validateReusableLayout(selected.layout).valid) {
throw new Error("Only a valid reusable layout can be the default");
}
}
return profiles.map((profile) => {
const isDefault = profile.id === profileId;
return profile.is_default === isDefault ? profile : { ...profile, is_default: isDefault };
});
}