feat: improve layout manager (#290)

This commit is contained in:
Carlos Florêncio
2026-02-26 11:26:30 +00:00
committed by GitHub
parent d02d801794
commit 80fe0bff60
14 changed files with 134 additions and 89 deletions
+9 -1
View File
@@ -298,9 +298,16 @@ function extractInitialScripts(
return initialState?.repositoryScripts?.itemsByRepositoryId?.[repoId] ?? [];
}
export default async function SessionPage({ params }: { params: Promise<{ sessionId: string }> }) {
export default async function SessionPage({
params,
searchParams,
}: {
params: Promise<{ sessionId: string }>;
searchParams: Promise<{ layout?: string }>;
}) {
let fetchedData: FetchedSessionData | null = null;
const defaultLayouts = await readLayoutDefaults();
const { layout: initialLayout } = await searchParams;
try {
const { sessionId: paramSessionId } = await params;
@@ -331,6 +338,7 @@ export default async function SessionPage({ params }: { params: Promise<{ sessio
initialScripts={extractInitialScripts(initialState, task)}
initialTerminals={initialTerminals}
defaultLayouts={defaultLayouts}
initialLayout={initialLayout}
/>
</>
);
@@ -6,9 +6,9 @@ import type { StepType } from "@/components/task-create-dialog-types";
import { selectPreferredBranch } from "@/lib/utils";
import { getLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { useContextFilesStore } from "@/lib/state/context-files-store";
import { linkToSession } from "@/lib/links";
import { INTENT_PLAN } from "@/lib/state/layout-manager";
import { createTask } from "@/lib/api";
type CreateTaskParams = Parameters<typeof createTask>[0];
@@ -76,10 +76,9 @@ export function activatePlanMode({
router,
}: ActivatePlanModeArgs) {
setActiveDocument(sessionId, { type: "plan", taskId });
useDockviewStore.getState().setLayoutIntent({ preset: "plan" });
setPlanMode(sessionId, true);
useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" });
router.push(linkToSession(sessionId));
router.push(linkToSession(sessionId, INTENT_PLAN));
}
export type BuildCreatePayloadArgs = {
@@ -396,8 +396,12 @@ export function useChatPanelState({
[planModeAvailable, rawHandlePlanModeChange, togglePlanLayout, planLayoutVisible],
);
// Auto-disable plan mode if agent doesn't support MCP (e.g. started from create dialog)
// Auto-disable plan mode if agent doesn't support MCP (e.g. started from create dialog).
// Only clear state — do NOT call applyBuiltInPreset("default") because the layout
// may have just been set via URL intent (?layout=plan) and we don't want to overwrite it.
const hasAgentProfile = Boolean(sessionState.session?.agent_profile_id);
const setPlanMode = useAppStore((s) => s.setPlanMode);
const removeCtxFile = useContextFilesStore((s) => s.removeFile);
const hasAutoDisabled = useRef(false);
useEffect(() => {
if (
@@ -408,7 +412,8 @@ export function useChatPanelState({
!hasAutoDisabled.current
) {
hasAutoDisabled.current = true;
rawHandlePlanModeChange(false);
setPlanMode(resolvedSessionId, false);
removeCtxFile(resolvedSessionId, PLAN_CONTEXT_PATH);
}
if (!planModeEnabled) hasAutoDisabled.current = false;
}, [
@@ -416,7 +421,8 @@ export function useChatPanelState({
hasAgentProfile,
planModeAvailable,
resolvedSessionId,
rawHandlePlanModeChange,
setPlanMode,
removeCtxFile,
]);
const contextFilesState = useContextFiles(resolvedSessionId);
@@ -627,11 +627,13 @@ type DockviewDesktopLayoutProps = {
repository?: Repository | null;
initialScripts?: RepositoryScript[];
initialTerminals?: Terminal[];
initialLayout?: string | null;
};
export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
sessionId,
repository,
initialLayout,
}: DockviewDesktopLayoutProps) {
const setApi = useDockviewStore((s) => s.setApi);
const buildDefaultLayout = useDockviewStore((s) => s.buildDefaultLayout);
@@ -672,9 +674,10 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
setApi(api);
const currentSessionId = sessionIdRef.current;
const restored = tryRestoreLayout(api, currentSessionId);
// If a layout intent was passed via URL, skip saved layout restoration
const restored = !initialLayout && tryRestoreLayout(api, currentSessionId);
if (!restored) {
buildDefaultLayout(api);
buildDefaultLayout(api, initialLayout ?? undefined);
}
useDockviewStore.setState({ currentLayoutSessionId: currentSessionId });
@@ -693,7 +696,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
setupLayoutPersistence(api, saveTimerRef, sessionIdRef);
setupPortalCleanup(api);
},
[setApi, buildDefaultLayout],
[setApi, buildDefaultLayout, initialLayout],
);
// Release session-scoped portals + trigger layout switch on session change
@@ -8,7 +8,7 @@ import { TaskSwitcher } from "../task-switcher";
import { WorkspaceSwitcher } from "../workspace-switcher";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { linkToSession } from "@/lib/links";
import { replaceSessionUrl } from "@/lib/links";
import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api";
import { useTasks } from "@/hooks/use-tasks";
import { useTaskActions } from "@/hooks/use-task-actions";
@@ -173,19 +173,11 @@ type SheetNavOptions = {
) => Promise<Array<{ id: string; updated_at?: string | null }>>;
setActiveSession: (taskId: string, sessionId: string) => void;
setActiveTask: (taskId: string) => void;
updateUrl: (sessionId: string) => void;
onOpenChange: (open: boolean) => void;
};
async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) {
const {
store,
loadTaskSessionsForTask,
setActiveSession,
setActiveTask,
updateUrl,
onOpenChange,
} = opts;
const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts;
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } }));
try {
const workflowsResponse = await listWorkflows(newWorkspaceId, { cache: "no-store" });
@@ -220,7 +212,7 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) {
const mostRecentSession = sortByUpdatedAtDesc(sessions)[0];
if (mostRecentSession) {
setActiveSession(mostRecentTask.id, mostRecentSession.id);
updateUrl(mostRecentSession.id);
replaceSessionUrl(mostRecentSession.id);
} else {
setActiveTask(mostRecentTask.id);
}
@@ -233,7 +225,7 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) {
}
function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) {
const { workspaceId, store, setActiveSession, setActiveTask, updateUrl, onOpenChange } = opts;
const { workspaceId, store, setActiveSession, setActiveTask, onOpenChange } = opts;
const handleWorkspaceChange = useCallback(
async (newWorkspaceId: string) => {
@@ -278,11 +270,11 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) {
setActiveTask(task.id);
if (meta?.taskSessionId) {
setActiveSession(task.id, meta.taskSessionId);
updateUrl(meta.taskSessionId);
replaceSessionUrl(meta.taskSessionId);
}
onOpenChange(false);
},
[store, setActiveTask, setActiveSession, updateUrl, onOpenChange],
[store, setActiveTask, setActiveSession, onOpenChange],
);
return { handleWorkspaceChange, handleTaskCreated };
@@ -296,18 +288,13 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea
const { deleteTaskById, archiveTaskById } = useTaskActions();
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
const updateUrl = useCallback((sessionId: string) => {
if (typeof window === "undefined") return;
window.history.replaceState({}, "", linkToSession(sessionId));
}, []);
const handleSelectTask = useCallback(
(taskId: string) => {
const kanbanTasks = store.getState().kanban.tasks;
const task = kanbanTasks.find((t) => t.id === taskId);
if (task?.primarySessionId) {
setActiveSession(taskId, task.primarySessionId);
updateUrl(task.primarySessionId);
replaceSessionUrl(task.primarySessionId);
loadTaskSessionsForTask(taskId);
onOpenChange(false);
return;
@@ -320,11 +307,11 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea
return;
}
setActiveSession(taskId, sessionId);
updateUrl(sessionId);
replaceSessionUrl(sessionId);
onOpenChange(false);
});
},
[loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl, store, onOpenChange],
[loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange],
);
const handleArchiveTask = useCallback(
@@ -358,7 +345,6 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea
loadTaskSessionsForTask,
setActiveSession,
setActiveTask,
updateUrl,
onOpenChange,
});
@@ -13,7 +13,7 @@ import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { TaskCreateDialog } from "../task-create-dialog";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { linkToSession } from "@/lib/links";
import { replaceSessionUrl } from "@/lib/links";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { performLayoutSwitch } from "@/lib/state/dockview-store";
import type { TaskSession, TaskSessionState } from "@/lib/types/http";
@@ -110,20 +110,16 @@ function useRunningSessionsClock(sessions: TaskSession[]) {
function useSessionSelectionHandlers(taskId: string | null) {
const setActiveSession = useAppStore((state) => state.setActiveSession);
const appStore = useAppStoreApi();
const updateUrl = useCallback((sessionId: string) => {
if (typeof window === "undefined") return;
window.history.replaceState({}, "", linkToSession(sessionId));
}, []);
const handleSelectSession = useCallback(
(sessionId: string, close: () => void) => {
if (!taskId) return;
const oldSessionId = appStore.getState().tasks.activeSessionId;
setActiveSession(taskId, sessionId);
performLayoutSwitch(oldSessionId, sessionId);
updateUrl(sessionId);
replaceSessionUrl(sessionId);
close();
},
[appStore, setActiveSession, taskId, updateUrl],
[appStore, setActiveSession, taskId],
);
return { handleSelectSession };
}
+3
View File
@@ -35,6 +35,7 @@ type TaskLayoutProps = {
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
initialLayout?: string | null;
};
export const TaskLayout = memo(function TaskLayout({
@@ -55,6 +56,7 @@ export const TaskLayout = memo(function TaskLayout({
remoteCreatedAt,
remoteCheckedAt,
remoteStatusError,
initialLayout,
}: TaskLayoutProps) {
const { isMobile, isTablet } = useResponsiveBreakpoint();
@@ -101,6 +103,7 @@ export const TaskLayout = memo(function TaskLayout({
repository={repository}
initialScripts={initialScripts}
initialTerminals={initialTerminals}
initialLayout={initialLayout}
/>
);
});
@@ -35,6 +35,7 @@ type TaskPageContentProps = {
initialScripts?: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts?: Record<string, Layout>;
initialLayout?: string | null;
};
function resolveEffectiveTask(
@@ -192,6 +193,7 @@ type TaskPageInnerProps = {
initialScripts: RepositoryScript[];
initialTerminals?: Terminal[];
defaultLayouts: Record<string, Layout>;
initialLayout?: string | null;
};
type RemoteExecutorStatus = {
@@ -273,6 +275,7 @@ function buildTaskLayoutProps(params: {
defaultLayouts: Record<string, Layout>;
merged: ReturnType<typeof useMergedAgentState>;
remote: ReturnType<typeof resolveRemoteExecutor>;
initialLayout?: string | null;
}) {
const { taskProps, repository, effectiveSessionId, initialScripts, initialTerminals } = params;
return {
@@ -283,6 +286,7 @@ function buildTaskLayoutProps(params: {
initialScripts,
initialTerminals,
defaultLayouts: params.defaultLayouts,
initialLayout: params.initialLayout,
taskTitle: taskProps.taskTitle,
baseBranch: taskProps.baseBranch,
worktreeBranch: params.merged.worktreeBranch,
@@ -342,6 +346,7 @@ function TaskPageInner({
initialScripts,
initialTerminals,
defaultLayouts,
initialLayout,
}: TaskPageInnerProps) {
const taskProps = resolveTaskProps(task, repository);
const remote = resolveRemoteExecutor(resumption.sessionStatus as RemoteExecutorStatus | null);
@@ -374,6 +379,7 @@ function TaskPageInner({
defaultLayouts,
merged,
remote,
initialLayout,
});
return (
@@ -493,6 +499,7 @@ export function TaskPageContent({
initialScripts = [],
initialTerminals,
defaultLayouts = {},
initialLayout,
}: TaskPageContentProps) {
const [isMounted, setIsMounted] = useState(false);
const [showDebugOverlay, setShowDebugOverlay] = useState(false);
@@ -537,6 +544,7 @@ export function TaskPageContent({
initialScripts={initialScripts}
initialTerminals={initialTerminals}
defaultLayouts={defaultLayouts}
initialLayout={initialLayout}
/>
);
}
@@ -10,7 +10,7 @@ import { PanelRoot, PanelBody } from "./panel-primitives";
import { IconPlus } from "@tabler/icons-react";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { linkToSession } from "@/lib/links";
import { replaceSessionUrl } from "@/lib/links";
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
import { useTaskActions } from "@/hooks/use-task-actions";
import { useTaskRemoval } from "@/hooks/use-task-removal";
@@ -229,18 +229,13 @@ function useSidebarActions(store: ReturnType<typeof useAppStoreApi>) {
useLayoutSwitch: true,
});
const updateUrl = useCallback((sessionId: string) => {
if (typeof window === "undefined") return;
window.history.replaceState({}, "", linkToSession(sessionId));
}, []);
const switchToSession = useCallback(
(taskId: string, sessionId: string, oldSessionId: string | null | undefined) => {
setActiveSession(taskId, sessionId);
performLayoutSwitch(oldSessionId ?? null, sessionId);
updateUrl(sessionId);
replaceSessionUrl(sessionId);
},
[setActiveSession, updateUrl],
[setActiveSession],
);
const handleSelectTask = useCallback(
@@ -4,9 +4,8 @@ import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { getWebSocketClient } from "@/lib/ws/connection";
import { linkToSession } from "@/lib/links";
import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager";
import { useAppStore } from "@/components/state-provider";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { CENTER_GROUP, RIGHT_TOP_GROUP } from "@/lib/state/layout-manager";
import type { Task } from "@/components/kanban-card";
type NavigationOptions = {
@@ -55,23 +54,6 @@ async function preparePRSession(taskId: string): Promise<string | null> {
}
}
function setPRReviewLayoutIntent(): void {
useDockviewStore.getState().setLayoutIntent({
panels: [
{
id: "pr-detail",
component: "pr-detail",
title: "Pull Request",
targetGroup: "center",
},
],
activePanels: {
[CENTER_GROUP]: "pr-detail",
[RIGHT_TOP_GROUP]: "changes",
},
});
}
export function useKanbanNavigation({
enablePreviewOnClick,
isMobile,
@@ -102,8 +84,7 @@ export function useKanbanNavigation({
if (taskPRs[task.id]) {
const sessionId = await preparePRSession(task.id);
if (sessionId) {
setPRReviewLayoutIntent();
router.push(linkToSession(sessionId));
router.push(linkToSession(sessionId, INTENT_PR_REVIEW));
return;
}
}
+9 -2
View File
@@ -1,5 +1,12 @@
export function linkToSession(sessionId: string): string {
return `/s/${sessionId}`;
export function linkToSession(sessionId: string, layout?: string): string {
const base = `/s/${sessionId}`;
return layout ? `${base}?layout=${encodeURIComponent(layout)}` : base;
}
/** Replace the browser URL to reflect the active session (no navigation). */
export function replaceSessionUrl(sessionId: string): void {
if (typeof window === "undefined") return;
window.history.replaceState({}, "", linkToSession(sessionId));
}
export function linkToTasks(workspaceId?: string): string {
+35 -16
View File
@@ -15,8 +15,12 @@ import {
defaultLayout,
mergeCurrentPanelsIntoPreset,
} from "./layout-manager";
import type { BuiltInPreset, LayoutState, LayoutGroupIds, LayoutIntent } from "./layout-manager";
import { injectIntentPanels, applyActivePanelOverrides } from "./layout-manager";
import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager";
import {
injectIntentPanels,
applyActivePanelOverrides,
resolveNamedIntent,
} from "./layout-manager";
import { buildFileStateActions } from "./dockview-file-state";
import { buildPanelActions, buildExtraPanelActions } from "./dockview-panel-actions";
@@ -73,7 +77,7 @@ type DockviewStore = {
updateFileState: (path: string, updates: Partial<FileEditorState>) => void;
removeFileState: (path: string) => void;
clearFileStates: () => void;
buildDefaultLayout: (api: DockviewApi) => void;
buildDefaultLayout: (api: DockviewApi, intentName?: string) => void;
resetLayout: () => void;
addChatPanel: () => void;
addChangesPanel: (groupId?: string) => void;
@@ -113,8 +117,6 @@ type DockviewStore = {
setPinnedWidth: (columnId: string, width: number) => void;
userDefaultLayout: LayoutState | null;
setUserDefaultLayout: (layout: LayoutState | null) => void;
layoutIntent: LayoutIntent | null;
setLayoutIntent: (intent: LayoutIntent | null) => void;
activeFilePath: string | null;
pendingChatScrollTop: number | null;
setPendingChatScrollTop: (value: number | null) => void;
@@ -374,21 +376,27 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
};
}
function performBuildDefault(api: DockviewApi, set: StoreSet, get: StoreGet): void {
const { userDefaultLayout, layoutIntent } = get();
function performBuildDefault(
api: DockviewApi,
set: StoreSet,
get: StoreGet,
intentName?: string,
): void {
const { userDefaultLayout } = get();
const intent = intentName ? resolveNamedIntent(intentName) : null;
const freshPinned = new Map<string, number>();
set({ isRestoringLayout: true, pinnedWidths: freshPinned, layoutIntent: null });
set({ isRestoringLayout: true, pinnedWidths: freshPinned });
const basePreset = layoutIntent?.preset as BuiltInPreset | undefined;
const basePreset = intent?.preset as BuiltInPreset | undefined;
let state = basePreset
? getPresetLayout(basePreset)
: (userDefaultLayout ?? getPresetLayout("default"));
if (layoutIntent?.panels?.length) {
state = injectIntentPanels(state, layoutIntent.panels);
if (intent?.panels?.length) {
state = injectIntentPanels(state, intent.panels);
}
if (layoutIntent?.activePanels) {
state = applyActivePanelOverrides(state, layoutIntent.activePanels);
if (intent?.activePanels) {
state = applyActivePanelOverrides(state, intent.activePanels);
}
const ids = applyLayout(api, state, freshPinned);
@@ -441,8 +449,6 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
},
userDefaultLayout: null,
setUserDefaultLayout: (layout) => set({ userDefaultLayout: layout }),
layoutIntent: null,
setLayoutIntent: (intent) => set({ layoutIntent: intent }),
...buildVisibilityActions(set, get),
...buildPresetActions(set, get),
isRestoringLayout: false,
@@ -455,6 +461,19 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
switchSessionLayout: (oldSessionId, newSessionId) => {
const { api, currentLayoutSessionId } = get();
if (!api || currentLayoutSessionId === newSessionId) return;
// When the first session becomes active (oldSessionId is null and no prior
// layout session), onReady already built the correct layout (possibly with
// an intent). Just adopt the current layout for the new session without
// rebuilding, which would lose the intent-based layout.
if (!oldSessionId && !currentLayoutSessionId) {
set({ currentLayoutSessionId: newSessionId });
try {
setSessionLayout(newSessionId, api.toJSON());
} catch {
/* ignore */
}
return;
}
const safeWidth = api.width;
const safeHeight = api.height;
set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId });
@@ -472,7 +491,7 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
set({ isRestoringLayout: false });
}
},
buildDefaultLayout: (api) => performBuildDefault(api, set, get),
buildDefaultLayout: (api, intentName) => performBuildDefault(api, set, get, intentName),
resetLayout: () => {
const { api } = get();
if (api) get().buildDefaultLayout(api);
+7 -1
View File
@@ -46,4 +46,10 @@ export type { LayoutGroupIds } from "./applier";
export { mergeCurrentPanelsIntoPreset } from "./merger";
// Intent
export { injectIntentPanels, applyActivePanelOverrides } from "./intent";
export {
INTENT_PLAN,
INTENT_PR_REVIEW,
injectIntentPanels,
applyActivePanelOverrides,
resolveNamedIntent,
} from "./intent";
+29 -1
View File
@@ -1,6 +1,34 @@
import type { LayoutState, LayoutIntentPanel, LayoutPanel } from "./types";
import type { LayoutState, LayoutIntent, LayoutIntentPanel, LayoutPanel } from "./types";
import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP } from "./constants";
/** Well-known intent name constants (used as URL ?layout= values). */
export const INTENT_PLAN = "plan";
export const INTENT_PR_REVIEW = "pr-review";
/** Registry of named layout intents, keyed by URL param value. */
const NAMED_INTENTS: Record<string, LayoutIntent> = {
[INTENT_PLAN]: { preset: "plan" },
[INTENT_PR_REVIEW]: {
panels: [
{
id: "pr-detail",
component: "pr-detail",
title: "Pull Request",
targetGroup: "center",
},
],
activePanels: {
[CENTER_GROUP]: "pr-detail",
[RIGHT_TOP_GROUP]: "changes",
},
},
};
/** Resolve a named intent string (from URL param) to a LayoutIntent, or null if unknown. */
export function resolveNamedIntent(name: string): LayoutIntent | null {
return NAMED_INTENTS[name] ?? null;
}
/** Map short aliases to well-known group IDs. */
const GROUP_ALIASES: Record<string, string> = {
center: CENTER_GROUP,