From ba75922fa1ec6157731d3515e78ea59a8010d168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 20 Jul 2026 12:09:06 +0100 Subject: [PATCH] feat: add configurable task layout profiles (#1799) --- apps/backend/internal/user/service/service.go | 15 + .../internal/user/service/service_test.go | 81 +++ apps/web/AGENTS.md | 5 + .../web/app/settings/general/layouts/page.tsx | 13 + apps/web/components/settings/general-nav.ts | 7 + .../layouts/layout-editor-actions.test.ts | 133 +++++ .../settings/layouts/layout-editor-actions.ts | 116 +++++ .../layouts/layout-editor-toolbar.tsx | 376 ++++++++++++++ .../settings/layouts/layout-editor.tsx | 208 ++++++++ .../settings/layouts/layout-profile-list.tsx | 200 ++++++++ .../settings/layouts/layout-settings.tsx | 253 ++++++++++ .../layouts/use-layout-settings.test.tsx | 335 +++++++++++++ .../settings/layouts/use-layout-settings.ts | 327 ++++++++++++ .../task/dockview-desktop-layout.tsx | 36 +- .../task/dockview-panel-content.tsx | 65 +-- .../task/layout-preset-selector.tsx | 250 +++++++--- apps/web/e2e/fixtures/test-base.ts | 2 + apps/web/e2e/pages/layout-settings-page.ts | 76 +++ .../saved-layout-session-isolation.spec.ts | 2 +- .../tests/settings/layout-profiles.spec.ts | 254 ++++++++++ .../settings/mobile-layout-profiles.spec.ts | 51 ++ .../tests/task/task-default-layout.spec.ts | 34 ++ .../kanban/use-active-task-has-repos.test.ts | 35 -- .../kanban/use-active-task-has-repos.ts | 28 -- ...e-ensure-default-terminal-ordinary.test.ts | 70 +++ apps/web/lib/layout/layout-profiles.test.ts | 446 +++++++++++++++++ apps/web/lib/layout/layout-profiles.ts | 468 ++++++++++++++++++ .../state/dockview-preset-persistence.test.ts | 49 +- apps/web/lib/state/dockview-store.ts | 21 +- .../web/lib/state/layout-manager/constants.ts | 12 + apps/web/lib/state/layout-manager/index.ts | 2 + apps/web/src/settings-routes.test.ts | 6 + apps/web/src/settings-routes.tsx | 2 + docs/plans/task-layout-profiles/plan.md | 102 ++++ .../task-01-saved-layout-validation.md | 43 ++ .../task-02-layout-profile-domain.md | 45 ++ .../task-03-layout-settings-editor.md | 54 ++ .../task-04-workbench-default-integration.md | 47 ++ .../task-05-e2e-docs-verification.md | 52 ++ docs/public/coverage.json | 7 +- docs/public/sessions-and-review.md | 2 + docs/specs/INDEX.md | 1 + docs/specs/ui/task-layout-profiles.md | 94 ++++ 43 files changed, 4198 insertions(+), 227 deletions(-) create mode 100644 apps/web/app/settings/general/layouts/page.tsx create mode 100644 apps/web/components/settings/layouts/layout-editor-actions.test.ts create mode 100644 apps/web/components/settings/layouts/layout-editor-actions.ts create mode 100644 apps/web/components/settings/layouts/layout-editor-toolbar.tsx create mode 100644 apps/web/components/settings/layouts/layout-editor.tsx create mode 100644 apps/web/components/settings/layouts/layout-profile-list.tsx create mode 100644 apps/web/components/settings/layouts/layout-settings.tsx create mode 100644 apps/web/components/settings/layouts/use-layout-settings.test.tsx create mode 100644 apps/web/components/settings/layouts/use-layout-settings.ts create mode 100644 apps/web/e2e/pages/layout-settings-page.ts create mode 100644 apps/web/e2e/tests/settings/layout-profiles.spec.ts create mode 100644 apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts delete mode 100644 apps/web/hooks/domains/kanban/use-active-task-has-repos.test.ts delete mode 100644 apps/web/hooks/domains/kanban/use-active-task-has-repos.ts create mode 100644 apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.test.ts create mode 100644 apps/web/lib/layout/layout-profiles.test.ts create mode 100644 apps/web/lib/layout/layout-profiles.ts create mode 100644 docs/plans/task-layout-profiles/plan.md create mode 100644 docs/plans/task-layout-profiles/task-01-saved-layout-validation.md create mode 100644 docs/plans/task-layout-profiles/task-02-layout-profile-domain.md create mode 100644 docs/plans/task-layout-profiles/task-03-layout-settings-editor.md create mode 100644 docs/plans/task-layout-profiles/task-04-workbench-default-integration.md create mode 100644 docs/plans/task-layout-profiles/task-05-e2e-docs-verification.md create mode 100644 docs/specs/ui/task-layout-profiles.md diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 53040b4517..9e04ae486e 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -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 diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index 343eda4dde..a2dfbea98b 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -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 { diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index e8b6748d30..a01bf20046 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -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. diff --git a/apps/web/app/settings/general/layouts/page.tsx b/apps/web/app/settings/general/layouts/page.tsx new file mode 100644 index 0000000000..ba6bfe8ac5 --- /dev/null +++ b/apps/web/app/settings/general/layouts/page.tsx @@ -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 ( + + + + ); +} diff --git a/apps/web/components/settings/general-nav.ts b/apps/web/components/settings/general-nav.ts index 99a2672370..fce22c3394 100644 --- a/apps/web/components/settings/general-nav.ts +++ b/apps/web/components/settings/general-nav.ts @@ -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", diff --git a/apps/web/components/settings/layouts/layout-editor-actions.test.ts b/apps/web/components/settings/layouts/layout-editor-actions.test.ts new file mode 100644 index 0000000000..ac42ef2276 --- /dev/null +++ b/apps/web/components/settings/layouts/layout-editor-actions.test.ts @@ -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; + setSize: ReturnType; + }; +}; + +type FakePanel = { + id: string; + group: FakeGroup; + api: { + moveTo: ReturnType; + }; +}; + +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 }); + }); +}); diff --git a/apps/web/components/settings/layouts/layout-editor-actions.ts b/apps/web/components/settings/layouts/layout-editor-actions.ts new file mode 100644 index 0000000000..43a2d4f344 --- /dev/null +++ b/apps/web/components/settings/layouts/layout-editor-actions.ts @@ -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; +} diff --git a/apps/web/components/settings/layouts/layout-editor-toolbar.tsx b/apps/web/components/settings/layouts/layout-editor-toolbar.tsx new file mode 100644 index 0000000000..230635a990 --- /dev/null +++ b/apps/web/components/settings/layouts/layout-editor-toolbar.tsx @@ -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 ( + + + + + + + {help} + + ); +} + +function MenuTrigger({ + label, + help, + disabled, + children, +}: { + label: string; + help: string; + disabled: boolean; + children: React.ReactNode; +}) { + return ( + + + + + + + + + {help} + + ); +} + +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 ( +
+ + + + + + + + + + Add a missing tool tab beside the selected split. + + + {missing.map((id) => ( + + state.perform(() => addReusablePanel(state.api!, id, state.selected?.group.id)) + } + > + {PANEL_REGISTRY[id].title} + + ))} + + +
+ ); +} + +function SplitMenu({ state }: { state: ActionState }) { + const disabled = state.disabled || (state.selected?.group.panels.length ?? 0) < 2; + return ( + + + + + + {directions.map(({ direction, label, icon: Icon }) => ( + + state.perform(() => splitPanel(state.api!, state.selectedPanelId!, direction)) + } + > + {label} + + ))} + + + ); +} + +function MovePanelMenu({ state }: { state: ActionState }) { + return ( + + + + + + {state.targetGroups.map((group) => ( + + state.perform(() => movePanelToGroup(state.api!, state.selectedPanelId!, group.id)) + } + > + {group.activePanel?.title ?? group.id} + + ))} + + + ); +} + +function ArrangeSplitMenu({ state }: { state: ActionState }) { + const resize = (axis: "width" | "height", delta: number) => + state.perform(() => resizeGroup(state.api!, state.selected!.group.id, axis, delta)); + return ( + + + + + + resize("width", -40)} + > + + Decrease width + + resize("width", 40)} + > + + Increase width + + resize("height", -40)} + > + + Decrease height + + resize("height", 40)} + > + + Increase height + + {state.targetGroups.length > 0 && } + {state.targetGroups.flatMap((target) => [ + ...directions.map(({ direction, label, icon: Icon }) => ( + + state.perform(() => + moveGroup(state.api!, state.selected!.group.id, target.id, direction), + ) + } + > + {label} of {target.activePanel?.title ?? target.id} + + )), + + state.perform(() => mergeGroup(state.api!, state.selected!.group.id, target.id)) + } + > + Merge with {target.activePanel?.title ?? target.id} + , + ])} + + + ); +} + +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 ( +
+ + {state.selected.title ?? state.selected.id} + + state.perform(() => reorderTab(state.api!, state.selectedPanelId!, -1))} + > + + + state.perform(() => reorderTab(state.api!, state.selectedPanelId!, 1))} + > + + + + + + state.perform(() => removeReusablePanel(state.api!, state.selectedPanelId!))} + > + + +
+ ); +} + +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 ( + <> + + + + ); +} diff --git a/apps/web/components/settings/layouts/layout-editor.tsx b/apps/web/components/settings/layouts/layout-editor.tsx new file mode 100644 index 0000000000..9b21262748 --- /dev/null +++ b/apps/web/components/settings/layouts/layout-editor.tsx @@ -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 ( +
+ {api.title ?? api.id} +
+ ); +} + +function EditorTab(props: IDockviewPanelHeaderProps) { + return ; +} + +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; + editable: boolean; + onReady: (event: DockviewReadyEvent) => void; +}) { + return ( +
+ +
+ ); +} + +export function LayoutEditor({ layout, editable, onChange }: LayoutEditorProps) { + const rootRef = useRef(null); + const apiRef = useRef(null); + const applyingRef = useRef(false); + const releaseTimerRef = useRef | null>(null); + const [api, setApi] = useState(null); + const [selectedPanelId, setSelectedPanelId] = useState(null); + const [revision, setRevision] = useState(0); + const [actionAnchor, setActionAnchor] = useState(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 ( +
+ + +
+ ); +} diff --git a/apps/web/components/settings/layouts/layout-profile-list.tsx b/apps/web/components/settings/layouts/layout-profile-list.tsx new file mode 100644 index 0000000000..6522e69f52 --- /dev/null +++ b/apps/web/components/settings/layouts/layout-profile-list.tsx @@ -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 ( + + + + + {help} + + ); +} + +function BuiltInProfileList({ + profiles, + selection, + effectiveBuiltInId, + onSelect, +}: { + profiles: SavedLayout[]; + selection: LayoutProfileSelection; + effectiveBuiltInId: BuiltInLayoutProfileId | null; + onSelect: (selection: LayoutProfileSelection) => void; +}) { + return ( +
+

Built-in

+ {BUILT_IN_LAYOUT_PROFILES.map((profile) => { + const override = getBuiltInLayoutOverride(profiles, profile.id); + return ( + + ); + })} +
+ ); +} + +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 ( + + ); +} diff --git a/apps/web/components/settings/layouts/layout-settings.tsx b/apps/web/components/settings/layouts/layout-settings.tsx new file mode 100644 index 0000000000..d086787b0d --- /dev/null +++ b/apps/web/components/settings/layouts/layout-settings.tsx @@ -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; + +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 ( + <> +
+

+ + Layouts +

+

+ Configure the initial desktop task workbench. +

+
+ + + ); +} + +function ResetBuiltInButton({ onClick }: { onClick: () => void }) { + return ( + + + + + + Restore the original built-in layout and discard its override. + + + ); +} + +function DeleteProfileButton({ onClick }: { onClick: () => void }) { + return ( + + + + + Delete this custom layout after confirmation. + + ); +} + +function SelectedLayoutHeader({ + controller, + onDelete, +}: { + controller: Controller; + onDelete: () => void; +}) { + return ( +
+
+ {controller.selectedCustom ? ( + controller.updateSelected({ name: event.target.value })} + className="min-h-11 max-w-md sm:min-h-9" + /> + ) : ( +
+

{controller.selectedName}

+ Built-in + {controller.selectedBuiltInOverride && Customized} +
+ )} +
+
+ + + + + + + + {defaultActionHelp(controller.selectedSavedDefault, controller.selectedIsDefault)} + + + {controller.selectedBuiltInOverride && ( + + )} + {controller.selectedCustom && } +
+
+ ); +} + +function SelectedLayoutEditor({ controller }: { controller: Controller }) { + const editorKey = `${controller.selection.kind}:${controller.selection.id}:${controller.editorReset}`; + if (!controller.editorLayout) { + return ( + + + Visual editor unavailable + + {controller.compatibility?.issues.map((issue) => issue.message).join(". ")} + + + ); + } + return ( + + ); +} + +function DeleteProfileDialog({ + controller, + open, + onOpenChange, +}: { + controller: Controller; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const confirm = () => { + controller.deleteSelected(); + onOpenChange(false); + }; + return ( + + + + + Delete {controller.selectedCustom?.name ?? "layout profile"}? + + + {controller.selectedCustom?.is_default + ? "The built-in Default layout will become the default." + : "This profile will be removed when you save."} + + + + Cancel + + Delete + + + + + ); +} + +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 ( +
+ + {controller.error && ( + + + Layout profiles were not saved + {controller.error} + + )} +
+ +
+ setDeleteOpen(true)} /> + +
+
+ +
+ ); +} diff --git a/apps/web/components/settings/layouts/use-layout-settings.test.tsx b/apps/web/components/settings/layouts/use-layout-settings.test.tsx new file mode 100644 index 0000000000..01e43909af --- /dev/null +++ b/apps/web/components/settings/layouts/use-layout-settings.test.tsx @@ -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 { + return { + id: PROFILE_ID, + name: ORIGINAL_NAME, + is_default: false, + layout: getBuiltInLayoutProfile("default").layout as unknown as Record, + 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 ( + + {children} + + ); + } + + 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, + }); + }); +}); diff --git a/apps/web/components/settings/layouts/use-layout-settings.ts b/apps/web/components/settings/layouts/use-layout-settings.ts new file mode 100644 index 0000000000..7e541154e3 --- /dev/null +++ b/apps/web/components/settings/layouts/use-layout-settings.ts @@ -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>, 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(() => + defaultSelection(savedLayouts), + ); + const [saveStatus, setSaveStatus] = useState("idle"); + const [error, setError] = useState(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; + +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; + +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) => { + 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; + 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, +) { + 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, + }; +} diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index adbc4b40e4..22e1900676 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -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> = { @@ -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. diff --git a/apps/web/components/task/dockview-panel-content.tsx b/apps/web/components/task/dockview-panel-content.tsx index 1bd899853c..d004d35d00 100644 --- a/apps/web/components/task/dockview-panel-content.tsx +++ b/apps/web/components/task/dockview-panel-content.tsx @@ -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); diff --git a/apps/web/components/task/layout-preset-selector.tsx b/apps/web/components/task/layout-preset-selector.tsx index a62061e2a0..bcb6e08742 100644 --- a/apps/web/components/task/layout-preset-selector.tsx +++ b/apps/web/components/task/layout-preset-selector.tsx @@ -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 = { + 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 ( @@ -175,43 +190,41 @@ function SavedLayoutItems({ }: { layouts: SavedLayout[]; onApply: (layout: SavedLayout) => void | Promise; - onDelete: (layoutId: string) => void; + onDelete: (layout: SavedLayout) => void; }) { if (layouts.length === 0) { return
No saved layouts
; } return layouts.map((layout) => ( - { - void onApply(layout); - }} - > - - {layout.name} - {layout.is_default && ( - - default - - )} - - + + )); } -/** 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, +) { + 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; +}) { + 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 ( + !open && !deleting && onClose()}> + + + Delete {layout?.name ?? "saved layout"}? + + {layout?.is_default + ? "The built-in Default layout will become the default." + : "This saved layout will be permanently removed."} + + + + + Cancel + + { + event.preventDefault(); + void handleDelete(); + }} + > + {deleting ? "Deleting..." : "Delete"} + + + + + ); +} + export function LayoutPresetSelector() { const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [tooltipOpen, setTooltipOpen] = useState(false); + const [deleteCandidate, setDeleteCandidate] = useState(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() { Presets - applyBuiltInPreset(id, true)} /> + + + + Reset Layout + + Saved Layouts !isBuiltInLayoutOverride(layout))} onApply={handleApplyCustom} - onDelete={handleDeleteLayout} + onDelete={setDeleteCandidate} /> @@ -339,6 +438,11 @@ export function LayoutPresetSelector() { + setDeleteCandidate(null)} + onDelete={handleDeleteLayout} + /> ); } diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 4a31fb6676..7f26050a8a 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -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, diff --git a/apps/web/e2e/pages/layout-settings-page.ts b/apps/web/e2e/pages/layout-settings-page.ts new file mode 100644 index 0000000000..da414997dd --- /dev/null +++ b/apps/web/e2e/pages/layout-settings-page.ts @@ -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 { + await this.page.goto("/settings/general/layouts"); + await expect(this.root).toBeVisible(); + } + + async openFromMobileMenu(): Promise { + 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 { + 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 { + await this.editor.locator(".dv-tab", { hasText: name }).click(); + await expect(this.actions).toHaveAccessibleName(`Actions for ${name}`); + } + + async removePanel(name: string): Promise { + 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 { + const input = this.page.getByRole("textbox", { name: "Layout profile name" }); + await expect(input).toBeVisible(); + await input.fill(name); + } + + async moveSelectedTabRight(): Promise { + const button = this.actions.getByRole("button", { name: "Move tab right" }); + await expect(button).toBeEnabled(); + await button.click(); + } + + async save(): Promise { + 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); + } +} diff --git a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts index 54e926374d..0728ef45bb 100644 --- a/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts +++ b/apps/web/e2e/tests/layout/saved-layout-session-isolation.spec.ts @@ -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), { diff --git a/apps/web/e2e/tests/settings/layout-profiles.spec.ts b/apps/web/e2e/tests/settings/layout-profiles.spec.ts new file mode 100644 index 0000000000..56f0c3be11 --- /dev/null +++ b/apps/web/e2e/tests/settings/layout-profiles.spec.ts @@ -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 { + await page.goto(`/t/${taskId}`); + const session = new SessionPage(page); + await session.waitForLoad(); + await session.waitForDockviewReady(); + return session; +} + +async function dockviewSnapshot(page: Page): Promise { + 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 { + 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); + }); +}); diff --git a/apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts b/apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts new file mode 100644 index 0000000000..4f2c93d28c --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-layout-profiles.spec.ts @@ -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"); + }); +}); diff --git a/apps/web/e2e/tests/task/task-default-layout.spec.ts b/apps/web/e2e/tests/task/task-default-layout.spec.ts index 9f799c12ed..dcb3083949 100644 --- a/apps/web/e2e/tests/task/task-default-layout.spec.ts +++ b/apps/web/e2e/tests/task/task-default-layout.spec.ts @@ -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, diff --git a/apps/web/hooks/domains/kanban/use-active-task-has-repos.test.ts b/apps/web/hooks/domains/kanban/use-active-task-has-repos.test.ts deleted file mode 100644 index f35ed4ca2c..0000000000 --- a/apps/web/hooks/domains/kanban/use-active-task-has-repos.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/web/hooks/domains/kanban/use-active-task-has-repos.ts b/apps/web/hooks/domains/kanban/use-active-task-has-repos.ts deleted file mode 100644 index d2a5a23962..0000000000 --- a/apps/web/hooks/domains/kanban/use-active-task-has-repos.ts +++ /dev/null @@ -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; - -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); -} diff --git a/apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.test.ts b/apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.test.ts new file mode 100644 index 0000000000..d894eb1043 --- /dev/null +++ b/apps/web/hooks/domains/session/use-ensure-default-terminal-ordinary.test.ts @@ -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", + }); + }); +}); diff --git a/apps/web/lib/layout/layout-profiles.test.ts b/apps/web/lib/layout/layout-profiles.test.ts new file mode 100644 index 0000000000..3a46153381 --- /dev/null +++ b/apps/web/lib/layout/layout-profiles.test.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/web/lib/layout/layout-profiles.ts b/apps/web/lib/layout/layout-profiles.ts new file mode 100644 index 0000000000..8b5b1ba356 --- /dev/null +++ b/apps/web/lib/layout/layout-profiles.ts @@ -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; + +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; + 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(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, +): BuiltInLayoutProfileId | null { + return ( + BUILT_IN_LAYOUT_PROFILES.find(({ id }) => profile.id === getBuiltInLayoutOverrideId(id))?.id ?? + null + ); +} + +export function isBuiltInLayoutOverride(profile: Pick): 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOptionalNumber(record: Record, 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): 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, +): 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(); + 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): Record { + 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, + 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; + 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 | 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 }; + }); +} diff --git a/apps/web/lib/state/dockview-preset-persistence.test.ts b/apps/web/lib/state/dockview-preset-persistence.test.ts index 320fce69e8..22cb3f19c7 100644 --- a/apps/web/lib/state/dockview-preset-persistence.test.ts +++ b/apps/web/lib/state/dockview-preset-persistence.test.ts @@ -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); diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 289d82276a..0ed4de581d 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -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((set, get) => ({ api: null, activeFilePath: null, @@ -1040,10 +1056,7 @@ export const useDockviewStore = create((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, diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts index 9ade2b2d2a..46c15804b9 100644 --- a/apps/web/lib/state/layout-manager/constants.ts +++ b/apps/web/lib/state/layout-manager/constants.ts @@ -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", diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts index 25dbe9f261..f74ba194d5 100644 --- a/apps/web/lib/state/layout-manager/index.ts +++ b/apps/web/lib/state/layout-manager/index.ts @@ -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 { diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index 584490d903..c2df5aa2fa 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -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); diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index d075263c27..be4323ae8f 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -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 = { ), "/settings/general/editors": () => , "/settings/general/keyboard-shortcuts": () => , + "/settings/general/layouts": () => , "/settings/general/notifications": () => , "/settings/general/resource-metrics": () => ( diff --git a/docs/plans/task-layout-profiles/plan.md b/docs/plans/task-layout-profiles/plan.md new file mode 100644 index 0000000000..21fef5bc08 --- /dev/null +++ b/docs/plans/task-layout-profiles/plan.md @@ -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 +``` diff --git a/docs/plans/task-layout-profiles/task-01-saved-layout-validation.md b/docs/plans/task-layout-profiles/task-01-saved-layout-validation.md new file mode 100644 index 0000000000..dda88cddb1 --- /dev/null +++ b/docs/plans/task-layout-profiles/task-01-saved-layout-validation.md @@ -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. diff --git a/docs/plans/task-layout-profiles/task-02-layout-profile-domain.md b/docs/plans/task-layout-profiles/task-02-layout-profile-domain.md new file mode 100644 index 0000000000..83420e611b --- /dev/null +++ b/docs/plans/task-layout-profiles/task-02-layout-profile-domain.md @@ -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. diff --git a/docs/plans/task-layout-profiles/task-03-layout-settings-editor.md b/docs/plans/task-layout-profiles/task-03-layout-settings-editor.md new file mode 100644 index 0000000000..923a926f4f --- /dev/null +++ b/docs/plans/task-layout-profiles/task-03-layout-settings-editor.md @@ -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. diff --git a/docs/plans/task-layout-profiles/task-04-workbench-default-integration.md b/docs/plans/task-layout-profiles/task-04-workbench-default-integration.md new file mode 100644 index 0000000000..8ff240e33f --- /dev/null +++ b/docs/plans/task-layout-profiles/task-04-workbench-default-integration.md @@ -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. diff --git a/docs/plans/task-layout-profiles/task-05-e2e-docs-verification.md b/docs/plans/task-layout-profiles/task-05-e2e-docs-verification.md new file mode 100644 index 0000000000..d20b6390f2 --- /dev/null +++ b/docs/plans/task-layout-profiles/task-05-e2e-docs-verification.md @@ -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. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index e5403ef85d..b4efbaec8a 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -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", diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index 8460559fdb..ea8b189f08 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 35060c3f2f..bc91dac13a 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 diff --git a/docs/specs/ui/task-layout-profiles.md b/docs/specs/ui/task-layout-profiles.md new file mode 100644 index 0000000000..ddab02a4c4 --- /dev/null +++ b/docs/specs/ui/task-layout-profiles.md @@ -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-`, 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.