feat: add configurable task layout profiles (#1799)

This commit is contained in:
Carlos Florêncio
2026-07-20 12:09:06 +01:00
committed by GitHub
parent 4d1787f484
commit ba75922fa1
43 changed files with 4198 additions and 227 deletions
@@ -430,10 +430,25 @@ func applySavedLayouts(settings *models.UserSettings, req *UpdateUserSettingsReq
if len(layouts) > maxSavedLayouts {
return fmt.Errorf("saved_layouts: max %d layouts allowed", maxSavedLayouts)
}
seen := make(map[string]struct{}, len(layouts))
hasDefault := false
for i := range layouts {
if strings.TrimSpace(layouts[i].ID) == "" {
return errors.New("saved_layouts: layout id must not be empty")
}
if strings.TrimSpace(layouts[i].Name) == "" {
return errors.New("saved_layouts: layout name must not be empty")
}
if _, dup := seen[layouts[i].ID]; dup {
return fmt.Errorf("saved_layouts: duplicate layout id %q", layouts[i].ID)
}
seen[layouts[i].ID] = struct{}{}
if layouts[i].IsDefault {
if hasDefault {
return errors.New("saved_layouts: at most one default layout allowed")
}
hasDefault = true
}
}
settings.SavedLayouts = layouts
return nil
@@ -392,6 +392,49 @@ func TestApplySavedLayouts(t *testing.T) {
wantCount: 1,
wantApplied: true,
},
{
name: "valid layout with one default is applied",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: "l1", Name: "Default layout", IsDefault: true, Layout: json.RawMessage(`{}`)},
{ID: "l2", Name: "Other layout", Layout: json.RawMessage(`{}`)},
}),
},
wantCount: 2,
wantApplied: true,
},
{
name: "valid reserved override default is applied",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: "layout-override-default", Name: "Default", IsDefault: true, Layout: json.RawMessage(`{}`)},
}),
},
wantCount: 1,
wantApplied: true,
},
{
name: "valid mixed custom and reserved override layouts are applied",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: "layout-custom", Name: "Custom", Layout: json.RawMessage(`{}`)},
{ID: "layout-override-plan", Name: "Plan Mode", Layout: json.RawMessage(`{}`)},
}),
},
wantCount: 2,
wantApplied: true,
},
{
name: "valid mixed layouts allow one reserved override default",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: "layout-custom", Name: "Custom", Layout: json.RawMessage(`{}`)},
{ID: "layout-override-default", Name: "Default", IsDefault: true, Layout: json.RawMessage(`{}`)},
}),
},
wantCount: 2,
wantApplied: true,
},
{
name: "exactly max layouts is accepted",
req: &UpdateUserSettingsRequest{
@@ -425,6 +468,44 @@ func TestApplySavedLayouts(t *testing.T) {
},
wantErr: "saved_layouts: layout name must not be empty",
},
{
name: "empty id returns error",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: "", Name: "Layout", Layout: json.RawMessage(`{}`)},
}),
},
wantErr: "saved_layouts: layout id must not be empty",
},
{
name: "whitespace-only id returns error",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: " ", Name: "Layout", Layout: json.RawMessage(`{}`)},
}),
},
wantErr: "saved_layouts: layout id must not be empty",
},
{
name: "duplicate ids return error",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: "l1", Name: "First", Layout: json.RawMessage(`{}`)},
{ID: "l1", Name: "Second", Layout: json.RawMessage(`{}`)},
}),
},
wantErr: `saved_layouts: duplicate layout id "l1"`,
},
{
name: "mixed custom and reserved override defaults return error",
req: &UpdateUserSettingsRequest{
SavedLayouts: ptr([]models.SavedLayout{
{ID: "layout-custom", Name: "Custom", IsDefault: true, Layout: json.RawMessage(`{}`)},
{ID: "layout-override-default", Name: "Default", IsDefault: true, Layout: json.RawMessage(`{}`)},
}),
},
wantErr: "saved_layouts: at most one default layout allowed",
},
}
for _, tt := range tests {
+5
View File
@@ -97,6 +97,11 @@ surface.
important exclusions, precedence, cost, or destructive consequences next to the control when they
can affect the decision. Do not rely on tooltips, external documentation, or implementation terms
alone to teach the setting.
- **Settings save coordination:** settings surfaces with local unsaved state must register a
contributor with `useSettingsSaveContributor` (or use `SettingsPageTemplate`) so the shared
floating **Save changes** control, navigation guard, and discard flow own persistence. Do not add
page-local Save/Cancel controls. Contributor `save` callbacks must reject on failure so the
coordinator can report an error; `discard` must restore the contributor's authoritative baseline.
- **Dialog Enter-to-confirm:** the base `@kandev/ui` `DialogContent` / `AlertDialogContent`
activate the dialog's semantic action on plain Enter (`packages/ui/src/lib/dialog-default-action.ts`),
so per-dialog "submit on Enter" input handlers are unnecessary — let the base own it.
@@ -0,0 +1,13 @@
import { StateProvider } from "@/components/state-provider";
import { LayoutSettings } from "@/components/settings/layouts/layout-settings";
import { getUserSettingsInitialState } from "../user-settings-state";
export default async function GeneralLayoutsPage() {
const initialState = await getUserSettingsInitialState();
return (
<StateProvider initialState={initialState}>
<LayoutSettings />
</StateProvider>
);
}
@@ -3,6 +3,7 @@ import {
IconBell,
IconCommand,
IconCode,
IconLayoutDashboard,
IconPalette,
IconTerminal2,
} from "@tabler/icons-react";
@@ -22,6 +23,12 @@ export const GENERAL_NAV_ITEMS: GeneralNavItem[] = [
description: "Theme, metrics, and changes panel preferences",
icon: IconPalette,
},
{
href: "/settings/general/layouts",
label: "Layouts",
description: "Task workbench layout profiles and defaults",
icon: IconLayoutDashboard,
},
{
href: "/settings/general/terminal",
label: "Terminal",
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "vitest";
import type { DockviewApi } from "dockview-react";
import {
addReusablePanel,
mergeGroup,
moveGroup,
movePanelToGroup,
removeReusablePanel,
reorderTab,
resizeGroup,
splitPanel,
} from "./layout-editor-actions";
type FakeGroup = {
id: string;
panels: FakePanel[];
api: {
width: number;
height: number;
moveTo: ReturnType<typeof vi.fn>;
setSize: ReturnType<typeof vi.fn>;
};
};
type FakePanel = {
id: string;
group: FakeGroup;
api: {
moveTo: ReturnType<typeof vi.fn>;
};
};
function fakeApi(panelIds = ["chat", "files", "changes"]) {
const group = {
id: "group-one",
panels: [] as FakePanel[],
api: { width: 320, height: 240, moveTo: vi.fn(), setSize: vi.fn() },
};
group.panels = panelIds.map((id) => ({
id,
group,
api: { moveTo: vi.fn() },
}));
const second = {
id: "group-two",
panels: [] as FakePanel[],
api: { width: 280, height: 200, moveTo: vi.fn(), setSize: vi.fn() },
};
const api = {
groups: [group, second],
panels: group.panels,
activeGroup: group,
getPanel: vi.fn((id: string) => group.panels.find((panel) => panel.id === id)),
addPanel: vi.fn(),
removePanel: vi.fn(),
} as unknown as DockviewApi;
return { api, group, second, panels: group.panels };
}
describe("layout editor actions", () => {
it("adds only missing reusable panels to the active group", () => {
const { api, group } = fakeApi(["chat"]);
expect(addReusablePanel(api, "files")).toBe(true);
expect(api.addPanel).toHaveBeenCalledWith(
expect.objectContaining({
id: "files",
component: "files",
position: { referenceGroup: group },
}),
);
expect(addReusablePanel(api, "chat")).toBe(false);
});
it("protects Agent while removing another reusable panel", () => {
const { api, panels } = fakeApi();
expect(removeReusablePanel(api, "chat")).toBe(false);
expect(removeReusablePanel(api, "files")).toBe(true);
expect(api.removePanel).toHaveBeenCalledWith(panels[1]);
});
it("reorders tabs within their group", () => {
const { api, panels, group } = fakeApi();
expect(reorderTab(api, "files", 1)).toBe(true);
expect(panels[1].api.moveTo).toHaveBeenCalledWith({
group,
position: "center",
index: 2,
skipSetActive: true,
});
});
it("moves a panel to another group and creates a split", () => {
const { api, panels, second, group } = fakeApi();
expect(movePanelToGroup(api, "files", second.id)).toBe(true);
expect(splitPanel(api, "changes", "right")).toBe(true);
expect(panels[1].api.moveTo).toHaveBeenCalledWith({ group: second, position: "center" });
expect(panels[2].api.moveTo).toHaveBeenCalledWith({ group, position: "right" });
});
it("does not claim to create a split from a single-tab group", () => {
const { api } = fakeApi(["chat"]);
expect(splitPanel(api, "chat", "right")).toBe(false);
});
it("moves and merges complete groups relative to another group", () => {
const { api, group, second } = fakeApi();
expect(moveGroup(api, group.id, second.id, "below")).toBe(true);
expect(mergeGroup(api, group.id, second.id)).toBe(true);
expect(group.api.moveTo).toHaveBeenNthCalledWith(1, {
group: second,
position: "bottom",
});
expect(group.api.moveTo).toHaveBeenNthCalledWith(2, {
group: second,
position: "center",
});
});
it("resizes a group in bounded keyboard-friendly steps", () => {
const { api, group } = fakeApi();
expect(resizeGroup(api, group.id, "width", -500)).toBe(true);
expect(resizeGroup(api, group.id, "height", 40)).toBe(true);
expect(group.api.setSize).toHaveBeenNthCalledWith(1, { width: 120 });
expect(group.api.setSize).toHaveBeenNthCalledWith(2, { height: 280 });
});
});
@@ -0,0 +1,116 @@
import type { DockviewApi } from "dockview-react";
import { PANEL_REGISTRY, type ReusablePanelId } from "@/lib/state/layout-manager";
export type LayoutEditorDirection = "left" | "right" | "above" | "below";
export type LayoutEditorSizeAxis = "width" | "height";
const MIN_GROUP_SIZE = 120;
function getGroup(api: DockviewApi, groupId: string) {
return api.groups.find((group) => group.id === groupId);
}
function toDockviewPosition(direction: LayoutEditorDirection) {
if (direction === "above") return "top";
if (direction === "below") return "bottom";
return direction;
}
export function addReusablePanel(
api: DockviewApi,
panelId: ReusablePanelId,
groupId?: string,
): boolean {
if (api.getPanel(panelId)) return false;
const referenceGroup = groupId ? getGroup(api, groupId) : (api.activeGroup ?? api.groups[0]);
api.addPanel({
id: panelId,
...PANEL_REGISTRY[panelId],
...(referenceGroup ? { position: { referenceGroup } } : {}),
});
return true;
}
export function removeReusablePanel(api: DockviewApi, panelId: string): boolean {
if (panelId === "chat") return false;
const panel = api.getPanel(panelId);
if (!panel) return false;
api.removePanel(panel);
return true;
}
export function reorderTab(api: DockviewApi, panelId: string, delta: -1 | 1): boolean {
const panel = api.getPanel(panelId);
if (!panel) return false;
const currentIndex = panel.group.panels.findIndex((candidate) => candidate.id === panelId);
const nextIndex = currentIndex + delta;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= panel.group.panels.length) return false;
panel.api.moveTo({
group: panel.group,
position: "center",
index: nextIndex,
skipSetActive: true,
});
return true;
}
export function movePanelToGroup(
api: DockviewApi,
panelId: string,
targetGroupId: string,
): boolean {
const panel = api.getPanel(panelId);
const target = getGroup(api, targetGroupId);
if (!panel || !target || panel.group.id === targetGroupId) return false;
panel.api.moveTo({ group: target, position: "center" });
return true;
}
export function splitPanel(
api: DockviewApi,
panelId: string,
direction: LayoutEditorDirection,
): boolean {
const panel = api.getPanel(panelId);
if (!panel || panel.group.panels.length < 2) return false;
panel.api.moveTo({ group: panel.group, position: toDockviewPosition(direction) });
return true;
}
export function moveGroup(
api: DockviewApi,
sourceGroupId: string,
targetGroupId: string,
direction: LayoutEditorDirection,
): boolean {
const source = getGroup(api, sourceGroupId);
const target = getGroup(api, targetGroupId);
if (!source || !target || source === target) return false;
source.api.moveTo({ group: target, position: toDockviewPosition(direction) });
return true;
}
export function mergeGroup(
api: DockviewApi,
sourceGroupId: string,
targetGroupId: string,
): boolean {
const source = getGroup(api, sourceGroupId);
const target = getGroup(api, targetGroupId);
if (!source || !target || source === target) return false;
source.api.moveTo({ group: target, position: "center" });
return true;
}
export function resizeGroup(
api: DockviewApi,
groupId: string,
axis: LayoutEditorSizeAxis,
delta: number,
): boolean {
const group = getGroup(api, groupId);
if (!group) return false;
const size = Math.max(MIN_GROUP_SIZE, group.api[axis] + delta);
group.api.setSize({ [axis]: size });
return true;
}
@@ -0,0 +1,376 @@
"use client";
import {
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconLayoutColumns,
IconLayoutRows,
IconMinus,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import type { DockviewApi, DockviewGroupPanel, IDockviewPanel } from "dockview-react";
import { PANEL_REGISTRY, REUSABLE_PANEL_IDS } from "@/lib/state/layout-manager";
import {
addReusablePanel,
mergeGroup,
moveGroup,
movePanelToGroup,
removeReusablePanel,
reorderTab,
resizeGroup,
splitPanel,
} from "./layout-editor-actions";
export type LayoutEditorActionAnchor = {
left: number;
top: number;
width: number;
};
type LayoutEditorActionsProps = {
api: DockviewApi | null;
anchor: LayoutEditorActionAnchor | null;
editable: boolean;
selectedPanelId: string | null;
onCommand: () => void;
};
type ActionState = LayoutEditorActionsProps & {
selected: IDockviewPanel | undefined;
targetGroups: DockviewGroupPanel[];
disabled: boolean;
perform: (command: () => boolean) => void;
};
const touchButtonClass = "min-h-11 min-w-11 cursor-pointer sm:min-h-8 sm:min-w-8";
const directions = [
{ direction: "left" as const, label: "Left", icon: IconArrowLeft },
{ direction: "right" as const, label: "Right", icon: IconArrowRight },
{ direction: "above" as const, label: "Above", icon: IconArrowUp },
{ direction: "below" as const, label: "Below", icon: IconArrowDown },
];
function ActionTooltip({
label,
help,
disabled,
onClick,
children,
}: {
label: string;
help: string;
disabled: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={disabled ? 0 : -1} className="inline-flex">
<Button
type="button"
size="icon-sm"
variant="ghost"
className={touchButtonClass}
disabled={disabled}
aria-label={label}
onClick={onClick}
>
{children}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>{help}</TooltipContent>
</Tooltip>
);
}
function MenuTrigger({
label,
help,
disabled,
children,
}: {
label: string;
help: string;
disabled: boolean;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={disabled ? 0 : -1} className="inline-flex">
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="ghost"
className={touchButtonClass}
disabled={disabled}
aria-label={label}
>
{children}
</Button>
</DropdownMenuTrigger>
</span>
</TooltipTrigger>
<TooltipContent>{help}</TooltipContent>
</Tooltip>
);
}
function AddPanelAction({ state }: { state: ActionState }) {
const missing = REUSABLE_PANEL_IDS.filter((id) => !state.api?.getPanel(id));
const disabled = !state.editable || !state.api || missing.length === 0;
return (
<div className="absolute bottom-2 left-2 z-20" data-testid="layout-editor-add-panel">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={disabled ? 0 : -1} className="inline-flex">
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="secondary"
className="min-h-11 cursor-pointer shadow-md sm:min-h-8"
disabled={disabled}
aria-label="Add panel"
>
<IconPlus className="mr-1.5 h-4 w-4" /> Add panel
</Button>
</DropdownMenuTrigger>
</span>
</TooltipTrigger>
<TooltipContent>Add a missing tool tab beside the selected split.</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start">
{missing.map((id) => (
<DropdownMenuItem
key={id}
className="cursor-pointer"
title={`Add the ${PANEL_REGISTRY[id].title} tab to the selected split.`}
onSelect={() =>
state.perform(() => addReusablePanel(state.api!, id, state.selected?.group.id))
}
>
{PANEL_REGISTRY[id].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function SplitMenu({ state }: { state: ActionState }) {
const disabled = state.disabled || (state.selected?.group.panels.length ?? 0) < 2;
return (
<DropdownMenu>
<MenuTrigger
label="Split tab"
help="Move this tab into a new split beside its current split."
disabled={disabled}
>
<IconLayoutColumns className="h-4 w-4" />
</MenuTrigger>
<DropdownMenuContent align="start">
{directions.map(({ direction, label, icon: Icon }) => (
<DropdownMenuItem
key={direction}
className="cursor-pointer"
title={`Create a new split ${direction} of the selected split.`}
onSelect={() =>
state.perform(() => splitPanel(state.api!, state.selectedPanelId!, direction))
}
>
<Icon className="mr-2 h-4 w-4" /> {label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function MovePanelMenu({ state }: { state: ActionState }) {
return (
<DropdownMenu>
<MenuTrigger
label="Move tab to split"
help="Move this tab into another existing split."
disabled={state.disabled || state.targetGroups.length === 0}
>
<IconArrowRight className="h-4 w-4" />
</MenuTrigger>
<DropdownMenuContent align="start">
{state.targetGroups.map((group) => (
<DropdownMenuItem
key={group.id}
className="cursor-pointer"
title={`Move this tab into the split containing ${group.activePanel?.title ?? group.id}.`}
onSelect={() =>
state.perform(() => movePanelToGroup(state.api!, state.selectedPanelId!, group.id))
}
>
{group.activePanel?.title ?? group.id}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function ArrangeSplitMenu({ state }: { state: ActionState }) {
const resize = (axis: "width" | "height", delta: number) =>
state.perform(() => resizeGroup(state.api!, state.selected!.group.id, axis, delta));
return (
<DropdownMenu>
<MenuTrigger
label="Arrange split"
help="Resize, reposition, or merge the selected split."
disabled={state.disabled}
>
<IconLayoutRows className="h-4 w-4" />
</MenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 overflow-y-auto">
<DropdownMenuItem
className="cursor-pointer"
title="Make the selected split 40 pixels narrower."
onSelect={() => resize("width", -40)}
>
<IconLayoutColumns className="mr-2 h-4 w-4" />
<IconMinus className="mr-2 h-3 w-3" /> Decrease width
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
title="Make the selected split 40 pixels wider."
onSelect={() => resize("width", 40)}
>
<IconLayoutColumns className="mr-2 h-4 w-4" />
<IconPlus className="mr-2 h-3 w-3" /> Increase width
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
title="Make the selected split 40 pixels shorter."
onSelect={() => resize("height", -40)}
>
<IconLayoutRows className="mr-2 h-4 w-4" />
<IconMinus className="mr-2 h-3 w-3" /> Decrease height
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
title="Make the selected split 40 pixels taller."
onSelect={() => resize("height", 40)}
>
<IconLayoutRows className="mr-2 h-4 w-4" />
<IconPlus className="mr-2 h-3 w-3" /> Increase height
</DropdownMenuItem>
{state.targetGroups.length > 0 && <DropdownMenuSeparator />}
{state.targetGroups.flatMap((target) => [
...directions.map(({ direction, label, icon: Icon }) => (
<DropdownMenuItem
key={`${target.id}-${direction}`}
className="cursor-pointer"
title={`Move this split ${direction} of ${target.activePanel?.title ?? target.id}.`}
onSelect={() =>
state.perform(() =>
moveGroup(state.api!, state.selected!.group.id, target.id, direction),
)
}
>
<Icon className="mr-2 h-4 w-4" /> {label} of {target.activePanel?.title ?? target.id}
</DropdownMenuItem>
)),
<DropdownMenuItem
key={`${target.id}-merge`}
className="cursor-pointer"
title={`Merge this split into the split containing ${target.activePanel?.title ?? target.id}.`}
onSelect={() =>
state.perform(() => mergeGroup(state.api!, state.selected!.group.id, target.id))
}
>
Merge with {target.activePanel?.title ?? target.id}
</DropdownMenuItem>,
])}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SelectedPanelActions({ state }: { state: ActionState }) {
if (!state.anchor || !state.selected) return null;
const first = state.selected.group.panels[0]?.id === state.selectedPanelId;
const last = state.selected.group.panels.at(-1)?.id === state.selectedPanelId;
return (
<div
className="absolute z-20 flex items-center gap-0.5 overflow-x-auto rounded-md border bg-popover/95 p-1 text-popover-foreground shadow-md backdrop-blur-sm"
style={{ left: state.anchor.left, top: state.anchor.top, width: state.anchor.width }}
data-testid="layout-editor-context-actions"
data-legacy-testid="layout-editor-toolbar"
aria-label={`Actions for ${state.selected.title ?? state.selected.id}`}
>
<span className="hidden max-w-28 truncate px-2 text-xs font-medium sm:inline">
{state.selected.title ?? state.selected.id}
</span>
<ActionTooltip
label="Move tab left"
help="Move this tab one position left within its split."
disabled={state.disabled || first}
onClick={() => state.perform(() => reorderTab(state.api!, state.selectedPanelId!, -1))}
>
<IconArrowLeft className="h-4 w-4" />
</ActionTooltip>
<ActionTooltip
label="Move tab right"
help="Move this tab one position right within its split."
disabled={state.disabled || last}
onClick={() => state.perform(() => reorderTab(state.api!, state.selectedPanelId!, 1))}
>
<IconArrowRight className="h-4 w-4" />
</ActionTooltip>
<SplitMenu state={state} />
<MovePanelMenu state={state} />
<ArrangeSplitMenu state={state} />
<ActionTooltip
label="Remove panel"
help="Remove this tab from the saved layout."
disabled={state.disabled || state.selectedPanelId === "chat"}
onClick={() => state.perform(() => removeReusablePanel(state.api!, state.selectedPanelId!))}
>
<IconTrash className="h-4 w-4" />
</ActionTooltip>
</div>
);
}
export function LayoutEditorActions(props: LayoutEditorActionsProps) {
const selected = props.selectedPanelId ? props.api?.getPanel(props.selectedPanelId) : undefined;
const perform = (command: () => boolean) => {
if (command()) props.onCommand();
};
const state: ActionState = {
...props,
selected,
targetGroups: props.api?.groups.filter((group) => group.id !== selected?.group.id) ?? [],
disabled: !props.editable || !props.api || !selected,
perform,
};
return (
<>
<AddPanelAction state={state} />
<SelectedPanelActions state={state} />
</>
);
}
@@ -0,0 +1,208 @@
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
import {
DockviewDefaultTab,
DockviewReact,
type DockviewApi,
type DockviewReadyEvent,
type IDockviewPanel,
type IDockviewPanelHeaderProps,
type IDockviewPanelProps,
} from "dockview-react";
import { themeKandev } from "@/lib/layout/dockview-theme";
import {
fromDockviewApi,
toSerializedDockview,
type LayoutState,
} from "@/lib/state/layout-manager";
import { LayoutEditorActions, type LayoutEditorActionAnchor } from "./layout-editor-toolbar";
type LayoutEditorProps = {
layout: LayoutState;
editable: boolean;
onChange: (layout: LayoutState) => void;
};
function PlaceholderPanel({ api }: IDockviewPanelProps) {
return (
<div
className="flex h-full items-center justify-center bg-background/60 p-4 text-center text-sm text-muted-foreground"
data-testid={`layout-placeholder-${api.id}`}
>
{api.title ?? api.id}
</div>
);
}
function EditorTab(props: IDockviewPanelHeaderProps) {
return <DockviewDefaultTab {...props} hideClose />;
}
const placeholderComponents = {
chat: PlaceholderPanel,
files: PlaceholderPanel,
changes: PlaceholderPanel,
terminal: PlaceholderPanel,
plan: PlaceholderPanel,
browser: PlaceholderPanel,
vscode: PlaceholderPanel,
};
const placeholderTabs = {
permanentTab: EditorTab,
changesTab: EditorTab,
planTab: EditorTab,
terminalTab: EditorTab,
};
function activePanelId(panel: { id: string } | undefined, api: DockviewApi) {
return panel?.id ?? api.panels[0]?.id ?? null;
}
function getActionAnchor(root: HTMLDivElement, selected: IDockviewPanel): LayoutEditorActionAnchor {
const rootBounds = root.getBoundingClientRect();
const groupBounds = selected.group.element.getBoundingClientRect();
const groupTop = groupBounds.top - rootBounds.top;
const groupBottom = groupBounds.bottom - rootBounds.top;
const actionWidth = Math.min(360, rootBounds.width - 16);
const centeredLeft = groupBounds.left - rootBounds.left + groupBounds.width / 2 - actionWidth / 2;
return {
left: Math.max(8, Math.min(centeredLeft, rootBounds.width - actionWidth - 8)),
top: Math.max(groupTop + 8, Math.min(groupTop + 42, groupBottom - 54)),
width: actionWidth,
};
}
function EditorDock({
rootRef,
editable,
onReady,
}: {
rootRef: RefObject<HTMLDivElement | null>;
editable: boolean;
onReady: (event: DockviewReadyEvent) => void;
}) {
return (
<div ref={rootRef} className="h-[28rem] min-h-80 w-full min-w-0 overflow-hidden sm:h-[32rem]">
<DockviewReact
theme={themeKandev}
components={placeholderComponents}
tabComponents={placeholderTabs}
defaultTabComponent={EditorTab}
onReady={onReady}
disableFloatingGroups
disableDnd={!editable}
locked={!editable}
noPanelsOverlay="emptyGroup"
className="h-full min-w-0"
/>
</div>
);
}
export function LayoutEditor({ layout, editable, onChange }: LayoutEditorProps) {
const rootRef = useRef<HTMLDivElement>(null);
const apiRef = useRef<DockviewApi | null>(null);
const applyingRef = useRef(false);
const releaseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [api, setApi] = useState<DockviewApi | null>(null);
const [selectedPanelId, setSelectedPanelId] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const [actionAnchor, setActionAnchor] = useState<LayoutEditorActionAnchor | null>(null);
const updateActionAnchor = useCallback(() => {
const root = rootRef.current;
const selected = selectedPanelId ? apiRef.current?.getPanel(selectedPanelId) : null;
if (!root || !selected) {
setActionAnchor(null);
return;
}
setActionAnchor(getActionAnchor(root, selected));
}, [selectedPanelId]);
useLayoutEffect(() => {
updateActionAnchor();
}, [revision, updateActionAnchor]);
const capture = useCallback(() => {
const current = apiRef.current;
if (!current || applyingRef.current || !editable) return;
const captured = fromDockviewApi(current);
if (captured.columns.length > 0) onChange(captured);
setRevision((revision) => revision + 1);
}, [editable, onChange]);
const apply = useCallback((current: DockviewApi, nextLayout: LayoutState) => {
const bounds = rootRef.current?.getBoundingClientRect();
const width = Math.max(320, Math.round(bounds?.width ?? current.width ?? 900));
const height = Math.max(320, Math.round(bounds?.height ?? current.height ?? 480));
applyingRef.current = true;
current.fromJSON(toSerializedDockview(nextLayout, width, height, new Map()));
current.layout(width, height, true);
setSelectedPanelId(current.activePanel?.id ?? current.panels[0]?.id ?? null);
setRevision((revision) => revision + 1);
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current);
releaseTimerRef.current = setTimeout(() => {
applyingRef.current = false;
}, 0);
}, []);
const onReady = useCallback(
(event: DockviewReadyEvent) => {
apiRef.current = event.api;
setApi(event.api);
apply(event.api, layout);
},
[apply, layout],
);
useEffect(() => {
if (!api) return;
const layoutDisposable = api.onDidLayoutChange(capture);
const activeDisposable = api.onDidActivePanelChange((panel) => {
setSelectedPanelId(activePanelId(panel, api));
setRevision((revision) => revision + 1);
});
return () => {
layoutDisposable.dispose();
activeDisposable.dispose();
};
}, [api, capture]);
useEffect(() => {
const element = rootRef.current;
if (!api || !element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(([entry]) => {
const width = Math.round(entry.contentRect.width);
const height = Math.round(entry.contentRect.height);
if (width <= 0 || height <= 0) return;
applyingRef.current = true;
api.layout(width, height, true);
setRevision((revision) => revision + 1);
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current);
releaseTimerRef.current = setTimeout(() => {
applyingRef.current = false;
}, 0);
});
observer.observe(element);
return () => {
observer.disconnect();
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current);
apiRef.current = null;
};
}, [api]);
return (
<div className="relative min-w-0 overflow-hidden rounded-md border" data-testid="layout-editor">
<LayoutEditorActions
api={api}
anchor={actionAnchor}
editable={editable}
selectedPanelId={selectedPanelId}
onCommand={capture}
/>
<EditorDock rootRef={rootRef} editable={editable} onReady={onReady} />
</div>
);
}
@@ -0,0 +1,200 @@
"use client";
import { IconCopy, IconPlus } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import {
BUILT_IN_LAYOUT_PROFILES,
getBuiltInLayoutOverride,
getBuiltInLayoutOverrideSourceId,
getLayoutProfileCompatibility,
isBuiltInLayoutOverride,
resolveEffectiveDefaultLayout,
type BuiltInLayoutProfileId,
} from "@/lib/layout/layout-profiles";
import type { SavedLayout } from "@/lib/types/http";
export type LayoutProfileSelection =
| { kind: "built-in"; id: BuiltInLayoutProfileId }
| { kind: "custom"; id: string };
type LayoutProfileListProps = {
profiles: SavedLayout[];
selection: LayoutProfileSelection;
onSelect: (selection: LayoutProfileSelection) => void;
onCreate: () => void;
onDuplicate: () => void;
};
function isSelected(
selection: LayoutProfileSelection,
kind: LayoutProfileSelection["kind"],
id: string,
) {
return selection.kind === kind && selection.id === id;
}
const profileButtonClass =
"flex min-h-11 w-full cursor-pointer items-start justify-between gap-2 rounded-md border px-3 py-2 text-left transition-colors";
function ProfileAction({
label,
help,
variant,
testId,
onClick,
children,
}: {
label: string;
help: string;
variant?: "default" | "outline";
testId: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant={variant}
className="min-h-11 cursor-pointer sm:min-h-8"
aria-label={label}
onClick={onClick}
data-testid={testId}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{help}</TooltipContent>
</Tooltip>
);
}
function BuiltInProfileList({
profiles,
selection,
effectiveBuiltInId,
onSelect,
}: {
profiles: SavedLayout[];
selection: LayoutProfileSelection;
effectiveBuiltInId: BuiltInLayoutProfileId | null;
onSelect: (selection: LayoutProfileSelection) => void;
}) {
return (
<div className="space-y-1.5">
<h4 className="text-xs font-medium uppercase text-muted-foreground">Built-in</h4>
{BUILT_IN_LAYOUT_PROFILES.map((profile) => {
const override = getBuiltInLayoutOverride(profiles, profile.id);
return (
<button
key={profile.id}
type="button"
className={cn(
profileButtonClass,
isSelected(selection, "built-in", profile.id)
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
onClick={() => onSelect({ kind: "built-in", id: profile.id })}
data-testid={`layout-profile-built-in-${profile.id}`}
>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 flex-wrap items-center justify-between gap-1">
<span className="text-sm font-medium">{profile.name}</span>
<span className="flex flex-wrap justify-end gap-1">
<Badge variant="outline">Built-in</Badge>
{override && <Badge variant="secondary">Customized</Badge>}
{effectiveBuiltInId === profile.id && <Badge variant="secondary">Default</Badge>}
</span>
</span>
<span className="text-xs text-muted-foreground">{profile.description}</span>
</span>
</button>
);
})}
</div>
);
}
export function LayoutProfileList({
profiles,
selection,
onSelect,
onCreate,
onDuplicate,
}: LayoutProfileListProps) {
const effectiveDefault = resolveEffectiveDefaultLayout(profiles);
const effectiveBuiltInId =
effectiveDefault.source === "built-in"
? effectiveDefault.profile.id
: getBuiltInLayoutOverrideSourceId(effectiveDefault.profile);
const customProfiles = profiles.filter((profile) => !isBuiltInLayoutOverride(profile));
return (
<aside className="min-w-0 space-y-3" aria-label="Layout profiles">
<div className="flex flex-wrap gap-2">
<ProfileAction
label="New layout"
help="Create a new editable layout based on Default."
onClick={onCreate}
testId="layout-profile-create"
>
<IconPlus className="mr-1.5 h-4 w-4" /> New
</ProfileAction>
<ProfileAction
label="Duplicate layout"
help="Create a separate custom copy of the selected layout."
variant="outline"
onClick={onDuplicate}
testId="layout-profile-duplicate"
>
<IconCopy className="mr-1.5 h-4 w-4" /> Duplicate
</ProfileAction>
</div>
<BuiltInProfileList
profiles={profiles}
selection={selection}
effectiveBuiltInId={effectiveBuiltInId}
onSelect={onSelect}
/>
<div className="space-y-1.5">
<h4 className="text-xs font-medium uppercase text-muted-foreground">Custom</h4>
{customProfiles.length === 0 && (
<p className="py-3 text-sm text-muted-foreground">No custom profiles</p>
)}
{customProfiles.map((profile) => {
const compatibility = getLayoutProfileCompatibility(profile);
return (
<button
key={profile.id}
type="button"
className={cn(
profileButtonClass,
isSelected(selection, "custom", profile.id)
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
onClick={() => onSelect({ kind: "custom", id: profile.id })}
data-testid={`layout-profile-custom-${profile.id}`}
>
<span className="min-w-0 truncate text-sm font-medium">{profile.name}</span>
<span className="flex shrink-0 gap-1">
{effectiveDefault.source === "custom" &&
effectiveDefault.profile.id === profile.id && (
<Badge variant="secondary">Default</Badge>
)}
{compatibility.status === "legacy" && <Badge variant="outline">Unavailable</Badge>}
</span>
</button>
);
})}
</div>
</aside>
);
}
@@ -0,0 +1,253 @@
"use client";
import { useState } from "react";
import {
IconAlertTriangle,
IconLayoutDashboard,
IconRestore,
IconTrash,
} from "@tabler/icons-react";
import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { LayoutEditor } from "./layout-editor";
import { LayoutProfileList } from "./layout-profile-list";
import { useLayoutSettings } from "./use-layout-settings";
type Controller = ReturnType<typeof useLayoutSettings>;
function defaultActionHelp(selectedSavedDefault: boolean, selectedIsDefault: boolean) {
if (selectedSavedDefault) {
return "Make the original Default layout the starting layout for new tasks.";
}
if (selectedIsDefault) return "This layout is used as the starting layout for new tasks.";
return "Use this layout as the starting layout for new tasks.";
}
function LayoutSettingsHeader() {
return (
<>
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-2xl font-bold">
<IconLayoutDashboard className="h-5 w-5" />
Layouts
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Configure the initial desktop task workbench.
</p>
</div>
<Separator />
</>
);
}
function ResetBuiltInButton({ onClick }: { onClick: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 cursor-pointer sm:min-h-8"
aria-label="Reset built-in layout"
onClick={onClick}
>
<IconRestore className="h-4 w-4" /> Reset
</Button>
</TooltipTrigger>
<TooltipContent>
Restore the original built-in layout and discard its override.
</TooltipContent>
</Tooltip>
);
}
function DeleteProfileButton({ onClick }: { onClick: () => void }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon-sm"
variant="outline"
className="min-h-11 min-w-11 cursor-pointer sm:min-h-8 sm:min-w-8"
aria-label="Delete layout profile"
onClick={onClick}
>
<IconTrash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete this custom layout after confirmation.</TooltipContent>
</Tooltip>
);
}
function SelectedLayoutHeader({
controller,
onDelete,
}: {
controller: Controller;
onDelete: () => void;
}) {
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
{controller.selectedCustom ? (
<Input
aria-label="Layout profile name"
value={controller.selectedCustom.name}
onChange={(event) => controller.updateSelected({ name: event.target.value })}
className="min-h-11 max-w-md sm:min-h-9"
/>
) : (
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-lg font-semibold">{controller.selectedName}</h3>
<Badge variant="outline">Built-in</Badge>
{controller.selectedBuiltInOverride && <Badge variant="secondary">Customized</Badge>}
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={controller.defaultActionDisabled ? 0 : -1} className="inline-flex">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 cursor-pointer sm:min-h-8"
disabled={controller.defaultActionDisabled}
onClick={controller.setDefault}
>
{controller.defaultActionLabel}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{defaultActionHelp(controller.selectedSavedDefault, controller.selectedIsDefault)}
</TooltipContent>
</Tooltip>
{controller.selectedBuiltInOverride && (
<ResetBuiltInButton onClick={controller.resetBuiltIn} />
)}
{controller.selectedCustom && <DeleteProfileButton onClick={onDelete} />}
</div>
</div>
);
}
function SelectedLayoutEditor({ controller }: { controller: Controller }) {
const editorKey = `${controller.selection.kind}:${controller.selection.id}:${controller.editorReset}`;
if (!controller.editorLayout) {
return (
<Alert>
<IconAlertTriangle className="h-4 w-4" />
<AlertTitle>Visual editor unavailable</AlertTitle>
<AlertDescription>
{controller.compatibility?.issues.map((issue) => issue.message).join(". ")}
</AlertDescription>
</Alert>
);
}
return (
<LayoutEditor
key={editorKey}
layout={controller.editorLayout}
editable
onChange={controller.updateLayout}
/>
);
}
function DeleteProfileDialog({
controller,
open,
onOpenChange,
}: {
controller: Controller;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const confirm = () => {
controller.deleteSelected();
onOpenChange(false);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete {controller.selectedCustom?.name ?? "layout profile"}?
</AlertDialogTitle>
<AlertDialogDescription>
{controller.selectedCustom?.is_default
? "The built-in Default layout will become the default."
: "This profile will be removed when you save."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction className="cursor-pointer" onClick={confirm}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function LayoutSettings() {
const controller = useLayoutSettings();
const [deleteOpen, setDeleteOpen] = useState(false);
const invalidName = controller.profiles.some((profile) => !profile.name.trim());
useSettingsSaveContributor({
id: "layout-profiles",
revision: controller.profilesKey,
isDirty: controller.isDirty,
canSave: !invalidName,
invalidReason: invalidName ? "Layout profile names must not be empty" : undefined,
save: controller.save,
discard: controller.cancel,
});
return (
<div className="min-w-0 space-y-6" data-testid="layout-settings">
<LayoutSettingsHeader />
{controller.error && (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
<AlertTitle>Layout profiles were not saved</AlertTitle>
<AlertDescription>{controller.error}</AlertDescription>
</Alert>
)}
<div className="grid min-w-0 gap-5 lg:grid-cols-[16rem_minmax(0,1fr)]">
<LayoutProfileList
profiles={controller.profiles}
selection={controller.selection}
onSelect={controller.setSelection}
onCreate={controller.create}
onDuplicate={controller.duplicate}
/>
<section className="min-w-0 space-y-3" aria-label={`${controller.selectedName} editor`}>
<SelectedLayoutHeader controller={controller} onDelete={() => setDeleteOpen(true)} />
<SelectedLayoutEditor controller={controller} />
</section>
</div>
<DeleteProfileDialog controller={controller} open={deleteOpen} onOpenChange={setDeleteOpen} />
</div>
);
}
@@ -0,0 +1,335 @@
import { type ReactNode } from "react";
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { getBuiltInLayoutProfile } from "@/lib/layout/layout-profiles";
import { defaultState } from "@/lib/state/default-state";
import type { SavedLayout, UserSettingsResponse } from "@/lib/types/http";
import { workspaceId } from "@/lib/types/ids";
const updateUserSettings = vi.fn();
const PROFILE_ID = "profile-one";
const SECOND_PROFILE_ID = "profile-two";
const ORIGINAL_NAME = "Original";
const DRAFT_NAME = "Draft name";
const LEGACY_LAYOUT = { columns: "old-format" };
vi.mock("@/lib/api", () => ({
updateUserSettings: (...args: unknown[]) => updateUserSettings(...args),
}));
import { useLayoutSettings } from "./use-layout-settings";
function profile(overrides: Partial<SavedLayout> = {}): SavedLayout {
return {
id: PROFILE_ID,
name: ORIGINAL_NAME,
is_default: false,
layout: getBuiltInLayoutProfile("default").layout as unknown as Record<string, unknown>,
created_at: "2026-07-19T10:00:00.000Z",
...overrides,
};
}
function responseWith(savedLayouts: SavedLayout[]): UserSettingsResponse {
return {
settings: {
user_id: "user-one",
workspace_id: workspaceId("workspace-one"),
repository_ids: [],
saved_layouts: savedLayouts,
updated_at: "2026-07-19T12:00:00.000Z",
},
};
}
function renderLayoutSettings(savedLayouts: SavedLayout[] = []) {
function Wrapper({ children }: { children: ReactNode }) {
return (
<StateProvider
initialState={{
userSettings: {
...defaultState.userSettings,
savedLayouts,
loaded: true,
},
}}
>
{children}
</StateProvider>
);
}
return renderHook(() => useLayoutSettings(), { wrapper: Wrapper });
}
beforeEach(() => {
updateUserSettings.mockReset();
});
afterEach(cleanup);
describe("useLayoutSettings selection and drafts", () => {
it("selects the effective default on initialization", () => {
const customDefault = profile({ is_default: true });
const builtIn = renderLayoutSettings();
const custom = renderLayoutSettings([customDefault]);
expect(builtIn.result.current).toMatchObject({
selection: { kind: "built-in", id: "default" },
selectedIsDefault: true,
defaultActionDisabled: true,
defaultActionLabel: "Default",
isDirty: false,
});
expect(custom.result.current).toMatchObject({
selection: { kind: "custom", id: PROFILE_ID },
selectedCustom: customDefault,
selectedIsDefault: true,
defaultActionDisabled: false,
defaultActionLabel: "Use built-in Default",
isDirty: false,
});
});
it("duplicates and renames a profile, then cancel restores the baseline", () => {
const { result } = renderLayoutSettings();
act(() => result.current.duplicate());
const duplicateId = result.current.selection.id;
expect(result.current).toMatchObject({
selection: { kind: "custom", id: duplicateId },
selectedName: "Default copy",
isDirty: true,
});
act(() => result.current.updateSelected({ name: DRAFT_NAME }));
expect(result.current.selectedName).toBe(DRAFT_NAME);
act(() => result.current.cancel());
expect(result.current).toMatchObject({
selection: { kind: "built-in", id: "default" },
profiles: [],
isDirty: false,
editorReset: 1,
});
});
it("creates an editable profile from the built-in Default", () => {
const { result } = renderLayoutSettings([profile()]);
act(() => result.current.create());
expect(result.current.profiles).toHaveLength(2);
expect(result.current.selectedCustom).toMatchObject({
id: result.current.selection.id,
name: "Untitled layout",
is_default: false,
});
expect(result.current.compatibility?.status).toBe("editable");
expect(result.current.isDirty).toBe(true);
});
it("sets a custom default and clears it through the built-in Default", () => {
const second = profile({ id: SECOND_PROFILE_ID, name: "Second" });
const { result } = renderLayoutSettings([profile(), second]);
act(() => result.current.setSelection({ kind: "custom", id: SECOND_PROFILE_ID }));
act(() => result.current.setDefault());
expect(result.current.profiles.map((candidate) => candidate.is_default)).toEqual([false, true]);
act(() => result.current.setSelection({ kind: "built-in", id: "default" }));
expect(result.current.defaultActionDisabled).toBe(false);
act(() => result.current.setDefault());
expect(result.current.profiles.map((candidate) => candidate.is_default)).toEqual([
false,
false,
]);
expect(result.current.selectedIsDefault).toBe(true);
expect(result.current.defaultActionDisabled).toBe(true);
});
it("disables an invalid legacy non-default but permits clearing an invalid default", () => {
const unavailable = profile({ layout: LEGACY_LAYOUT });
const nonDefault = renderLayoutSettings([unavailable]);
act(() => nonDefault.result.current.setSelection({ kind: "custom", id: PROFILE_ID }));
expect(nonDefault.result.current).toMatchObject({
compatibility: { status: "legacy" },
defaultActionDisabled: true,
selectedIsDefault: false,
});
const invalidDefault = renderLayoutSettings([
profile({ layout: LEGACY_LAYOUT, is_default: true }),
]);
expect(invalidDefault.result.current).toMatchObject({
selection: { kind: "built-in", id: "default" },
selectedIsDefault: true,
defaultActionDisabled: true,
});
act(() => invalidDefault.result.current.setSelection({ kind: "custom", id: PROFILE_ID }));
expect(invalidDefault.result.current).toMatchObject({
compatibility: { status: "legacy" },
defaultActionDisabled: false,
defaultActionLabel: "Use built-in Default",
selectedIsDefault: false,
});
act(() => invalidDefault.result.current.setDefault());
expect(invalidDefault.result.current.profiles[0].is_default).toBe(false);
expect(invalidDefault.result.current.defaultActionDisabled).toBe(true);
});
});
describe("useLayoutSettings built-in editing", () => {
it("edits the built-in Default directly as a custom default draft", () => {
const { result } = renderLayoutSettings();
const layout = getBuiltInLayoutProfile("default").layout;
layout.columns = layout.columns
.map((column) => ({
...column,
groups: column.groups
.map((group) => ({
...group,
panels: group.panels.filter((panel) => panel.id !== "terminal-default"),
}))
.filter((group) => group.panels.length > 0),
}))
.filter((column) => column.groups.length > 0);
act(() => result.current.updateLayout(layout));
expect(result.current).toMatchObject({
selection: { kind: "built-in", id: "default" },
selectedName: "Default",
selectedIsDefault: true,
defaultActionLabel: "Default",
isDirty: true,
});
expect(result.current.selectedBuiltInOverride).toMatchObject({
name: "Default",
is_default: true,
});
expect(result.current.profiles).toHaveLength(1);
expect(
result.current.editorLayout?.columns
.flatMap((column) => column.groups)
.flatMap((group) => group.panels)
.map((panel) => panel.id),
).not.toContain("terminal-default");
});
it("preserves a custom default when editing the built-in Default", () => {
const customDefault = profile({ is_default: true });
const { result } = renderLayoutSettings([customDefault]);
const layout = getBuiltInLayoutProfile("default").layout;
layout.columns[0].width = 640;
act(() => result.current.setSelection({ kind: "built-in", id: "default" }));
act(() => result.current.updateLayout(layout));
expect(result.current.profiles).toEqual([
customDefault,
expect.objectContaining({
id: "layout-override-default",
is_default: false,
}),
]);
expect(result.current.selectedIsDefault).toBe(false);
});
it("resets a built-in override without affecting custom profiles", () => {
const custom = profile({ id: SECOND_PROFILE_ID, name: "Custom" });
const { result } = renderLayoutSettings([custom]);
const layout = getBuiltInLayoutProfile("default").layout;
layout.columns[1].groups = layout.columns[1].groups.slice(0, 1);
act(() => result.current.updateLayout(layout));
expect(result.current.selectedBuiltInOverride).not.toBeNull();
act(() => result.current.resetBuiltIn());
expect(result.current.selectedBuiltInOverride).toBeNull();
expect(result.current.profiles).toEqual([custom]);
expect(result.current.editorLayout).toEqual(getBuiltInLayoutProfile("default").layout);
});
it("preserves a non-Default built-in override as default when editing it", () => {
const { result } = renderLayoutSettings();
act(() => result.current.setSelection({ kind: "built-in", id: "plan" }));
act(() => result.current.setDefault());
const layout = getBuiltInLayoutProfile("plan").layout;
layout.columns[0].width = 640;
act(() => result.current.updateLayout(layout));
expect(result.current.selectedBuiltInOverride?.is_default).toBe(true);
expect(result.current.selectedIsDefault).toBe(true);
});
});
describe("useLayoutSettings persistence", () => {
it("keeps a customized built-in selected after a successful save", async () => {
const { result } = renderLayoutSettings();
const layout = getBuiltInLayoutProfile("plan").layout;
layout.columns[0].width = 640;
act(() => result.current.setSelection({ kind: "built-in", id: "plan" }));
act(() => result.current.updateLayout(layout));
updateUserSettings.mockResolvedValueOnce(
responseWith(structuredClone(result.current.profiles)),
);
await act(async () => result.current.save());
expect(result.current.selection).toEqual({ kind: "built-in", id: "plan" });
expect(result.current.editorLayout?.columns[0].width).toBe(640);
});
it("adopts the successful PATCH response as the authoritative baseline", async () => {
const authoritative = profile({ name: "Server name", is_default: true });
updateUserSettings.mockResolvedValueOnce(responseWith([authoritative]));
const { result } = renderLayoutSettings([profile()]);
act(() => result.current.setSelection({ kind: "custom", id: PROFILE_ID }));
act(() => result.current.updateSelected({ name: DRAFT_NAME }));
await act(async () => result.current.save());
expect(updateUserSettings).toHaveBeenCalledWith({
saved_layouts: [profile({ name: DRAFT_NAME })],
});
expect(result.current).toMatchObject({
profiles: [authoritative],
baseline: [authoritative],
selection: { kind: "custom", id: PROFILE_ID },
selectedName: "Server name",
saveStatus: "success",
error: null,
isDirty: false,
});
});
it("preserves the unsaved draft and reports a failed PATCH", async () => {
updateUserSettings.mockRejectedValueOnce(new Error("save failed"));
const original = profile();
const { result } = renderLayoutSettings([original]);
act(() => result.current.setSelection({ kind: "custom", id: PROFILE_ID }));
act(() => result.current.updateSelected({ name: DRAFT_NAME }));
await act(async () => {
await expect(result.current.save()).rejects.toThrow("save failed");
});
expect(result.current).toMatchObject({
profiles: [profile({ name: DRAFT_NAME })],
baseline: [original],
selectedName: DRAFT_NAME,
saveStatus: "error",
error: "save failed",
isDirty: true,
});
});
});
@@ -0,0 +1,327 @@
"use client";
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { useAppStore } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api";
import {
createLayoutProfileId,
deleteLayoutProfile,
duplicateLayoutProfile,
getBuiltInLayoutOverride,
getBuiltInLayoutOverrideSourceId,
getBuiltInLayoutProfile,
getLayoutProfileCompatibility,
resetBuiltInLayoutOverride,
resolveEffectiveDefaultLayout,
setDefaultLayoutProfile,
upsertBuiltInLayoutOverride,
validateReusableLayout,
} from "@/lib/layout/layout-profiles";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import type { LayoutState } from "@/lib/state/layout-manager";
import type { SavedLayout } from "@/lib/types/http";
import type { LayoutProfileSelection } from "./layout-profile-list";
export type SaveStatus = "idle" | "loading" | "success" | "error";
function defaultSelection(profiles: SavedLayout[]): LayoutProfileSelection {
const effective = resolveEffectiveDefaultLayout(profiles);
if (effective.source === "built-in") return { kind: "built-in", id: "default" };
const builtInId = getBuiltInLayoutOverrideSourceId(effective.profile);
return builtInId
? { kind: "built-in", id: builtInId }
: { kind: "custom", id: effective.profile.id };
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Failed to save layout profiles";
}
function getDefaultActionLabel(isCustomDefault: boolean, selectedIsDefault: boolean) {
if (isCustomDefault) return "Use built-in Default";
if (selectedIsDefault) return "Default";
return "Use as default";
}
function attempt(setError: Dispatch<SetStateAction<string | null>>, action: () => void) {
try {
action();
} catch (error) {
setError(errorMessage(error));
}
}
function useLayoutProfileDrafts(savedLayouts: SavedLayout[]) {
const [baseline, setBaseline] = useState(() => structuredClone(savedLayouts));
const [profiles, setProfiles] = useState(() => structuredClone(savedLayouts));
const [selection, setSelection] = useState<LayoutProfileSelection>(() =>
defaultSelection(savedLayouts),
);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [editorReset, setEditorReset] = useState(0);
const baselineKey = useMemo(() => JSON.stringify(baseline), [baseline]);
const profilesKey = useMemo(() => JSON.stringify(profiles), [profiles]);
const storeKey = useMemo(() => JSON.stringify(savedLayouts), [savedLayouts]);
const isDirty = baselineKey !== profilesKey;
useEffect(() => {
if (storeKey === baselineKey || isDirty) return;
const next = structuredClone(savedLayouts);
setBaseline(next);
setProfiles(structuredClone(next));
setSelection(defaultSelection(next));
}, [baselineKey, isDirty, savedLayouts, storeKey]);
const replaceProfiles = (next: SavedLayout[]) => {
setProfiles(next);
setSaveStatus("idle");
setError(null);
};
const cancel = () => {
setProfiles(structuredClone(baseline));
setSelection(defaultSelection(baseline));
setSaveStatus("idle");
setError(null);
setEditorReset((value) => value + 1);
};
return {
baseline,
profiles,
selection,
saveStatus,
error,
editorReset,
isDirty,
profilesKey,
setBaseline,
setProfiles,
setSelection,
setSaveStatus,
setError,
setEditorReset,
replaceProfiles,
cancel,
};
}
type Drafts = ReturnType<typeof useLayoutProfileDrafts>;
function selectedState(drafts: Drafts) {
const selectedCustom =
drafts.selection.kind === "custom"
? (drafts.profiles.find((profile) => profile.id === drafts.selection.id) ?? null)
: null;
const selectedBuiltIn =
drafts.selection.kind === "built-in" ? getBuiltInLayoutProfile(drafts.selection.id) : null;
const selectedBuiltInOverride = selectedBuiltIn
? getBuiltInLayoutOverride(drafts.profiles, selectedBuiltIn.id)
: null;
const editableProfile = selectedCustom ?? selectedBuiltInOverride;
const compatibility = editableProfile ? getLayoutProfileCompatibility(editableProfile) : null;
const editorLayout =
compatibility?.status === "editable" ? compatibility.layout : (selectedBuiltIn?.layout ?? null);
return {
selectedCustom,
selectedBuiltIn,
selectedBuiltInOverride,
compatibility,
editorLayout,
};
}
type SelectedState = ReturnType<typeof selectedState>;
function updateBuiltInDefault(drafts: Drafts, selected: SelectedState) {
if (!selected.selectedBuiltIn) return false;
if (selected.selectedBuiltIn.id === "default" || selected.selectedBuiltInOverride?.is_default) {
drafts.replaceProfiles(setDefaultLayoutProfile(drafts.profiles, null));
return true;
}
drafts.replaceProfiles(
upsertBuiltInLayoutOverride(
drafts.profiles,
selected.selectedBuiltIn.id,
selected.editorLayout ?? selected.selectedBuiltIn.layout,
{ createdAt: new Date().toISOString(), isDefault: true },
),
);
return true;
}
function useProfileActions(drafts: Drafts, selected: SelectedState) {
const selectedName = selected.selectedBuiltIn?.name ?? selected.selectedCustom?.name ?? "Layout";
const duplicate = () =>
attempt(drafts.setError, () => {
const id = createLayoutProfileId();
const source =
selected.selectedBuiltInOverride ?? selected.selectedBuiltIn ?? drafts.selection.id;
drafts.replaceProfiles(
duplicateLayoutProfile(drafts.profiles, source, {
id,
name: `${selectedName} copy`,
createdAt: new Date().toISOString(),
}),
);
drafts.setSelection({ kind: "custom", id });
});
const create = () =>
attempt(drafts.setError, () => {
const id = createLayoutProfileId();
drafts.replaceProfiles(
duplicateLayoutProfile(drafts.profiles, getBuiltInLayoutProfile("default"), {
id,
name: "Untitled layout",
createdAt: new Date().toISOString(),
}),
);
drafts.setSelection({ kind: "custom", id });
});
const updateSelected = (updates: Partial<SavedLayout>) => {
if (!selected.selectedCustom) return;
drafts.replaceProfiles(
drafts.profiles.map((profile) =>
profile.id === selected.selectedCustom?.id ? { ...profile, ...updates } : profile,
),
);
};
const updateLayout = (layout: LayoutState) => {
const validation = validateReusableLayout(layout);
if (!validation.valid) {
drafts.setError(validation.issues.map((issue) => issue.message).join(". "));
return;
}
const nextLayout = validation.layout as unknown as Record<string, unknown>;
if (selected.selectedCustom) {
updateSelected({ layout: nextLayout });
return;
}
const builtIn = selected.selectedBuiltIn;
if (!builtIn) return;
attempt(drafts.setError, () => {
const isDefault =
builtIn.id === "default"
? selected.selectedBuiltInOverride?.is_default === true ||
!drafts.profiles.some((profile) => profile.is_default)
: undefined;
drafts.replaceProfiles(
upsertBuiltInLayoutOverride(drafts.profiles, builtIn.id, nextLayout, {
createdAt: new Date().toISOString(),
isDefault,
}),
);
});
};
const setDefault = () =>
attempt(drafts.setError, () => {
if (updateBuiltInDefault(drafts, selected)) return;
const profileId =
drafts.selection.kind === "custom" && !selected.selectedCustom?.is_default
? drafts.selection.id
: null;
drafts.replaceProfiles(setDefaultLayoutProfile(drafts.profiles, profileId));
});
const deleteSelected = () =>
attempt(drafts.setError, () => {
if (!selected.selectedCustom) return;
drafts.replaceProfiles(deleteLayoutProfile(drafts.profiles, selected.selectedCustom.id));
drafts.setSelection({ kind: "built-in", id: "default" });
});
const resetBuiltIn = () => {
if (!selected.selectedBuiltInOverride || !selected.selectedBuiltIn) return;
drafts.replaceProfiles(
resetBuiltInLayoutOverride(drafts.profiles, selected.selectedBuiltIn.id),
);
drafts.setEditorReset((value) => value + 1);
};
return {
selectedName,
duplicate,
create,
updateSelected,
updateLayout,
setDefault,
deleteSelected,
resetBuiltIn,
};
}
function useSaveProfiles(drafts: Drafts) {
const setUserSettings = useAppStore((state) => state.setUserSettings);
return async () => {
if (!drafts.isDirty || drafts.saveStatus === "loading") return;
if (drafts.profiles.some((profile) => !profile.name.trim())) {
drafts.setError("Layout profile names must not be empty");
drafts.setSaveStatus("error");
return;
}
drafts.setSaveStatus("loading");
drafts.setError(null);
try {
const response = await updateUserSettings({ saved_layouts: drafts.profiles });
const authoritative = mapUserSettingsResponse(response);
const next = structuredClone(authoritative.savedLayouts);
setUserSettings(authoritative);
drafts.setBaseline(next);
drafts.setProfiles(structuredClone(next));
drafts.setSelection((current) =>
current.kind === "built-in" || next.some((profile) => profile.id === current.id)
? current
: defaultSelection(next),
);
drafts.setSaveStatus("success");
} catch (error) {
drafts.setSaveStatus("error");
drafts.setError(errorMessage(error));
throw error;
}
};
}
function isSelectedEffectiveDefault(
selection: LayoutProfileSelection,
effectiveDefault: ReturnType<typeof resolveEffectiveDefaultLayout>,
) {
if (effectiveDefault.source === "built-in") {
return selection.kind === "built-in" && selection.id === "default";
}
if (selection.kind === "custom") return effectiveDefault.profile.id === selection.id;
return getBuiltInLayoutOverrideSourceId(effectiveDefault.profile) === selection.id;
}
function getDefaultActionState(drafts: Drafts, selected: SelectedState) {
const effectiveDefault = resolveEffectiveDefaultLayout(drafts.profiles);
const selectedIsDefault = isSelectedEffectiveDefault(drafts.selection, effectiveDefault);
const selectedSavedDefault = Boolean(
selected.selectedCustom?.is_default || selected.selectedBuiltInOverride?.is_default,
);
const disabled =
(drafts.selection.kind === "built-in" && selectedIsDefault) ||
(selected.compatibility?.status === "legacy" && !selectedSavedDefault);
const builtInDefaultSelected =
drafts.selection.kind === "built-in" && drafts.selection.id === "default";
const label =
builtInDefaultSelected && selectedIsDefault
? "Default"
: getDefaultActionLabel(selectedSavedDefault, selectedIsDefault);
return { disabled, label, selectedIsDefault, selectedSavedDefault };
}
export function useLayoutSettings() {
const savedLayouts = useAppStore((state) => state.userSettings.savedLayouts);
const drafts = useLayoutProfileDrafts(savedLayouts);
const selected = selectedState(drafts);
const actions = useProfileActions(drafts, selected);
const save = useSaveProfiles(drafts);
const defaultAction = getDefaultActionState(drafts, selected);
return {
...drafts,
...selected,
...actions,
save,
defaultActionDisabled: defaultAction.disabled,
defaultActionLabel: defaultAction.label,
selectedIsDefault: defaultAction.selectedIsDefault,
selectedSavedDefault: defaultAction.selectedSavedDefault,
};
}
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useLayoutEffect, useRef, memo } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, memo } from "react";
import {
DockviewReact,
DockviewDefaultTab,
@@ -58,7 +58,8 @@ import type { Terminal } from "@/hooks/domains/session/use-terminals";
// Portal system
import { PanelPortalHost, usePortalSlot } from "@/lib/layout/panel-portal-host";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
import { normalizeReusableSessionPanels, type LayoutState } from "@/lib/state/layout-manager";
import { resolveEffectiveDefaultLayout } from "@/lib/layout/layout-profiles";
import type { LayoutState } from "@/lib/state/layout-manager";
// ---------------------------------------------------------------------------
// PORTAL SLOT — generic dockview component that adopts a persistent portal
@@ -151,22 +152,17 @@ function PermanentTab(props: IDockviewPanelHeaderProps) {
}
/** Sync the user's default saved layout from settings into the dockview store. */
function useSyncUserDefaultLayout() {
function useSyncUserDefaultLayout(): LayoutState | null {
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const setUserDefaultLayout = useDockviewStore((s) => s.setUserDefaultLayout);
const userDefaultLayout = useMemo(() => {
const effectiveDefault = resolveEffectiveDefaultLayout(savedLayouts);
return effectiveDefault.source === "custom" ? effectiveDefault.layout : null;
}, [savedLayouts]);
useEffect(() => {
const defaultLayout = savedLayouts.find((l) => l.is_default);
const state = defaultLayout?.layout as unknown as LayoutState | undefined;
// Drop the obsolete "sidebar" column: the dockview-embedded sidebar pane was
// retired for the unified AppSidebar, but a default layout saved before that
// change still carries it. The default-build path applies this layout
// without the restore-time sanitize layer, so an orphaned sidebar column
// (its panel component is no longer registered) renders a broken grid.
const columns = state?.columns?.filter((c) => c.id !== "sidebar");
setUserDefaultLayout(
columns && columns.length > 0 ? normalizeReusableSessionPanels({ ...state, columns }) : null,
);
}, [savedLayouts, setUserDefaultLayout]);
setUserDefaultLayout(userDefaultLayout);
}, [setUserDefaultLayout, userDefaultLayout]);
return userDefaultLayout;
}
const tabComponents: Record<string, React.FunctionComponent<IDockviewPanelHeaderProps>> = {
@@ -271,6 +267,7 @@ type ReadyDockviewLayoutSetup = {
buildDefaultLayout: (api: DockviewReadyEvent["api"], intentName?: string) => void;
compact: boolean;
initialLayout?: string | null;
userDefaultLayout: LayoutState | null;
};
type ReadyDockviewRefs = {
@@ -288,6 +285,9 @@ type ReadyDockviewSetup = {
};
function setupReadyDockview({ api, appStore, layout, refs }: ReadyDockviewSetup): void {
// Dockview can become ready before the parent passive effect synchronizes
// settings. Seed the store before exposing the API or building a cold layout.
useDockviewStore.getState().setUserDefaultLayout(layout.userDefaultLayout);
refs.setApi(api);
const currentEnvId = refs.envIdRef.current;
@@ -369,7 +369,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
const review = useReviewDialog(effectiveSessionId);
useSyncUserDefaultLayout();
const userDefaultLayout = useSyncUserDefaultLayout();
useLspFileOpener();
useEditorKeybinds();
usePlanPanelAutoOpen();
@@ -391,11 +391,11 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
setupReadyDockview({
api: event.api,
appStore,
layout: { buildDefaultLayout, compact, initialLayout },
layout: { buildDefaultLayout, compact, initialLayout, userDefaultLayout },
refs: { envIdRef, readyDisposersRef, saveTimerRef, setApi },
});
},
[setApi, buildDefaultLayout, initialLayout, compact, appStore],
[setApi, buildDefaultLayout, initialLayout, compact, userDefaultLayout, appStore],
);
// Release session-scoped portals + trigger layout switch on session change.
@@ -2,16 +2,13 @@
import React, { useCallback, useEffect } from "react";
import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useActiveTaskHasRepos } from "@/hooks/domains/kanban/use-active-task-has-repos";
import { useAppStore } from "@/components/state-provider";
import { useSessionChangesCount } from "@/hooks/domains/session/use-session-changes-count";
import type { ReviewSource } from "@/hooks/domains/session/use-review-sources";
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
import { useFileEditors } from "@/hooks/use-file-editors";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
import { useDockviewStore } from "@/lib/state/dockview-store";
import type { AppState } from "@/lib/state/store";
import { BrowserPanel } from "./browser-panel";
import type { OpenDiffOptions } from "./changes-diff-target";
import { ChangesPanel } from "./changes-panel";
@@ -26,8 +23,6 @@ import { TaskPlanPanel } from "./task-plan-panel";
import { TerminalPanel } from "./terminal-panel";
import { VscodePanel } from "./vscode-panel";
const debugChangesVisibility = createDebugLogger("changes:visibility");
export const CHAT_PANEL_FALLBACK_LABEL = "Agent";
export function resolveChatPanelTitle(agentLabel: string | null | undefined): string {
@@ -121,75 +116,17 @@ function DiffViewerContent({
);
}
function describeTaskRepositoriesForDebug(state: AppState, taskId: string | null) {
if (!taskId) return { source: "none", repositoryId: "-", repoCount: -1, repoIds: "-" };
const task = state.kanban.tasks.find((item) => item.id === taskId);
if (task) {
return {
source: "kanban",
repositoryId: task.repositoryId ?? "-",
repoCount: task.repositories?.length ?? -1,
repoIds: task.repositories?.map((repo) => repo.repository_id).join(",") || "-",
};
}
for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) {
const snapshotTask = snapshot.tasks.find((item) => item.id === taskId);
if (!snapshotTask) continue;
return {
source: `kanbanMulti:${workflowId}`,
repositoryId: snapshotTask.repositoryId ?? "-",
repoCount: snapshotTask.repositories?.length ?? -1,
repoIds: snapshotTask.repositories?.map((repo) => repo.repository_id).join(",") || "-",
};
}
return { source: "missing", repositoryId: "-", repoCount: -1, repoIds: "-" };
}
function ChangesContent({ panelId }: { panelId: string }) {
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel);
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
const { openFile } = useFileEditors();
const appStore = useAppStoreApi();
// Dynamic title with file count - use environment-stable sessionId so the
// tab title doesn't re-fetch on same-environment session tab switches.
const activeSessionId = useEnvironmentSessionId();
const totalCount = useSessionChangesCount(activeSessionId);
// Repo-less tasks have no git changes ever - auto-close the panel so users
// don't see a permanently empty Changes tab. Gate on a confirmed `false`:
// `null` means the task hasn't loaded yet, and removing the panel during
// that window is unrecoverable in the same session.
const taskHasRepos = useActiveTaskHasRepos();
useEffect(() => {
const dockApi = useDockviewStore.getState().api;
const panel = dockApi?.getPanel(panelId);
if (isDebug()) {
const state = appStore.getState();
const activeTaskId = state.tasks.activeTaskId;
const repoDebug = describeTaskRepositoriesForDebug(state, activeTaskId);
let action = "keep";
if (taskHasRepos === false) {
action = panel ? "remove" : "remove-missing-panel";
}
debugChangesVisibility("auto-close decision", {
panelId,
taskHasRepos: taskHasRepos === null ? "unknown" : String(taskHasRepos),
action,
activeTaskId: activeTaskId ?? "-",
sessionId: state.tasks.activeSessionId ?? "-",
taskSource: repoDebug.source,
repositoryId: repoDebug.repositoryId,
repoCount: repoDebug.repoCount,
repoIds: repoDebug.repoIds,
livePanelIds: dockApi?.panels.map((p) => p.id).join(",") ?? "-",
});
}
if (taskHasRepos !== false) return;
if (dockApi && panel) dockApi.removePanel(panel);
}, [taskHasRepos, panelId, appStore]);
useEffect(() => {
const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes";
setPanelTitle(panelId, title);
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { toast } from "sonner";
import {
IconLayout,
IconCheck,
@@ -9,12 +10,23 @@ import {
IconFileText,
IconDeviceDesktop,
IconBrandVscode,
IconRestore,
IconTrash,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Checkbox } from "@kandev/ui/checkbox";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import {
Dialog,
DialogContent,
@@ -34,46 +46,48 @@ import {
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Badge } from "@kandev/ui/badge";
import { useDockviewStore, type BuiltInPreset } from "@/lib/state/dockview-store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import {
BUILT_IN_LAYOUT_PROFILES,
createLayoutProfile,
createLayoutProfileId,
deleteLayoutProfile,
getBuiltInLayoutOverride,
getLayoutProfileCompatibility,
isBuiltInLayoutOverride,
type BuiltInLayoutProfileId,
} from "@/lib/layout/layout-profiles";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import type { SavedLayout } from "@/lib/types/http";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { resolveLayoutApplySessionIds } from "./layout-preset-selector-session-ids";
type PresetOption = {
id: BuiltInPreset;
id: BuiltInLayoutProfileId;
label: string;
description: string;
icon: React.ElementType;
};
const BUILT_IN_PRESETS: PresetOption[] = [
{
id: "default",
label: "Default",
description: "Sidebar + Agent + Changes/Files/Terminal",
icon: IconColumns3,
},
{
id: "plan",
label: "Plan Mode",
description: "Sidebar + Agent + Plan (side-by-side)",
icon: IconFileText,
},
{
id: "preview",
label: "Preview Mode",
description: "Sidebar + Agent + Browser (side-by-side)",
icon: IconDeviceDesktop,
},
{
id: "vscode",
label: "VS Code",
description: "Sidebar + Agent/VS Code (tabbed)",
icon: IconBrandVscode,
},
];
const PRESET_ICONS: Record<BuiltInLayoutProfileId, React.ElementType> = {
default: IconColumns3,
plan: IconFileText,
preview: IconDeviceDesktop,
vscode: IconBrandVscode,
};
const BUILT_IN_PRESETS: PresetOption[] = BUILT_IN_LAYOUT_PROFILES.map((profile) => ({
id: profile.id,
label: profile.name,
description: profile.description,
icon: PRESET_ICONS[profile.id],
}));
function mutationError(error: unknown): string {
return error instanceof Error ? error.message : "Please try again.";
}
function SaveLayoutDialog({
open,
@@ -87,6 +101,7 @@ function SaveLayoutDialog({
const [saving, setSaving] = useState(false);
const captureCurrentLayout = useDockviewStore((s) => s.captureCurrentLayout);
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const setUserSettings = useAppStore((s) => s.setUserSettings);
const handleSave = useCallback(async () => {
const trimmed = name.trim();
@@ -94,25 +109,25 @@ function SaveLayoutDialog({
setSaving(true);
try {
const layout = captureCurrentLayout();
const newLayout: SavedLayout = {
id: `layout-${Date.now()}`,
name: trimmed,
is_default: isDefault,
layout,
created_at: new Date().toISOString(),
};
// If this layout is the default, unset other defaults
const updated = savedLayouts.map((l) => (isDefault ? { ...l, is_default: false } : l));
await updateUserSettings({
saved_layouts: [...updated, newLayout],
const response = await updateUserSettings({
saved_layouts: createLayoutProfile(savedLayouts, {
id: createLayoutProfileId(),
name: trimmed,
isDefault,
layout,
createdAt: new Date().toISOString(),
}),
});
setUserSettings(mapUserSettingsResponse(response));
setName("");
setIsDefault(false);
onOpenChange(false);
} catch (error) {
toast.error("Failed to save layout", { description: mutationError(error) });
} finally {
setSaving(false);
}
}, [name, isDefault, captureCurrentLayout, savedLayouts, onOpenChange]);
}, [name, isDefault, captureCurrentLayout, savedLayouts, setUserSettings, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -175,43 +190,41 @@ function SavedLayoutItems({
}: {
layouts: SavedLayout[];
onApply: (layout: SavedLayout) => void | Promise<void>;
onDelete: (layoutId: string) => void;
onDelete: (layout: SavedLayout) => void;
}) {
if (layouts.length === 0) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">No saved layouts</div>;
}
return layouts.map((layout) => (
<DropdownMenuItem
key={layout.id}
className="cursor-pointer group/layout"
onClick={() => {
void onApply(layout);
}}
>
<IconCheck className="h-3.5 w-3.5 mr-2 shrink-0 opacity-0" />
<span className="text-xs truncate flex-1">{layout.name}</span>
{layout.is_default && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 ml-1">
default
</Badge>
)}
<button
type="button"
className="ml-1 text-destructive/60 hover:text-destructive opacity-0 group-hover/layout:opacity-100 transition-opacity cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(layout.id);
}}
<div key={layout.id} className="flex items-stretch" role="presentation">
<DropdownMenuItem
className="min-w-0 flex-1 cursor-pointer"
onSelect={() => void onApply(layout)}
>
<IconCheck className="mr-2 h-3.5 w-3.5 shrink-0 opacity-0" />
<span className="flex-1 truncate text-xs">{layout.name}</span>
{layout.is_default && (
<Badge variant="secondary" className="ml-1 px-1 py-0 text-[9px]">
default
</Badge>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 min-w-11 shrink-0 cursor-pointer justify-center px-2 text-destructive/60 focus:text-destructive sm:min-h-7 sm:min-w-7"
aria-label={`Delete ${layout.name}`}
data-testid="layout-saved-delete"
data-layout-id={layout.id}
onSelect={() => onDelete(layout)}
>
<IconTrash className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
</DropdownMenuItem>
</div>
));
}
/** The built-in preset menu items. Applying any preset resets widths
* (resetWidths=true), which is how a user clears a custom sidebar width. */
function BuiltInPresetItems({ onApply }: { onApply: (presetId: BuiltInPreset) => void }) {
/** Built-in preset menu items. Code-defined presets reset widths; customized
* built-ins restore the dimensions saved in their override. */
function BuiltInPresetItems({ onApply }: { onApply: (presetId: BuiltInLayoutProfileId) => void }) {
return BUILT_IN_PRESETS.map((preset) => {
const Icon = preset.icon;
return (
@@ -264,20 +277,97 @@ function useApplySavedLayout() {
);
}
function useApplyBuiltInLayout(
savedLayouts: SavedLayout[],
applySavedLayout: (layout: SavedLayout) => Promise<void>,
) {
const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset);
return useCallback(
(presetId: BuiltInLayoutProfileId) => {
const override = getBuiltInLayoutOverride(savedLayouts, presetId);
if (override && getLayoutProfileCompatibility(override).status === "editable") {
void applySavedLayout(override);
return;
}
applyBuiltInPreset(presetId, true);
},
[applyBuiltInPreset, applySavedLayout, savedLayouts],
);
}
function DeleteLayoutDialog({
layout,
onClose,
onDelete,
}: {
layout: SavedLayout | null;
onClose: () => void;
onDelete: (layoutId: string) => Promise<void>;
}) {
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!layout) return;
setDeleting(true);
try {
await onDelete(layout.id);
onClose();
} catch (error) {
toast.error("Failed to delete layout", { description: mutationError(error) });
} finally {
setDeleting(false);
}
};
return (
<AlertDialog open={Boolean(layout)} onOpenChange={(open) => !open && !deleting && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {layout?.name ?? "saved layout"}?</AlertDialogTitle>
<AlertDialogDescription>
{layout?.is_default
? "The built-in Default layout will become the default."
: "This saved layout will be permanently removed."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer" disabled={deleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function LayoutPresetSelector() {
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const [deleteCandidate, setDeleteCandidate] = useState<SavedLayout | null>(null);
const recentlyClosedRef = useRef(false);
const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset);
const resetLayout = useDockviewStore((s) => s.resetLayout);
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const setUserSettings = useAppStore((s) => s.setUserSettings);
const handleApplyCustom = useApplySavedLayout();
const handleApplyBuiltIn = useApplyBuiltInLayout(savedLayouts, handleApplyCustom);
const handleDeleteLayout = useCallback(
async (layoutId: string) => {
await updateUserSettings({ saved_layouts: savedLayouts.filter((l) => l.id !== layoutId) });
const response = await updateUserSettings({
saved_layouts: deleteLayoutProfile(savedLayouts, layoutId),
});
setUserSettings(mapUserSettingsResponse(response));
},
[savedLayouts],
[savedLayouts, setUserSettings],
);
const handleDropdownOpenChange = useCallback((open: boolean) => {
@@ -320,15 +410,24 @@ export function LayoutPresetSelector() {
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">Presets</DropdownMenuLabel>
<BuiltInPresetItems onApply={(id) => applyBuiltInPreset(id, true)} />
<BuiltInPresetItems onApply={handleApplyBuiltIn} />
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
data-testid="layout-reset-item"
onClick={resetLayout}
className="cursor-pointer"
>
<IconRestore className="h-4 w-4 mr-2 shrink-0" />
<span className="text-xs">Reset Layout</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">Saved Layouts</DropdownMenuLabel>
<SavedLayoutItems
layouts={savedLayouts}
layouts={savedLayouts.filter((layout) => !isBuiltInLayoutOverride(layout))}
onApply={handleApplyCustom}
onDelete={handleDeleteLayout}
onDelete={setDeleteCandidate}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -339,6 +438,11 @@ export function LayoutPresetSelector() {
</DropdownMenuContent>
</DropdownMenu>
<SaveLayoutDialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen} />
<DeleteLayoutDialog
layout={deleteCandidate}
onClose={() => setDeleteCandidate(null)}
onDelete={handleDeleteLayout}
/>
</>
);
}
+2
View File
@@ -171,6 +171,7 @@ export const test = backendFixture.extend<
confirm_task_archive: true,
mcp_task_agent_profile_default: "current_task",
sidebar_views: [],
saved_layouts: [],
task_create_last_used: {
repository_id: seedData.repositoryId,
branch: "main",
@@ -241,6 +242,7 @@ test.beforeEach(async ({ apiClient, seedData }) => {
confirm_task_archive: true,
mcp_task_agent_profile_default: "current_task",
sidebar_views: [],
saved_layouts: [],
kanban_view_mode: "",
task_create_last_used: {
repository_id: seedData.repositoryId,
@@ -0,0 +1,76 @@
import { expect, type Locator, type Page } from "@playwright/test";
export class LayoutSettingsPage {
readonly root: Locator;
readonly editor: Locator;
readonly actions: Locator;
constructor(private readonly page: Page) {
this.root = page.getByTestId("layout-settings");
this.editor = page.getByTestId("layout-editor");
this.actions = page.getByTestId("layout-editor-context-actions");
}
async open(): Promise<void> {
await this.page.goto("/settings/general/layouts");
await expect(this.root).toBeVisible();
}
async openFromMobileMenu(): Promise<void> {
await this.page.goto("/settings/general/terminal");
await this.page.getByTestId("settings-mobile-menu-button").click();
const menu = this.page.getByTestId("settings-mobile-menu");
await expect(menu).toBeVisible();
await menu.getByRole("link", { name: "Layouts", exact: true }).click();
await expect(this.page).toHaveURL(/\/settings\/general\/layouts$/);
await expect(this.root).toBeVisible();
}
async duplicateDefault(name: string): Promise<void> {
await this.page.getByTestId("layout-profile-built-in-default").click();
await this.page.getByTestId("layout-profile-duplicate").click();
const nameInput = this.page.getByRole("textbox", { name: "Layout profile name" });
await expect(nameInput).toBeVisible();
await nameInput.fill(name);
await expect(this.actions).toBeVisible();
}
async selectPanel(name: string): Promise<void> {
await this.editor.locator(".dv-tab", { hasText: name }).click();
await expect(this.actions).toHaveAccessibleName(`Actions for ${name}`);
}
async removePanel(name: string): Promise<void> {
await this.selectPanel(name);
const button = this.actions.getByRole("button", { name: "Remove panel" });
await expect(button).toBeEnabled();
await button.click();
await expect(
this.page
.getByTestId("layout-profile-built-in-default")
.getByText("Customized", { exact: true }),
).toBeVisible();
}
async renameSelected(name: string): Promise<void> {
const input = this.page.getByRole("textbox", { name: "Layout profile name" });
await expect(input).toBeVisible();
await input.fill(name);
}
async moveSelectedTabRight(): Promise<void> {
const button = this.actions.getByRole("button", { name: "Move tab right" });
await expect(button).toBeEnabled();
await button.click();
}
async save(): Promise<void> {
const response = this.page.waitForResponse(
(candidate) =>
candidate.url().includes("/api/v1/user/settings") &&
candidate.request().method() === "PATCH",
);
await this.page.getByRole("button", { name: "Save changes" }).click();
expect((await response).ok()).toBe(true);
}
}
@@ -123,7 +123,7 @@ test.describe("saved Dockview layouts", () => {
expect(await dockviewPanelIds(testPage)).not.toContain(`session:${taskA.sessionId}`);
await testPage.getByTestId("layout-preset-trigger").click();
await testPage.getByRole("menuitem", { name: /Simple/ }).click();
await testPage.getByRole("menuitem", { name: "Simple", exact: true }).click();
await expect
.poll(() => dockviewPanelIds(testPage), {
@@ -0,0 +1,254 @@
import { expect, type Page } from "@playwright/test";
import { test, type SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { LayoutSettingsPage } from "../../pages/layout-settings-page";
import { SessionPage } from "../../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
type DockviewSnapshot = {
panelIds: string[];
filesGroupId: string | null;
changesGroupId: string | null;
rightGroupOrder: string[];
};
function noTerminalLayout() {
return {
columns: [
{
id: "center",
groups: [
{
id: "group-center",
panels: [
{
id: "chat",
component: "chat",
title: "Agent",
tabComponent: "permanentTab",
},
],
activePanel: "chat",
},
],
},
{
id: "right",
pinned: true,
width: 350,
groups: [
{
id: "group-right-top",
panels: [
{ id: "files", component: "files", title: "Files" },
{
id: "changes",
component: "changes",
title: "Changes",
tabComponent: "changesTab",
},
],
activePanel: "files",
},
],
},
],
};
}
async function createTaskWithSession(apiClient: ApiClient, seedData: SeedData, title: string) {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error(`${title} did not return a session_id`);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 45_000, message: `Waiting for ${title} session to settle` },
)
.toBe(true);
return task;
}
async function openTask(page: Page, taskId: string): Promise<SessionPage> {
await page.goto(`/t/${taskId}`);
const session = new SessionPage(page);
await session.waitForLoad();
await session.waitForDockviewReady();
return session;
}
async function dockviewSnapshot(page: Page): Promise<DockviewSnapshot> {
return page.evaluate(() => {
type Panel = { id: string; group?: { id: string; panels: Panel[] } };
type Api = { panels: Panel[]; getPanel: (id: string) => Panel | undefined };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
if (!api) throw new Error("dockview api not exposed");
const files = api.getPanel("files");
const changes = api.getPanel("changes");
return {
panelIds: api.panels.map((panel) => panel.id),
filesGroupId: files?.group?.id ?? null,
changesGroupId: changes?.group?.id ?? null,
rightGroupOrder: files?.group?.panels.map((panel) => panel.id) ?? [],
};
});
}
async function expectNoTerminalDefault(page: Page): Promise<void> {
await expect
.poll(() => dockviewSnapshot(page), {
timeout: 10_000,
message: "Waiting for the no-terminal default layout",
})
.toMatchObject({
filesGroupId: "group-right-top",
changesGroupId: "group-right-top",
rightGroupOrder: ["files", "changes"],
});
const snapshot = await dockviewSnapshot(page);
expect(snapshot.panelIds).not.toContain("terminal-default");
}
async function ordinaryShells(apiClient: ApiClient, taskId: string) {
const { sessions } = await apiClient.listTaskSessions(taskId);
const environmentId = sessions[0]?.task_environment_id;
if (!environmentId) throw new Error(`Task ${taskId} has no environment`);
const response = await apiClient.wsRequest<{ shells?: Array<{ kind?: string }> }>(
"user_shell.list",
{
task_id: taskId,
task_environment_id: environmentId,
include_parked: true,
},
);
return (response.shells ?? []).filter((shell) => shell.kind === "ordinary");
}
test.describe("Task layout profile defaults", () => {
test("edits and saves the built-in Default without duplicating it first", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const layouts = new LayoutSettingsPage(testPage);
await layouts.open();
await testPage.getByRole("button", { name: "Add panel" }).hover();
await expect(
testPage.getByRole("tooltip", {
name: "Add a missing tool tab beside the selected split.",
}),
).toBeVisible();
await layouts.selectPanel("Terminal");
await layouts.actions.getByRole("button", { name: "Remove panel" }).hover();
await expect(
testPage.getByRole("tooltip", { name: "Remove this tab from the saved layout." }),
).toBeVisible();
await layouts.removePanel("Terminal");
await expect(testPage.getByRole("textbox", { name: "Layout profile name" })).toHaveCount(0);
await expect(testPage.getByText("No custom profiles", { exact: true })).toBeVisible();
await layouts.save();
const response = await apiClient.getUserSettings();
const saved = response.settings.saved_layouts;
expect(saved).toHaveLength(1);
expect(saved[0]).toMatchObject({
id: "layout-override-default",
name: "Default",
is_default: true,
});
expect(JSON.stringify(saved[0].layout)).not.toContain("terminal-default");
const task = await createTaskWithSession(apiClient, seedData, "Hidden Layout Override Task");
await openTask(testPage, task.id);
await testPage.getByTestId("layout-preset-trigger").click();
await expect(
testPage.locator(
'[data-testid="layout-saved-delete"][data-layout-id="layout-override-default"]',
),
).toHaveCount(0);
await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="default"]').click();
await expect(testPage.getByTestId("terminal-panel")).toHaveCount(0);
});
test("resets a customized built-in layout to its original definition", async ({
testPage,
apiClient,
}) => {
const layouts = new LayoutSettingsPage(testPage);
await layouts.open();
await layouts.removePanel("Terminal");
await layouts.save();
await testPage.getByRole("button", { name: "Reset built-in layout" }).click();
await expect(testPage.getByText("Customized", { exact: true })).toHaveCount(0);
await expect(layouts.editor.locator(".dv-tab", { hasText: "Terminal" })).toBeVisible();
await layouts.save();
expect((await apiClient.getUserSettings()).settings.saved_layouts).toEqual([]);
});
test("fresh tasks use the no-terminal default while existing tasks wait for Reset Layout", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const taskA = await createTaskWithSession(apiClient, seedData, "Existing Layout Task");
await openTask(testPage, taskA.id);
await testPage.getByTestId("layout-preset-trigger").click();
await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="default"]').click();
await expect(testPage.getByTestId("terminal-panel")).toBeVisible({ timeout: 15_000 });
await testPage.waitForTimeout(500);
await apiClient.saveUserSettings({
saved_layouts: [
{
id: "focused-default",
name: "Focused default",
is_default: true,
layout: noTerminalLayout(),
created_at: new Date().toISOString(),
},
],
});
const taskB = await createTaskWithSession(apiClient, seedData, "Fresh Layout Task");
await openTask(testPage, taskB.id);
await expectNoTerminalDefault(testPage);
await testPage.waitForTimeout(500);
expect(await ordinaryShells(apiClient, taskB.id)).toHaveLength(0);
await openTask(testPage, taskA.id);
await expect(testPage.getByTestId("terminal-panel")).toBeVisible({ timeout: 15_000 });
await testPage.getByTestId("layout-preset-trigger").click();
await testPage.getByTestId("layout-reset-item").click();
await expectNoTerminalDefault(testPage);
await openTask(testPage, taskA.id);
await testPage.getByTestId("layout-preset-trigger").click();
await testPage
.locator('[data-testid="layout-saved-delete"][data-layout-id="focused-default"]')
.click({ force: true });
await expect(testPage.getByRole("alertdialog")).toContainText(
"The built-in Default layout will become the default.",
);
await testPage.getByRole("button", { name: "Cancel" }).click();
expect((await apiClient.getUserSettings()).settings.saved_layouts).toHaveLength(1);
});
});
@@ -0,0 +1,51 @@
import { test, expect } from "../../fixtures/test-base";
import {
assertNoDescendantOverflowsRight,
assertNoDocumentHorizontalOverflow,
} from "../../helpers/layout-assertions";
import { LayoutSettingsPage } from "../../pages/layout-settings-page";
type SavedProfile = {
id: string;
name: string;
is_default: boolean;
layout: {
columns: Array<{
groups: Array<{ panels: Array<{ id: string }> }>;
}>;
};
};
test.describe("Mobile layout profiles", () => {
test("edits and reorders a profile without horizontal overflow", async ({
testPage,
apiClient,
}) => {
const layouts = new LayoutSettingsPage(testPage);
await layouts.openFromMobileMenu();
await assertNoDocumentHorizontalOverflow(testPage, "layouts page");
await assertNoDescendantOverflowsRight(layouts.root, "layouts settings");
await layouts.selectPanel("Files");
await layouts.moveSelectedTabRight();
await layouts.save();
await expect
.poll(async () => (await apiClient.getUserSettings()).settings.saved_layouts, {
timeout: 10_000,
message: "Waiting for the reordered layout profile to persist",
})
.toHaveLength(1);
const response = await apiClient.getUserSettings();
const profile = (response.settings.saved_layouts as SavedProfile[])[0];
expect(profile).toMatchObject({ id: "layout-override-default", name: "Default" });
expect(profile).toMatchObject({ is_default: true });
const filesGroup = profile.layout.columns
.flatMap((column) => column.groups)
.find((group) => group.panels.some((panel) => panel.id === "files"));
expect(filesGroup?.panels.map((panel) => panel.id)).toEqual(["changes", "files"]);
await assertNoDocumentHorizontalOverflow(testPage, "edited layouts page");
await assertNoDescendantOverflowsRight(layouts.root, "edited layouts settings");
});
});
@@ -8,6 +8,40 @@ import { SessionPage } from "../../pages/session-page";
// placeholder before adding the session panel, which destroyed the center
// group and collapsed the horizontal split into a single vertical column.
test.describe("Task default layout shape", () => {
test("repo-less tasks keep every panel in the built-in default layout", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Repo-less Default Layout",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForDockviewReady();
await expect
.poll(
() =>
testPage.evaluate(() => {
type Api = { panels: Array<{ id: string }> };
const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__;
return api?.panels.map((panel) => panel.id).sort() ?? [];
}),
{ message: "Waiting for every built-in default panel" },
)
.toEqual(["changes", "files", `session:${task.session_id}`, "terminal-default"]);
});
test("entering a task is horizontal (right column beside chat, not stacked)", async ({
testPage,
apiClient,
@@ -1,35 +0,0 @@
import { describe, expect, it } from "vitest";
import { taskHasRepositories } from "./use-active-task-has-repos";
describe("taskHasRepositories", () => {
it("treats legacy repositoryId as a repo association", () => {
expect(taskHasRepositories({ repositoryId: "repo-a" })).toBe(true);
});
it("treats a non-empty repositories array as a repo association", () => {
expect(
taskHasRepositories({
repositories: [
{
id: "link-a",
repository_id: "repo-a",
base_branch: "main",
position: 0,
},
],
}),
).toBe(true);
});
it("returns false when both repository fields are absent", () => {
expect(taskHasRepositories({})).toBe(false);
});
it("returns false for an empty repositories array with no repositoryId", () => {
expect(taskHasRepositories({ repositories: [] })).toBe(false);
});
it("returns false for null input", () => {
expect(taskHasRepositories(null)).toBe(false);
});
});
@@ -1,28 +0,0 @@
import { useAppStore } from "@/components/state-provider";
import type { KanbanState } from "@/lib/state/slices";
import { useTask } from "@/hooks/use-task";
type RepoBearingTask = Pick<KanbanState["tasks"][number], "repositories" | "repositoryId">;
export function taskHasRepositories(task: RepoBearingTask | null | undefined): boolean {
return Boolean(task?.repositoryId || (task?.repositories && task.repositories.length > 0));
}
/**
* Repo-status of the active task, distinguishing three states:
* - `null` — unknown (no active task, or task hasn't loaded into the
* store yet); callers must not act on this.
* - `true` — task has at least one repository.
* - `false` — task is confirmed repo-less.
*
* Returning a boolean would conflate "loading" with "no repos", which would
* cause auto-close effects (e.g. the Changes panel) to tear panels down on
* first render before the task data arrives — `removePanel` is permanent
* within a session, so the user couldn't recover them.
*/
export function useActiveTaskHasRepos(): boolean | null {
const taskId = useAppStore((s) => s.tasks.activeTaskId);
const task = useTask(taskId);
if (!task) return null;
return taskHasRepositories(task);
}
@@ -0,0 +1,70 @@
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { appState, createUserShell, dockviewApi } = vi.hoisted(() => ({
appState: {
tasks: { activeSessionId: "session-one", activeTaskId: "task-one" },
environmentIdBySessionId: { "session-one": "env-one" },
userShells: {
loaded: { "env-one": true },
byEnvironmentId: { "env-one": [] },
},
addUserShell: vi.fn(),
},
createUserShell: vi.fn(),
dockviewApi: { getPanel: vi.fn() },
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof appState) => unknown) => selector(appState),
}));
vi.mock("@/lib/state/dockview-store", () => ({
useDockviewStore: (selector: (state: { api: typeof dockviewApi }) => unknown) =>
selector({ api: dockviewApi }),
}));
vi.mock("@/lib/api/domains/user-shell-api", () => ({ createUserShell }));
import { useEnsureDefaultTerminalOrdinary } from "./use-ensure-default-terminal-ordinary";
describe("useEnsureDefaultTerminalOrdinary", () => {
beforeEach(() => {
vi.clearAllMocks();
dockviewApi.getPanel.mockReturnValue(undefined);
});
it("does not create a shell when the effective layout omits terminal-default", () => {
renderHook(() => useEnsureDefaultTerminalOrdinary());
expect(dockviewApi.getPanel).toHaveBeenCalledWith("terminal-default");
expect(createUserShell).not.toHaveBeenCalled();
expect(appState.addUserShell).not.toHaveBeenCalled();
});
it("still migrates the default terminal panel to an ordinary shell", async () => {
const updateParameters = vi.fn();
dockviewApi.getPanel.mockReturnValue({
params: { terminalId: "shell-default" },
api: { updateParameters },
});
createUserShell.mockResolvedValue({
terminalId: "terminal-one",
kind: "ordinary",
seq: 1,
displayName: "Terminal 1",
state: "open",
ptyStatus: "running",
});
renderHook(() => useEnsureDefaultTerminalOrdinary());
await waitFor(() => expect(createUserShell).toHaveBeenCalledTimes(1));
expect(createUserShell).toHaveBeenCalledWith("env-one", { taskId: "task-one" });
expect(updateParameters).toHaveBeenCalledWith({
terminalId: "terminal-one",
environmentId: "env-one",
taskID: "task-one",
});
});
});
+446
View File
@@ -0,0 +1,446 @@
import { describe, expect, it } from "vitest";
import type { SavedLayout } from "@/lib/types/http";
import { panel, type LayoutState } from "@/lib/state/layout-manager";
import {
BUILT_IN_LAYOUT_PROFILES,
createLayoutProfile,
createLayoutProfileId,
deleteLayoutProfile,
duplicateLayoutProfile,
getBuiltInLayoutOverride,
getBuiltInLayoutOverrideId,
getBuiltInLayoutProfile,
getLayoutProfileCompatibility,
isBuiltInLayoutOverride,
renameLayoutProfile,
resetBuiltInLayoutOverride,
resolveEffectiveDefaultLayout,
setDefaultLayoutProfile,
upsertBuiltInLayoutOverride,
validateReusableLayout,
} from "./layout-profiles";
const DEFAULT_LAYOUT_ID = "default";
const CREATED_AT = "2026-07-19T11:00:00.000Z";
const FIRST_LAYOUT_ID = "layout-one";
const SECOND_LAYOUT_ID = "layout-two";
const CENTER_COLUMN_ID = "center";
const LEGACY_LAYOUT_VALUE = "old-format";
const SESSION_PANEL_ID = "session:session-one";
function reusableLayout(panelIds: string[] = ["chat", "files", "changes"]): LayoutState {
return {
columns: [
{
id: CENTER_COLUMN_ID,
groups: [
{
id: "group-center",
panels: panelIds.map(panel),
activePanel: panelIds[0],
},
],
},
],
};
}
function savedLayout(overrides: Partial<SavedLayout> = {}): SavedLayout {
return {
id: FIRST_LAYOUT_ID,
name: "Layout one",
is_default: false,
layout: reusableLayout(),
created_at: "2026-07-19T10:00:00.000Z",
...overrides,
};
}
describe("built-in layout profiles", () => {
it("exposes the four reusable built-in templates", () => {
expect(BUILT_IN_LAYOUT_PROFILES.map(({ id, name }) => ({ id, name }))).toEqual([
{ id: DEFAULT_LAYOUT_ID, name: "Default" },
{ id: "plan", name: "Plan Mode" },
{ id: "preview", name: "Preview Mode" },
{ id: "vscode", name: "VS Code" },
]);
});
it("describes the current workbench without the retired embedded sidebar", () => {
expect(BUILT_IN_LAYOUT_PROFILES.map(({ description }) => description)).not.toContainEqual(
expect.stringContaining("Sidebar"),
);
});
it("returns a fresh template layout for each request", () => {
const first = getBuiltInLayoutProfile(DEFAULT_LAYOUT_ID);
first.layout.columns[0].groups[0].panels.length = 0;
expect(
getBuiltInLayoutProfile(DEFAULT_LAYOUT_ID).layout.columns[0].groups[0].panels,
).toHaveLength(1);
});
});
describe("validateReusableLayout", () => {
it("accepts one Agent and unique reusable optional panels", () => {
const result = validateReusableLayout(reusableLayout());
expect(result).toMatchObject({ valid: true, issues: [] });
});
it("normalizes a saved session Agent without changing the input", () => {
const input = reusableLayout();
input.columns[0].groups[0].panels[0] = {
id: SESSION_PANEL_ID,
component: "chat",
title: "Agent",
tabComponent: "sessionTab",
params: { sessionId: "session-one" },
};
input.columns[0].groups[0].activePanel = SESSION_PANEL_ID;
const snapshot = structuredClone(input);
const result = validateReusableLayout(input);
expect(result.valid && result.layout.columns[0].groups[0]).toMatchObject({
activePanel: "chat",
panels: expect.arrayContaining([expect.objectContaining({ id: "chat", component: "chat" })]),
});
expect(input).toEqual(snapshot);
});
it.each([
{
name: "a missing Agent",
layout: reusableLayout(["files"]),
code: "missing-agent",
},
{
name: "a duplicate reusable panel",
layout: reusableLayout(["chat", "files", "files"]),
code: "duplicate-panel",
},
{
name: "an unsupported panel",
layout: reusableLayout(["chat", "pr-detail"]),
code: "unsupported-panel",
},
{
name: "an empty group",
layout: {
columns: [{ id: CENTER_COLUMN_ID, groups: [{ panels: [panel("chat")] }, { panels: [] }] }],
},
code: "empty-group",
},
{
name: "an active tab outside its group",
layout: {
columns: [
{
id: CENTER_COLUMN_ID,
groups: [{ panels: [panel("chat")], activePanel: "files" }],
},
],
},
code: "invalid-active-panel",
},
])("rejects $name", ({ layout, code }) => {
const result = validateReusableLayout(layout);
expect(result).toMatchObject({ valid: false, issues: [expect.objectContaining({ code })] });
});
it("identifies unreadable legacy JSON without modifying it", () => {
const input = {
columns: [{ id: CENTER_COLUMN_ID, groups: LEGACY_LAYOUT_VALUE }],
legacy: true,
};
const snapshot = structuredClone(input);
expect(validateReusableLayout(input)).toMatchObject({
valid: false,
issues: [expect.objectContaining({ code: "invalid-layout" })],
});
expect(input).toEqual(snapshot);
});
it("validates the authoritative tree groups when a layout has split metadata", () => {
const layout = reusableLayout();
layout.columns[0].tree = {
type: "leaf",
group: { panels: [panel("chat")], activePanel: "files" },
};
expect(validateReusableLayout(layout)).toMatchObject({
valid: false,
issues: [expect.objectContaining({ code: "invalid-active-panel" })],
});
});
it("reports an invalid active panel only once", () => {
const layout = reusableLayout();
layout.columns[0].groups[0].activePanel = "files-outside-group";
const result = validateReusableLayout(layout);
expect(result.valid).toBe(false);
expect(result.issues.filter(({ code }) => code === "invalid-active-panel")).toHaveLength(1);
});
});
describe("layout profile defaults", () => {
it("marks an unreadable profile as legacy without rewriting its payload", () => {
const profile = savedLayout({ layout: { columns: LEGACY_LAYOUT_VALUE } });
const snapshot = structuredClone(profile);
expect(getLayoutProfileCompatibility(profile)).toMatchObject({
status: "legacy",
profile,
issues: [expect.objectContaining({ code: "invalid-layout" })],
});
expect(profile).toEqual(snapshot);
});
it("resolves a valid custom default and returns its normalized layout", () => {
const profile = savedLayout({ is_default: true });
expect(resolveEffectiveDefaultLayout([profile])).toMatchObject({
source: "custom",
profile,
layout: reusableLayout(),
});
});
it("resolves a valid reserved built-in override default", () => {
const profile = savedLayout({
id: getBuiltInLayoutOverrideId(DEFAULT_LAYOUT_ID),
name: "Default",
is_default: true,
});
expect(resolveEffectiveDefaultLayout([profile])).toMatchObject({
source: "custom",
profile,
layout: reusableLayout(),
});
});
it("falls back to the built-in Default when the selected profile is invalid", () => {
const invalid = savedLayout({ is_default: true, layout: reusableLayout(["files"]) });
const resolved = resolveEffectiveDefaultLayout([invalid]);
expect(resolved.source).toBe("built-in");
expect(resolved.profile.id).toBe(DEFAULT_LAYOUT_ID);
expect(validateReusableLayout(resolved.layout).valid).toBe(true);
});
it("applies a legacy default after removing its retired sidebar column", () => {
const layout = reusableLayout();
layout.columns.unshift({
id: "sidebar",
groups: [
{
id: "group-sidebar",
panels: [{ id: "sidebar", component: "sidebar", title: "Sidebar" }],
activePanel: "sidebar",
},
],
});
const profile = savedLayout({ is_default: true, layout });
const resolved = resolveEffectiveDefaultLayout([profile]);
expect(resolved.source).toBe("custom");
expect(resolved.layout.columns.map(({ id }) => id)).toEqual([CENTER_COLUMN_ID]);
});
});
describe("layout profile IDs", () => {
it("uses UUID-backed IDs", () => {
const first = createLayoutProfileId();
const second = createLayoutProfileId();
expect(first).toMatch(/^layout-[0-9a-f-]{36}$/);
expect(second).not.toBe(first);
});
});
describe("built-in layout overrides", () => {
it("creates one stable hidden override and updates it in place", () => {
const firstLayout = reusableLayout(["chat", "files"]);
const secondLayout = reusableLayout(["chat", "changes"]);
const created = upsertBuiltInLayoutOverride([], DEFAULT_LAYOUT_ID, firstLayout, {
createdAt: CREATED_AT,
isDefault: true,
});
const updated = upsertBuiltInLayoutOverride(created, DEFAULT_LAYOUT_ID, secondLayout, {
createdAt: "2026-07-19T12:00:00.000Z",
});
expect(updated).toHaveLength(1);
expect(updated[0]).toMatchObject({
id: getBuiltInLayoutOverrideId(DEFAULT_LAYOUT_ID),
name: "Default",
is_default: true,
layout: secondLayout,
created_at: CREATED_AT,
});
expect(isBuiltInLayoutOverride(updated[0])).toBe(true);
expect(getBuiltInLayoutOverride(updated, DEFAULT_LAYOUT_ID)).toBe(updated[0]);
});
it("rejects invalid layouts when creating or updating an override", () => {
const invalidLayout = reusableLayout(["files"]);
expect(() =>
upsertBuiltInLayoutOverride([], "plan", invalidLayout, { createdAt: CREATED_AT }),
).toThrow("valid reusable layout");
const existing = upsertBuiltInLayoutOverride([], "plan", reusableLayout(), {
createdAt: CREATED_AT,
});
expect(() =>
upsertBuiltInLayoutOverride(existing, "plan", invalidLayout, { createdAt: CREATED_AT }),
).toThrow("valid reusable layout");
});
it("persists the normalized layout when creating and updating an override", () => {
const sessionLayout = reusableLayout();
sessionLayout.columns[0].groups[0].panels[0] = {
id: SESSION_PANEL_ID,
component: "chat",
title: "Agent",
params: { sessionId: "session-one" },
};
sessionLayout.columns[0].groups[0].activePanel = SESSION_PANEL_ID;
const created = upsertBuiltInLayoutOverride([], "plan", sessionLayout, {
createdAt: CREATED_AT,
});
const updated = upsertBuiltInLayoutOverride(created, "plan", sessionLayout, {
createdAt: CREATED_AT,
});
for (const profiles of [created, updated]) {
const storedLayout = profiles[0].layout as unknown as LayoutState;
expect(storedLayout.columns[0].groups[0]).toMatchObject({
activePanel: "chat",
panels: expect.arrayContaining([expect.objectContaining({ id: "chat" })]),
});
}
});
it("resets only the selected built-in override", () => {
const defaultOverride = upsertBuiltInLayoutOverride([], DEFAULT_LAYOUT_ID, reusableLayout(), {
createdAt: CREATED_AT,
isDefault: true,
});
const withCustom = [...defaultOverride, savedLayout({ id: SECOND_LAYOUT_ID })];
expect(resetBuiltInLayoutOverride(withCustom, DEFAULT_LAYOUT_ID)).toEqual([
savedLayout({ id: SECOND_LAYOUT_ID }),
]);
});
});
describe("immutable layout profile mutations", () => {
it("creates a trimmed profile and clears an earlier default", () => {
const original = [savedLayout({ is_default: true })];
const next = createLayoutProfile(original, {
id: SECOND_LAYOUT_ID,
name: " Focused ",
layout: reusableLayout(["chat", "files"]),
createdAt: CREATED_AT,
isDefault: true,
});
expect(next).toEqual([
{ ...original[0], is_default: false },
savedLayout({
id: SECOND_LAYOUT_ID,
name: "Focused",
is_default: true,
layout: reusableLayout(["chat", "files"]),
created_at: CREATED_AT,
}),
]);
expect(original[0].is_default).toBe(true);
});
it("rejects duplicate IDs and blank names", () => {
const original = [savedLayout()];
expect(() =>
createLayoutProfile(original, {
id: FIRST_LAYOUT_ID,
name: "Duplicate",
layout: reusableLayout(),
createdAt: CREATED_AT,
}),
).toThrow("unique");
expect(() => renameLayoutProfile(original, FIRST_LAYOUT_ID, " ")).toThrow("name");
});
it("renames only the selected profile while preserving its creation time", () => {
const original = [savedLayout(), savedLayout({ id: SECOND_LAYOUT_ID, name: "Two" })];
const next = renameLayoutProfile(original, FIRST_LAYOUT_ID, " Renamed ");
expect(next[0]).toEqual({ ...original[0], name: "Renamed" });
expect(next[1]).toBe(original[1]);
});
it("duplicates a legacy profile as a non-default independent copy", () => {
const legacyLayout = { columns: LEGACY_LAYOUT_VALUE };
const original = [savedLayout({ is_default: true, layout: legacyLayout })];
const next = duplicateLayoutProfile(original, "layout-one", {
id: "layout-copy",
name: "Layout copy",
createdAt: "2026-07-19T12:00:00.000Z",
});
expect(next[1]).toEqual({
...original[0],
id: "layout-copy",
name: "Layout copy",
is_default: false,
created_at: "2026-07-19T12:00:00.000Z",
});
expect(next[1].layout).not.toBe(legacyLayout);
expect(original[0]).toEqual(savedLayout({ is_default: true, layout: legacyLayout }));
});
it("deletes a profile without changing the remaining objects", () => {
const retained = savedLayout({ id: SECOND_LAYOUT_ID, name: "Two" });
const original = [savedLayout(), retained];
const next = deleteLayoutProfile(original, FIRST_LAYOUT_ID);
expect(next).toEqual([retained]);
expect(next[0]).toBe(retained);
});
it("sets one valid profile as default and can clear the custom default", () => {
const first = savedLayout({ is_default: true });
const second = savedLayout({ id: SECOND_LAYOUT_ID, name: "Two" });
const selected = setDefaultLayoutProfile([first, second], SECOND_LAYOUT_ID);
const cleared = setDefaultLayoutProfile(selected, null);
expect(selected.map((profile) => profile.is_default)).toEqual([false, true]);
expect(cleared.map((profile) => profile.is_default)).toEqual([false, false]);
expect(first.is_default).toBe(true);
});
it("does not make an invalid legacy profile the default", () => {
const legacy = savedLayout({ layout: { columns: LEGACY_LAYOUT_VALUE } });
expect(() => setDefaultLayoutProfile([legacy], legacy.id)).toThrow("reusable");
});
});
+468
View File
@@ -0,0 +1,468 @@
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";
export type BuiltInLayoutProfileId = Exclude<BuiltInPreset, "compact">;
export type BuiltInLayoutProfileDescriptor = {
id: BuiltInLayoutProfileId;
name: string;
description: string;
};
export type BuiltInLayoutProfile = BuiltInLayoutProfileDescriptor & {
layout: LayoutState;
};
export const BUILT_IN_LAYOUT_PROFILES: readonly BuiltInLayoutProfileDescriptor[] = [
{
id: "default",
name: "Default",
description: "Agent with Files, Changes, and Terminal",
},
{
id: "plan",
name: "Plan Mode",
description: "Agent and Plan side by side",
},
{
id: "preview",
name: "Preview Mode",
description: "Agent and Browser side by side",
},
{
id: "vscode",
name: "VS Code",
description: "Agent and VS Code side by side",
},
];
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-${globalThis.crypto.randomUUID()}`;
}
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
);
}
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 };
});
}
@@ -77,7 +77,7 @@ vi.mock("./layout-manager", async (importOriginal) => {
};
});
import { setEnvLayout } from "@/lib/local-storage";
import { removeEnvMaximizeState, setEnvLayout } from "@/lib/local-storage";
import { persistEnvLayoutNow, useDockviewStore } from "./dockview-store";
import { applyLayout } from "./layout-manager";
@@ -310,6 +310,53 @@ describe("applyBuiltInPreset — persistence at call site", () => {
});
});
describe("resetLayout — effective default persistence", () => {
beforeEach(resetStoreForIntegration);
it("applies the current user default and persists it for the active environment", async () => {
const api = makeStoreApi();
const userDefaultLayout = {
columns: [
{
id: "center",
groups: [{ panels: [{ id: "chat", component: "chat", title: "Agent" }] }],
},
],
};
useDockviewStore.setState({
api,
currentLayoutEnvId: "env-reset",
userDefaultLayout,
});
useDockviewStore.getState().resetLayout();
expect(applyLayout).toHaveBeenCalledWith(api, userDefaultLayout, expect.any(Map), 800, 600);
await flushRaf();
expect(setEnvLayout).toHaveBeenCalledWith("env-reset", expect.any(Object));
});
it("clears maximized state before applying and persisting the default", async () => {
const api = makeStoreApi();
useDockviewStore.setState({
api,
currentLayoutEnvId: "env-reset",
preMaximizeLayout: { columns: [{ id: "old", groups: [] }] },
maximizedGroupId: "group-old",
});
useDockviewStore.getState().resetLayout();
expect(useDockviewStore.getState()).toMatchObject({
preMaximizeLayout: null,
maximizedGroupId: null,
});
expect(removeEnvMaximizeState).toHaveBeenCalledWith("env-reset");
await flushRaf();
expect(setEnvLayout).toHaveBeenCalledWith("env-reset", expect.any(Object));
});
});
describe("applyCustomLayout — session panel normalization", () => {
beforeEach(resetStoreForIntegration);
+17 -4
View File
@@ -939,6 +939,22 @@ function performBuildDefault(
});
}
function resetToEffectiveDefault(set: StoreSet, get: StoreGet): void {
const { api, currentLayoutEnvId, preMaximizeLayout } = get();
if (!api) return;
if (preMaximizeLayout) {
set({ preMaximizeLayout: null, maximizedGroupId: null });
if (currentLayoutEnvId) removeEnvMaximizeState(currentLayoutEnvId);
}
get().buildDefaultLayout(api);
requestAnimationFrame(() => {
const { currentLayoutEnvId: activeEnvId, preMaximizeLayout } = get();
if (activeEnvId === currentLayoutEnvId) {
persistEnvLayoutNow(api, currentLayoutEnvId, preMaximizeLayout);
}
});
}
export const useDockviewStore = create<DockviewStore>((set, get) => ({
api: null,
activeFilePath: null,
@@ -1040,10 +1056,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
})),
switchEnvLayout: buildEnvSwitchAction(set, get),
buildDefaultLayout: (api, intentName) => performBuildDefault(api, set, get, intentName),
resetLayout: () => {
const { api } = get();
if (api) get().buildDefaultLayout(api);
},
resetLayout: () => resetToEffectiveDefault(set, get),
pendingChatScrollTop: null,
setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }),
preMaximizeLayout: null,
@@ -18,6 +18,18 @@ export const RIGHT_BOTTOM_GROUP = "group-right-bottom";
export const TERMINAL_DEFAULT_ID = "terminal-default";
export const SIDEBAR_LOCK = "no-drop-target" as const;
/** Canonical single-instance panels supported by reusable layout profiles. */
export const REUSABLE_PANEL_IDS = [
"chat",
"files",
"changes",
TERMINAL_DEFAULT_ID,
"plan",
"browser",
"vscode",
] as const;
export type ReusablePanelId = (typeof REUSABLE_PANEL_IDS)[number];
/** Fixed panel IDs that can be saved in layout configs. */
export const KNOWN_PANEL_IDS = new Set([
"chat",
@@ -20,11 +20,13 @@ export {
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
TERMINAL_DEFAULT_ID,
REUSABLE_PANEL_IDS,
SIDEBAR_LOCK,
KNOWN_PANEL_IDS,
PANEL_REGISTRY,
panel,
} from "./constants";
export type { ReusablePanelId } from "./constants";
// Runtime caps for pinned columns
export {
+6
View File
@@ -111,6 +111,12 @@ describe("buildSettingsInitialStateForRoute", () => {
});
describe("renderSettingsRoute", () => {
it("renders layout profile settings from General settings", () => {
const route = renderSettingsRoute("/settings/general/layouts");
expect(isValidElement(route)).toBe(true);
expect(((route as ReactElement).type as { name?: string }).name).toBe("LayoutSettings");
});
it("renders task action preferences from General settings", () => {
const route = renderSettingsRoute("/settings/general/task-actions");
expect(isValidElement(route)).toBe(true);
+2
View File
@@ -39,6 +39,7 @@ import {
TaskActionsSettings,
} from "@/components/settings/general-settings";
import { NotificationsSettings } from "@/components/settings/notifications-settings";
import { LayoutSettings } from "@/components/settings/layouts/layout-settings";
import { PromptsSettings } from "@/components/settings/prompts-settings";
import { SecretsSettings } from "@/components/settings/secrets-settings";
import { SettingsLayoutClient } from "@/components/settings/settings-layout-client";
@@ -128,6 +129,7 @@ const SETTINGS_ROUTES: Record<string, RouteRenderer> = {
),
"/settings/general/editors": () => <EditorsSettings />,
"/settings/general/keyboard-shortcuts": () => <KeyboardShortcutsSettings />,
"/settings/general/layouts": () => <LayoutSettings />,
"/settings/general/notifications": () => <NotificationsSettings />,
"/settings/general/resource-metrics": () => (
<SettingsRedirect to="/settings/general/appearance" />
+102
View File
@@ -0,0 +1,102 @@
---
spec: docs/specs/ui/task-layout-profiles.md
created: 2026-07-19
status: complete
---
# Implementation Plan: Task Layout Profiles
## Overview
Extend the existing `saved_layouts` user setting instead of introducing new persistence. First harden profile-list validation and add pure reusable-layout operations, then build the responsive settings editor and connect the workbench default path, and finally prove persistence and task isolation in Playwright. The runtime remains compatible with the existing layout menu and environment-scoped layout restoration.
## Backend
### Saved layout validation
- `apps/backend/internal/user/service/service.go`: extend `applySavedLayouts` to reject empty IDs, duplicate IDs, and more than one `is_default` profile while retaining the existing count and name checks.
- `apps/backend/internal/user/service/service_test.go`: add table-driven validation coverage, including acceptance of zero or one saved default across custom profiles and reserved overrides.
No schema, repository, DTO, or endpoint changes are required; `users.settings.saved_layouts` and the existing GET/PATCH contract remain authoritative.
## Frontend
### Profile domain
- Add `apps/web/lib/layout/layout-profiles.ts` and focused tests for built-in template descriptors, reusable-layout validation, effective-default resolution, immutable create/rename/duplicate/delete/default operations, and legacy-layout compatibility status.
- Reuse `LayoutState`, `PANEL_REGISTRY`, `getPresetLayout`, session-panel normalization, and the existing `SavedLayout` HTTP type. Editor-created layouts use canonical reusable panel IDs and never introduce task-specific IDs.
- Extract shared profile mutations from `apps/web/components/task/layout-preset-selector.tsx` so the task menu and settings page cannot diverge on default/delete behavior.
### Settings page and editor
- Add `apps/web/app/settings/general/layouts/page.tsx` with the existing user-settings hydration pattern.
- Add `apps/web/components/settings/layouts/layout-settings.tsx` for profile selection, create/duplicate/rename/delete/default/reset commands, dirty-state handling through the shared Settings floating save coordinator, error feedback, and narrow-viewport composition.
- Add focused editor components under `apps/web/components/settings/layouts/` that render lightweight panel placeholders, use the existing Dockview layout engine for pointer/touch split and tab manipulation, and expose contextual keyboard/touch commands beside the selected split. Every command has hover/focus help. Agent is permanent; reusable panels are single-instance.
- Represent direct built-in edits as reserved hidden overrides so each built-in remains one visible row with `Built-in` and optional `Customized` status. Reset removes the reserved override and restores the code-defined layout.
- Treat reserved built-in overrides like custom profiles for `is_default` uniqueness: an override may own the single saved default, while editing built-in Default preserves an existing custom default. When no saved profile owns the default, editing built-in Default marks its override as the default so that customization becomes effective.
- The preview is isolated from `useDockviewStore` and runtime panel components so Browser, VS Code, and Terminal have no runtime side effects in Settings.
- Add `Layouts` to `apps/web/components/settings/general-nav.ts` and the settings tree; register `/settings/general/layouts` in `apps/web/src/settings-routes.tsx` and cover route resolution.
### Workbench integration
- Add a compatibility resolver used by `apps/web/components/task/dockview-desktop-layout.tsx` so only a valid reusable saved default, including a reserved built-in override, reaches `setUserDefaultLayout`; invalid legacy defaults fall back to the built-in Default.
- Preserve `apps/web/lib/state/dockview-store.ts` precedence: environment layout first, effective user default for fresh/reset paths second, built-in fallback last.
- Verify `apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.ts` remains panel-driven, so omitting `terminal-default` prevents `createUserShell`.
- Keep `apps/web/components/task/layout-preset-selector.tsx` behavior compatible while reusing profile operations and making deletion of the selected custom default explicitly fall back to the built-in Default.
## Tests
- **Saved profile list validation:** `apps/backend/internal/user/service/service_test.go`; table-driven tests cover blank/duplicate IDs, multiple defaults, limits, names, and valid zero/one-default lists.
- **Profile operations and effective default:** `apps/web/lib/layout/layout-profiles.test.ts`; pure unit tests cover create, duplicate, rename, delete-default fallback, set-default uniqueness, valid layout requirements, and legacy compatibility.
- **Default layout resolution:** focused tests for the resolver used by `dockview-desktop-layout.tsx`; valid reusable layouts are normalized and invalid/unreadable defaults return `null`.
- **Terminal side-effect guard:** `apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.test.ts`; no `terminal-default` panel means no `createUserShell`, while the existing panel path still migrates once.
- **Settings SPA route:** `apps/web/src/settings-routes.test.ts` and general navigation tests cover the Layouts route without direct component fetches.
## E2E Tests
- **Desktop profile workflow:** `apps/web/e2e/tests/settings/layout-profiles.spec.ts`; edit built-in Default directly, remove Terminal with contextual controls, verify action help, save through the shared floating control, and reset the built-in override.
- **Fresh task and reset behavior:** the same desktop spec creates and opens a fresh task, verifies no Terminal tab/default shell, proves an existing customized task is unchanged after a default update, then verifies Reset Layout applies the latest default.
- **Mobile settings parity:** `apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts`; navigate through the settings drawer, select and reorder a built-in tab with the contextual touch controls, save its hidden override, and verify there is no horizontal page scroll.
- **Existing layout regressions:** run `apps/web/e2e/tests/task/task-default-layout.spec.ts` and `apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts` with the new specs.
## Public Documentation
- Update `docs/public/sessions-and-review.md` with the Layouts settings path, effective-default behavior, and the distinction between reusable defaults and task-specific restored layouts.
## Implementation Waves
Wave 1 (parallel in the shared worktree):
- [x] [Task 01 - Saved layout validation](task-01-saved-layout-validation.md)
- [x] [Task 02 - Layout profile domain](task-02-layout-profile-domain.md)
Wave 2 (parallel):
- [x] [Task 03 - Layout settings editor](task-03-layout-settings-editor.md)
- [x] [Task 04 - Workbench default integration](task-04-workbench-default-integration.md)
Wave 3:
- [x] [Task 05 - E2E, docs, and verification](task-05-e2e-docs-verification.md)
The user authorized subagents after the approval checkpoint. Tasks 01 and 02 ran in parallel with separate file ownership; Tasks 03 and 04 do the same in Wave 2. The parent agent owns shared plan synchronization and integration review. Read-only editor and E2E research informed the implementation tasks.
## Risks
- Existing saved layouts may contain task-specific or old serialized Dockview payloads. Detection must not mutate or discard them silently.
- Dockview pointer behavior alone is insufficient on mobile and for keyboard users. Explicit move/split commands are part of acceptance, not a fallback enhancement.
- Settings previews must never mount real task panels or start terminal/editor/browser resources.
- Environment-scoped restoration must remain higher precedence than the new default, or changing Settings would unexpectedly rewrite active work.
- User-setting updates replace the full profile list, so each mutation must use the latest authoritative store value and adopt the response to avoid lost updates.
## Verification
```bash
make fmt
make typecheck
make test
make lint
make build-web
cd apps/web && pnpm e2e:run tests/settings/layout-profiles.spec.ts tests/task/task-default-layout.spec.ts tests/layout/saved-layout-session-isolation.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome -- tests/settings/mobile-layout-profiles.spec.ts --workers=1
```
@@ -0,0 +1,43 @@
---
id: "01-saved-layout-validation"
title: "Saved layout validation"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 01: Saved Layout Validation
## Acceptance
- `saved_layouts` rejects blank IDs, duplicate IDs, and more than one custom default.
- Existing count/name checks remain intact, and lists with zero or one custom default are accepted.
- The existing GET/PATCH representation remains unchanged.
## Files likely touched
- `apps/backend/internal/user/service/service.go`
- `apps/backend/internal/user/service/service_test.go`
## Dependencies
None.
## Inputs
- Spec `Data model` and `API surface` sections.
- Existing `applySavedLayouts` and its table-driven tests.
## Verification
```bash
go test -v -run TestApplySavedLayouts ./internal/user/service/...
```
Run from `apps/backend`.
## Output contract
Report validation behavior, tests run, files changed, blockers, and risks; mark this task and its plan item done.
@@ -0,0 +1,45 @@
---
id: "02-layout-profile-domain"
title: "Layout profile domain"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 02: Layout Profile Domain
## Acceptance
- Pure helpers define built-in templates, validate reusable layouts, resolve the effective default, and perform immutable profile mutations.
- Editor-created layouts allow exactly one Agent and at most one of each reusable optional panel, with no empty groups.
- Invalid or legacy layouts are identified without mutating their stored payloads.
## Files likely touched
- `apps/web/lib/layout/layout-profiles.ts`
- `apps/web/lib/layout/layout-profiles.test.ts`
- `apps/web/lib/state/layout-manager/constants.ts`
- `apps/web/components/task/layout-preset-selector.tsx`
## Dependencies
None.
## Inputs
- Spec `What`, `Data model`, and legacy failure-mode requirements.
- Existing `LayoutState`, `PANEL_REGISTRY`, `getPresetLayout`, and `SavedLayout` types.
## Verification
```bash
pnpm --filter @kandev/web test -- --run lib/layout/layout-profiles.test.ts
```
Run from `apps`.
## Output contract
Report helper contracts, tests run, files changed, blockers, and risks; mark this task and its plan item done.
@@ -0,0 +1,54 @@
---
id: "03-layout-settings-editor"
title: "Layout settings editor"
status: done
wave: 2
depends_on: ["01-saved-layout-validation", "02-layout-profile-domain"]
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 03: Layout Settings Editor
## Acceptance
- `Settings > General > Layouts` manages built-in layouts and custom profiles through the shared floating save coordinator, navigation guard, and page-level error state.
- Built-in edits remain on one visible row, create a hidden override marked `Customized`, and can be restored with Reset.
- Selecting a tab exposes contextual split/tab actions with hover/focus descriptions; adding panels remains a separate floating action.
- The isolated preview supports reusable panel add/remove, tab reorder, active-tab choice, and split create/move/resize without mounting runtime task panels.
- Every operation is reachable by pointer, keyboard, and touch; the mobile page has no horizontal page scroll.
## Files likely touched
- `apps/web/app/settings/general/layouts/page.tsx`
- `apps/web/components/settings/layouts/layout-settings.tsx`
- `apps/web/components/settings/layouts/layout-editor.tsx`
- `apps/web/components/settings/layouts/layout-editor-toolbar.tsx`
- `apps/web/components/settings/layouts/layout-profile-list.tsx`
- `apps/web/components/settings/general-nav.ts`
- `apps/web/components/app-sidebar/sections/settings/general-group.tsx`
- `apps/web/src/settings-routes.tsx`
- `apps/web/src/settings-routes.test.ts`
## Dependencies
- Task 01 supplies backend invariants.
- Task 02 supplies profile and validation helpers.
## Inputs
- Spec `What`, `Failure modes`, and settings scenarios.
- Existing settings route hydration, `SettingsPageTemplate`, Dockview theme, layout serializer/applier, and `@dnd-kit`/touch patterns.
## Verification
```bash
pnpm --filter @kandev/web test -- --run lib/layout/layout-profiles.test.ts src/settings-routes.test.ts
pnpm --filter @kandev/web typecheck
```
Run from `apps`.
## Output contract
Report desktop/mobile behavior, tests run, files changed, blockers, and risks; mark this task and its plan item done.
@@ -0,0 +1,47 @@
---
id: "04-workbench-default-integration"
title: "Workbench default integration"
status: done
wave: 2
depends_on: ["02-layout-profile-domain"]
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 04: Workbench Default Integration
## Acceptance
- A valid custom default supplies the fresh-task and Reset Layout state, while an invalid legacy default falls back to built-in Default.
- Existing environment layouts retain precedence over user defaults.
- Omitting Terminal from a fresh default prevents the default-shell migration path from creating a user shell.
## Files likely touched
- `apps/web/components/task/dockview-desktop-layout.tsx`
- `apps/web/components/task/layout-preset-selector.tsx`
- `apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.ts`
- `apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.test.ts`
- `apps/web/lib/layout/layout-profiles.test.ts`
## Dependencies
- Task 02 supplies effective-default validation and fallback behavior.
## Inputs
- Spec task-isolation, Reset Layout, terminal, and invalid-default scenarios.
- Existing `useSyncUserDefaultLayout`, `performBuildDefault`, `restoreEnvLayout`, and `useEnsureDefaultTerminalOrdinary` behavior.
## Verification
```bash
pnpm --filter @kandev/web test -- --run lib/layout/layout-profiles.test.ts hooks/domains/session/use-ensure-default-terminal-ordinary.test.ts
pnpm --filter @kandev/web typecheck
```
Run from `apps`.
## Output contract
Report precedence/fallback behavior, tests run, files changed, blockers, and risks; mark this task and its plan item done.
@@ -0,0 +1,52 @@
---
id: "05-e2e-docs-verification"
title: "E2E, docs, and verification"
status: done
wave: 3
depends_on: ["03-layout-settings-editor", "04-workbench-default-integration"]
plan: "plan.md"
spec: "../../specs/ui/task-layout-profiles.md"
---
# Task 05: E2E, Docs, And Verification
## Acceptance
- Desktop and mobile layout-profile scenarios pass against a production build, including fresh-task terminal suppression, existing-task isolation, and Reset Layout.
- Shared E2E settings reset prevents `saved_layouts` leakage between worker tests.
- Public workbench documentation is current and all repository verification gates are green.
## Files likely touched
- `apps/web/e2e/fixtures/test-base.ts`
- `apps/web/e2e/pages/layout-settings-page.ts`
- `apps/web/e2e/tests/settings/layout-profiles.spec.ts`
- `apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts`
- `docs/public/sessions-and-review.md`
## Dependencies
- Task 03 provides the complete persisted settings workflow.
- Task 04 provides fresh/reset default behavior and terminal suppression.
## Inputs
- Spec settings, task-isolation, Reset Layout, terminal, persistence, and mobile scenarios.
- Existing E2E `ApiClient.saveUserSettings`, `SessionPage.waitForDockviewReady`, `window.__dockviewApi__`, and `user_shell.list` patterns.
- `/mobile-parity`, `/e2e`, `/docs-maintainer`, `/qa`, `/simplify`, and `/verify` workflows.
## Verification
```bash
make fmt
make typecheck
make test
make lint
make build-web
cd apps/web && pnpm e2e:run tests/settings/layout-profiles.spec.ts tests/task/task-default-layout.spec.ts tests/layout/saved-layout-session-isolation.spec.ts
cd apps/web && pnpm e2e:run --project mobile-chrome -- tests/settings/mobile-layout-profiles.spec.ts --workers=1
```
## Output contract
Report user-visible behavior, unit/E2E/full verification results, files changed, blockers, and remaining risks; mark this task and its plan item done.
+4 -3
View File
@@ -215,14 +215,15 @@
"id": "personal-settings-and-secrets",
"audiences": ["user"],
"stability": "stable",
"docs": ["security", "configuration", "developer-tools", "operations"],
"sources": ["apps/web/src/settings-routes.tsx", "apps/web/components/settings/general-settings.tsx"],
"tests": ["apps/web/src/settings-routes.test.ts", "apps/web/components/settings/terminal-settings.test.ts"],
"docs": ["security", "configuration", "developer-tools", "operations", "sessions-and-review"],
"sources": ["apps/web/src/settings-routes.tsx", "apps/web/components/settings/general-settings.tsx", "apps/web/components/settings/layouts/layout-settings.tsx"],
"tests": ["apps/web/src/settings-routes.test.ts", "apps/web/components/settings/terminal-settings.test.ts", "apps/web/lib/layout/layout-profiles.test.ts"],
"settingsRoutes": [
"/settings/general",
"/settings/general/appearance",
"/settings/general/editors",
"/settings/general/keyboard-shortcuts",
"/settings/general/layouts",
"/settings/general/notifications",
"/settings/general/secrets",
"/settings/general/sprites",
+2
View File
@@ -86,6 +86,8 @@ Messages show peer attribution, and Kandev gives the receiving agent hidden repl
Desktop panel groups can host agent chat, files, terminals, Changes, the task plan, previews, and GitHub pull-request detail. Use **+** to add a panel. Mobile exposes sessions, files, terminal, and changes through task navigation and sheets. Its task switcher opens as an inset bottom card, and the current-session control shows the active agent's icon and name.
Open **Settings > General > Layouts** to configure reusable desktop workbench profiles. Select a tab in a built-in layout to reveal its nearby edit controls, arrange or remove tabs and splits, then use the floating **Save changes** control. Kandev keeps the built-in row visible, marks it **Customized**, and stores your override without requiring a duplicate. Choose **Reset** beside a customized built-in to restore its original definition. Removing Terminal from the Default layout also prevents Kandev from creating its initial user shell. Changing the default does not replace a layout already saved for a task; choose **Reset Layout** from the workbench layout menu when you want that task to adopt the latest default.
All panels for a task point at the same task environment. In a multi-repository task, check the repository label before editing, committing, or reviewing. A preview also requires the application to listen on a reachable interface and expose a forwarded port.
Structured shell-command activity keeps the command, working directory, status, and output size in the chat row. Expand **Output** to fetch the transcript; Kandev continues refreshing an open, running command and stops when it reaches a terminal state. The disclosure separates standard output and errors, reports truncation and the exit code when known, and offers **Retry** when the transcript request fails. Historical command transcripts are loaded only when opened, which keeps long conversations responsive without discarding the stored output.
+1
View File
@@ -117,6 +117,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [slash-command-composer](ui/slash-command-composer.md) | shipped |
| [settings-manual-save](ui/settings-manual-save.md) | shipped |
| [mobile-task-navigation](ui/mobile-task-navigation.md) | shipped |
| [task-layout-profiles](ui/task-layout-profiles.md) | draft |
## system-page/ — operational diagnostics & maintenance UI
+94
View File
@@ -0,0 +1,94 @@
---
status: shipped
created: 2026-07-19
owner: kandev
---
# Task Layout Profiles
## Why
Users can arrange and save the desktop task workbench only while a task is open, which makes the default layout difficult to discover or configure. Users who do not want an initial terminal, or who prefer a different Files and Changes arrangement, need a durable settings surface that does not disturb layouts already customized for individual tasks.
## What
- `Settings > General > Layouts` is the central manager for reusable desktop task-layout profiles and is reachable on desktop and mobile settings navigation.
- The page lists the built-in Default, Plan Mode, Preview Mode, and VS Code layouts as stable rows. A user edits a built-in directly; Kandev stores a hidden override while keeping the built-in row selected and marks it `Customized`. Reset removes the override and restores the code-defined layout.
- A user can create, rename, duplicate, edit, delete, and select the default custom profile. Names must be non-empty; profile IDs must be unique.
- Exactly one layout is effective as the user default. A saved profile, including a reserved built-in override, marked `is_default` wins; when none is marked, the built-in Default layout is effective.
- The visual editor supports one instance of each reusable panel: Agent, Files, Changes, Terminal, Plan, Browser, and VS Code. Agent is required and cannot be removed.
- Selecting a tab makes it active and shows contextual controls next to its split. Users can reorder or remove the tab, move it between groups, create splits, and move, merge, or resize the selected split. Adding a missing panel remains a separate floating action. Every editor action provides a hover/focus description.
- Layout changes use the shared Settings floating save control and navigation guard. The page does not render its own Save or Cancel buttons.
- Removing Terminal from the effective default prevents the default terminal panel and its backing user shell from being created when a fresh task environment is first opened.
- Applying a layout preserves every configured reusable panel regardless of whether the task has repositories. Panels without applicable content remain available and show their normal empty state.
- A changed default applies to task environments that have no saved task-specific layout and to an explicit Reset Layout action. It does not overwrite an existing task-specific layout merely because the setting changed.
- The existing workbench layout menu continues to apply built-in and custom profiles and save the current workbench as a custom profile. Profile mutations from either surface remain consistent after the user-settings response is received.
- Layout-profile editing is usable with pointer, keyboard, and touch input. On narrow settings viewports, profile management and all editor commands remain reachable without horizontal page scrolling.
- Layout profiles configure the desktop Dockview workbench only. Mobile and tablet task-detail layouts retain their existing behavior.
## Data model
Layout profiles remain in the backend-owned `users.settings.saved_layouts` JSON value; no schema migration or second durable store is introduced.
`SavedLayout`
| Field | Type | Constraint |
|---|---|---|
| `id` | string | Non-empty and unique within the user's list |
| `name` | string | Non-empty after trimming |
| `is_default` | boolean | At most one saved profile is `true` |
| `layout` | JSON object | Reusable `LayoutState` payload |
| `created_at` | ISO-8601 string | Preserved when editing; newly assigned when creating or duplicating |
The built-in layouts are code-defined templates. A customization is stored in `saved_layouts` under the reserved stable ID `layout-override-<built-in-id>`, but is hidden from the Custom list and presented as the same built-in row. Reserved overrides participate in the same single-`is_default` invariant as custom profiles. A Default override replaces the code-defined Default as the effective default only when that override owns `is_default`; editing it claims the default when no saved profile currently owns it and otherwise preserves the existing custom default. If no saved profile has `is_default: true`, the code-defined Default template is the effective default. Resetting a built-in removes only its reserved override.
The editor persists the existing declarative `LayoutState`: ordered columns contain ordered groups, groups contain ordered panels and an active panel, and captured tree/size data preserves split placement and proportions. New editor-created profiles use only the reusable panel registry. A legacy profile with an unreadable layout remains listed for rename, duplication, deletion, or default removal, but cannot enter the visual editor or become a new default until replaced with a valid reusable layout.
Task-specific restored layouts remain device-local environment state and take precedence over the user default. They are not copied into or overwritten by layout-profile edits.
## API surface
No new endpoint is introduced.
- `GET /api/v1/user/settings` returns `settings.saved_layouts`.
- `PATCH /api/v1/user/settings` accepts `saved_layouts` as a complete replacement list and returns the updated user settings.
- A `saved_layouts` update returns `400 Bad Request` when it exceeds the existing limit, contains an empty ID or name, contains duplicate IDs, or marks more than one saved profile, including reserved overrides, as default.
The frontend treats the returned settings payload as authoritative after each successful mutation.
## Failure modes
- If a profile save fails, the editor keeps the unsaved draft, reports the error, and leaves the previously persisted profiles/default unchanged.
- If a saved default layout is unreadable or contains no usable Agent panel, the workbench falls back to the built-in Default layout instead of rendering a broken or empty workbench.
- If a legacy profile cannot be opened by the visual editor, the page identifies it as unavailable for editing and does not silently rewrite its payload.
- Browser and VS Code panels in the settings preview do not launch, download, connect to, or authenticate external processes. Their normal runtime behavior begins only when the profile is applied in a task.
- Deleting the current custom default requires confirmation and makes the built-in Default layout effective.
## Persistence guarantees
- Custom profiles and the selected custom default survive browser and Kandev restarts through backend user settings and are portable across the user's devices.
- An unsaved editor draft does not survive navigation or restart.
- Per-task layout state continues to use its existing environment-scoped persistence and is not made portable by this feature.
## Scenarios
- **GIVEN** the user opens General settings on desktop or mobile, **WHEN** they select Layouts, **THEN** the built-in templates, custom profiles, and effective default are visible.
- **GIVEN** the built-in Default layout, **WHEN** the user removes Terminal and saves with the shared floating control, **THEN** the same Default row is marked `Customized` and its hidden default override persists without requiring a duplicate step.
- **GIVEN** a customized built-in layout, **WHEN** the user chooses Reset and saves, **THEN** its hidden override is removed and the original code-defined layout is restored.
- **GIVEN** a customized built-in layout, **WHEN** the user selects that built-in from the task workbench layout menu, **THEN** the saved override is applied instead of the original code-defined template.
- **GIVEN** a valid custom profile, **WHEN** the user reorders tabs or moves a panel into a new split and saves, **THEN** reopening the profile shows the same tab order, active tab, split order, and proportions.
- **GIVEN** a default profile without Terminal and a task environment with no saved layout, **WHEN** the user first opens that task, **THEN** the workbench has no Terminal tab and no default user shell is created.
- **GIVEN** an existing task with a task-specific layout, **WHEN** the user changes the default profile and returns to that task, **THEN** the task-specific layout is unchanged.
- **GIVEN** an existing task with a task-specific layout, **WHEN** the user chooses Reset Layout, **THEN** the latest effective default profile replaces that task's layout.
- **GIVEN** a custom default profile, **WHEN** the user deletes it and confirms, **THEN** the built-in Default becomes effective.
- **GIVEN** a profile draft with Agent removed, duplicate reusable panels, or an empty group, **WHEN** the user attempts to save, **THEN** saving is blocked and the invalid locations are identified.
- **GIVEN** a backend save failure, **WHEN** the user saves a profile edit, **THEN** the draft remains available and the previous persisted layout stays selected.
- **GIVEN** a legacy unreadable saved profile, **WHEN** the Layouts page loads, **THEN** the profile remains available for non-editor management, is marked unavailable for visual editing, and is not silently modified.
## Out of scope
- Customizing mobile or tablet task-detail layouts.
- Forcing a changed default onto existing task-specific layouts without Reset Layout.
- Configuring task-specific panels such as individual files, diffs, commits, pull requests, extra sessions, or extra terminals.
- Mutating the code-defined built-in definitions; direct edits are persisted as hidden user overrides.
- Sharing profiles between users or scoping profiles to a workspace, repository, agent, or executor.