feat: pr layout after start (#288)

This commit is contained in:
Carlos Florêncio
2026-02-26 09:28:05 +00:00
committed by GitHub
parent 2782d826ec
commit b48305c4e0
16 changed files with 403 additions and 218 deletions
@@ -237,7 +237,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag
}
if execution.SessionID != "" {
s.updateTaskSessionState(ctx, taskID, execution.SessionID, models.TaskSessionStateRunning, "", true)
s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, false)
}
return execution, nil
@@ -76,13 +76,7 @@ export function activatePlanMode({
router,
}: ActivatePlanModeArgs) {
setActiveDocument(sessionId, { type: "plan", taskId });
useDockviewStore.getState().queuePanelAction({
id: "plan",
component: "plan",
title: "Plan",
placement: "right",
referencePanel: "chat",
});
useDockviewStore.getState().setLayoutIntent({ preset: "plan" });
setPlanMode(sessionId, true);
useContextFilesStore.getState().addFile(sessionId, { path: "plan:context", name: "Plan" });
router.push(linkToSession(sessionId));
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, memo } from "react";
import { SessionPanelContent } from "@kandev/ui/pannel-session";
import { useDockviewStore } from "@/lib/state/dockview-store";
import type { Message } from "@/lib/types/http";
import { AgentStatus } from "@/components/task/chat/messages/agent-status";
import { PrepareProgress } from "@/components/session/prepare-progress";
@@ -153,7 +154,10 @@ function useAutoScroll(
// Auto-scroll on new messages if near bottom
useLayoutEffect(() => {
const el = scrollRef.current;
if (el && isNearBottomRef.current) {
if (!el) return;
// Skip auto-scroll when a layout rebuild scroll restore is pending
if (useDockviewStore.getState().pendingChatScrollTop !== null) return;
if (isNearBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages, scrollRef]);
@@ -176,6 +180,7 @@ export const NativeMessageList = memo(function NativeMessageList({
messagesLoading,
isWorking,
sessionState,
taskState,
worktreePath,
onOpenFile,
}: MessageListProps) {
@@ -199,10 +204,15 @@ export const NativeMessageList = memo(function NativeMessageList({
useEffect(() => {
if (didInitialScroll.current || items.length === 0) return;
const el = scrollRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
if (!el) return;
// If a layout rebuild scroll restore is pending, skip initial scroll
// (the restore handler will set the correct position)
if (useDockviewStore.getState().pendingChatScrollTop !== null) {
didInitialScroll.current = true;
return;
}
el.scrollTop = el.scrollHeight;
didInitialScroll.current = true;
}, [items.length]);
return (
@@ -235,6 +245,7 @@ export const NativeMessageList = memo(function NativeMessageList({
isTurnActive={isRunning}
messages={messages}
sessionState={sessionState}
taskState={taskState}
onScrollToMessage={handleScrollToMessage}
/>
</div>
@@ -1,7 +1,7 @@
"use client";
import { GridSpinner } from "@/components/grid-spinner";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { Message, TaskSessionState, TaskState } from "@/lib/types/http";
import type { RenderItem } from "@/hooks/use-processed-messages";
import { MessageRenderer } from "@/components/task/chat/message-renderer";
import { TurnGroupMessage } from "@/components/task/chat/messages/turn-group-message";
@@ -16,6 +16,7 @@ export type MessageListProps = {
messagesLoading: boolean;
isWorking: boolean;
sessionState?: TaskSessionState;
taskState?: TaskState;
worktreePath?: string;
onOpenFile?: (path: string) => void;
};
@@ -85,6 +86,7 @@ export function MessageItem({
isTurnActive,
messages,
sessionState,
taskState,
onScrollToMessage,
}: {
item: RenderItem;
@@ -98,6 +100,7 @@ export function MessageItem({
isTurnActive: boolean;
messages: Message[];
sessionState?: TaskSessionState;
taskState?: TaskState;
onScrollToMessage: (id: string) => void;
}) {
if (item.type === "turn_group") {
@@ -122,6 +125,7 @@ export function MessageItem({
comment={item.message}
isTaskDescription={item.message.id === "task-description"}
sessionState={sessionState}
taskState={taskState}
taskId={taskId}
permissionsByToolCallId={permissionsByToolCallId}
childrenByParentToolCallId={childrenByParentToolCallId}
@@ -70,7 +70,15 @@ function useStableFirstItemIndex(items: RenderItem[]) {
function useVirtuosoCallbacks(props: VirtuosoBodyProps) {
const { items, sessionId, permissionsByToolCallId, childrenByParentToolCallId, taskId } = props;
const { worktreePath, onOpenFile, lastTurnGroupId, isRunning, messages, sessionState } = props;
const {
worktreePath,
onOpenFile,
lastTurnGroupId,
isRunning,
messages,
sessionState,
taskState,
} = props;
const { hasMore, isLoadingMore, loadMore } = props;
const virtuosoRef = useRef<VirtuosoHandle>(null);
const itemCount = items.length;
@@ -128,6 +136,7 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) {
isTurnActive={isRunning}
messages={messages}
sessionState={sessionState}
taskState={taskState}
onScrollToMessage={handleScrollToMessage}
/>
</div>
@@ -146,6 +155,7 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) {
isRunning,
messages,
sessionState,
taskState,
handleScrollToMessage,
],
);
@@ -3,7 +3,7 @@
import { memo, useState, useCallback, type ReactElement } from "react";
import { IconPlayerPlay } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { Message, TaskSessionState, TaskState } from "@/lib/types/http";
import type { ToolCallMetadata } from "@/components/task/chat/types";
import { getWebSocketClient } from "@/lib/ws/connection";
import { ChatMessage } from "@/components/task/chat/messages/chat-message";
@@ -23,6 +23,7 @@ import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subage
type AdapterContext = {
isTaskDescription: boolean;
sessionState?: TaskSessionState;
taskState?: TaskState;
taskId?: string;
permissionsByToolCallId?: Map<string, Message>;
childrenByParentToolCallId?: Map<string, Message[]>;
@@ -195,7 +196,11 @@ const adapters: MessageAdapter[] = [
(ctx.isTaskDescription && ctx.sessionState !== "FAILED")
) {
const showStartButton =
ctx.isTaskDescription && ctx.sessionState === "CREATED" && ctx.taskId && ctx.sessionId;
ctx.isTaskDescription &&
ctx.sessionState === "CREATED" &&
ctx.taskState !== "SCHEDULING" &&
ctx.taskId &&
ctx.sessionId;
return (
<>
<ChatMessage
@@ -229,6 +234,7 @@ type MessageRendererProps = {
comment: Message;
isTaskDescription: boolean;
sessionState?: TaskSessionState;
taskState?: TaskState;
taskId?: string;
permissionsByToolCallId?: Map<string, Message>;
childrenByParentToolCallId?: Map<string, Message[]>;
@@ -243,6 +249,7 @@ export const MessageRenderer = memo(function MessageRenderer({
comment,
isTaskDescription,
sessionState,
taskState,
taskId,
permissionsByToolCallId,
childrenByParentToolCallId,
@@ -255,6 +262,7 @@ export const MessageRenderer = memo(function MessageRenderer({
const ctx = {
isTaskDescription,
sessionState,
taskState,
taskId,
permissionsByToolCallId,
childrenByParentToolCallId,
@@ -5,6 +5,7 @@ import { useAppStore } from "@/components/state-provider";
import { getLocalStorage } from "@/lib/local-storage";
import { useLayoutStore } from "@/lib/state/layout-store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { preserveChatScrollDuringLayout } from "@/lib/state/dockview-scroll-preserve";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useSessionMessages } from "@/hooks/domains/session/use-session-messages";
import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
@@ -69,19 +70,13 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n
}
}, [resolvedSessionId, setPlanMode, addContextFile]);
// Re-focus chat input and preserve scroll position after dockview layout rebuild
// Preserve scroll position and re-focus chat input after dockview layout rebuild
const refocusChatAfterLayout = useCallback(() => {
// Capture scroll position before layout rebuild completes
const scrollEl = document.querySelector<HTMLElement>(".chat-message-list");
const savedScrollTop = scrollEl?.scrollTop ?? 0;
preserveChatScrollDuringLayout();
const unsub = useDockviewStore.subscribe((state) => {
if (!state.isRestoringLayout) {
unsub();
requestAnimationFrame(() => {
// Restore chat scroll position after layout rebuild
const el = document.querySelector<HTMLElement>(".chat-message-list");
if (el) el.scrollTop = savedScrollTop;
document.querySelector<HTMLElement>(".tiptap.ProseMirror")?.focus();
});
}
@@ -81,6 +81,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
const {
resolvedSessionId,
session,
task,
taskId,
isWorking,
messagesLoading,
@@ -120,6 +121,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
messagesLoading={messagesLoading}
isWorking={isWorking}
sessionState={session?.state}
taskState={task?.state}
worktreePath={session?.worktree_path}
onOpenFile={onOpenFile}
/>
@@ -5,6 +5,8 @@ import { useRouter } from "next/navigation";
import { getWebSocketClient } from "@/lib/ws/connection";
import { linkToSession } from "@/lib/links";
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 = {
@@ -53,6 +55,23 @@ 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,
@@ -83,6 +102,7 @@ export function useKanbanNavigation({
if (taskPRs[task.id]) {
const sessionId = await preparePRSession(task.id);
if (sessionId) {
setPRReviewLayoutIntent();
router.push(linkToSession(sessionId));
return;
}
@@ -0,0 +1,177 @@
import type { DockviewApi } from "dockview-react";
import { focusOrAddPanel } from "./dockview-layout-builders";
type StoreGet = () => {
api: DockviewApi | null;
centerGroupId: string;
rightTopGroupId: string;
rightBottomGroupId: string;
selectedDiff: { path: string; content?: string } | null;
};
type StoreSet = (
partial: Partial<{ selectedDiff: { path: string; content?: string } | null }>,
) => void;
type SimplePanelOpts = {
id: string;
component: string;
title: string;
params?: Record<string, unknown>;
};
function addSimplePanel(api: DockviewApi, groupId: string, opts: SimplePanelOpts): void {
focusOrAddPanel(api, { ...opts, position: { referenceGroup: groupId } });
}
export function buildPanelActions(set: StoreSet, get: StoreGet) {
const getFileName = (path: string) => path.split("/").pop() || path;
return {
addChatPanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "chat",
component: "chat",
tabComponent: "permanentTab",
title: "Agent",
position: { referenceGroup: centerGroupId },
});
},
addChangesPanel: (groupId?: string) => {
const { api, rightTopGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "changes",
component: "changes",
title: "Changes",
});
},
addFilesPanel: (groupId?: string) => {
const { api, rightTopGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "files",
component: "files",
title: "Files",
});
},
addDiffViewerPanel: (path?: string, content?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
if (path) set({ selectedDiff: { path, content } });
addSimplePanel(api, groupId ?? centerGroupId, {
id: "diff-viewer",
component: "diff-viewer",
title: "Diff Viewer",
params: { kind: "all" },
});
},
addFileDiffPanel: (path: string, content?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? centerGroupId, {
id: `diff:file:${path}`,
component: "diff-viewer",
title: `Diff [${getFileName(path)}]`,
params: { kind: "file", path, content },
});
},
addCommitDetailPanel: (sha: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? centerGroupId, {
id: `commit:${sha}`,
component: "commit-detail",
title: sha.slice(0, 7),
params: { commitSha: sha },
});
},
addFileEditorPanel: (path: string, name: string, quiet?: boolean) => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(
api,
{
id: `file:${path}`,
component: "file-editor",
title: name,
params: { path },
position: { referenceGroup: centerGroupId },
},
quiet,
);
},
addBrowserPanel: (url?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
const browserId = url ? `browser:${url}` : `browser:${Date.now()}`;
addSimplePanel(api, groupId ?? centerGroupId, {
id: browserId,
component: "browser",
title: "Browser",
params: { url: url ?? "" },
});
},
};
}
export function buildExtraPanelActions(get: StoreGet) {
return {
addVscodePanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
position: { referenceGroup: centerGroupId },
});
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
openInternalVscode: (_goto: { file: string; line: number; col: number } | null) => {
const { api } = get();
if (!api) return;
const existing = api.getPanel("vscode");
if (existing) {
existing.api.setActive();
return;
}
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
position: { referencePanel: "chat", direction: "right" },
});
},
addPlanPanel: (groupId?: string) => {
const { api } = get();
if (!api) return;
const position = groupId
? { referenceGroup: groupId }
: { referencePanel: "chat" as const, direction: "right" as const };
focusOrAddPanel(api, { id: "plan", component: "plan", title: "Plan", position });
},
addPRPanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "pr-detail",
component: "pr-detail",
title: "Pull Request",
position: { referenceGroup: centerGroupId },
});
},
addTerminalPanel: (terminalId?: string, groupId?: string) => {
const { api, rightBottomGroupId } = get();
if (!api) return;
const id = terminalId ?? `terminal-${Date.now()}`;
addSimplePanel(api, groupId ?? rightBottomGroupId, {
id,
component: "terminal",
title: "Terminal",
params: { terminalId: id },
});
},
};
}
@@ -0,0 +1,24 @@
import { useDockviewStore } from "./dockview-store";
/**
* Capture the current chat scroll position and restore it after the next
* layout rebuild completes (isRestoringLayout transitions to false).
* Safe to call from non-React code (WS handlers, store actions).
*/
export function preserveChatScrollDuringLayout(): void {
const scrollEl = document.querySelector<HTMLElement>(".chat-message-list");
const savedScrollTop = scrollEl?.scrollTop ?? 0;
useDockviewStore.getState().setPendingChatScrollTop(savedScrollTop);
const unsub = useDockviewStore.subscribe((state) => {
if (!state.isRestoringLayout) {
unsub();
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(".chat-message-list");
if (el) el.scrollTop = savedScrollTop;
useDockviewStore.getState().setPendingChatScrollTop(null);
});
}
});
}
+40 -194
View File
@@ -15,8 +15,10 @@ import {
defaultLayout,
mergeCurrentPanelsIntoPreset,
} from "./layout-manager";
import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager";
import type { BuiltInPreset, LayoutState, LayoutGroupIds, LayoutIntent } from "./layout-manager";
import { injectIntentPanels, applyActivePanelOverrides } from "./layout-manager";
import { buildFileStateActions } from "./dockview-file-state";
import { buildPanelActions, buildExtraPanelActions } from "./dockview-panel-actions";
// Re-export types and constants used by other modules
export type { BuiltInPreset } from "./layout-manager";
@@ -111,7 +113,11 @@ 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;
};
type StoreGet = () => DockviewStore;
@@ -368,170 +374,38 @@ function buildPresetActions(set: StoreSet, get: StoreGet) {
};
}
type SimplePanelOpts = {
id: string;
component: string;
title: string;
params?: Record<string, unknown>;
};
function performBuildDefault(api: DockviewApi, set: StoreSet, get: StoreGet): void {
const { userDefaultLayout, layoutIntent } = get();
const freshPinned = new Map<string, number>();
set({ isRestoringLayout: true, pinnedWidths: freshPinned, layoutIntent: null });
function addSimplePanel(api: DockviewApi, groupId: string, opts: SimplePanelOpts): void {
focusOrAddPanel(api, { ...opts, position: { referenceGroup: groupId } });
}
const basePreset = layoutIntent?.preset as BuiltInPreset | undefined;
let state = basePreset
? getPresetLayout(basePreset)
: (userDefaultLayout ?? getPresetLayout("default"));
function buildPanelActions(set: StoreSet, get: StoreGet) {
const getFileName = (path: string) => path.split("/").pop() || path;
if (layoutIntent?.panels?.length) {
state = injectIntentPanels(state, layoutIntent.panels);
}
if (layoutIntent?.activePanels) {
state = applyActivePanelOverrides(state, layoutIntent.activePanels);
}
return {
addChatPanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "chat",
component: "chat",
tabComponent: "permanentTab",
title: "Agent",
position: { referenceGroup: centerGroupId },
});
},
addChangesPanel: (groupId?: string) => {
const { api, rightTopGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "changes",
component: "changes",
title: "Changes",
});
},
addFilesPanel: (groupId?: string) => {
const { api, rightTopGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? rightTopGroupId, {
id: "files",
component: "files",
title: "Files",
});
},
addDiffViewerPanel: (path?: string, content?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
if (path) set({ selectedDiff: { path, content } });
addSimplePanel(api, groupId ?? centerGroupId, {
id: "diff-viewer",
component: "diff-viewer",
title: "Diff Viewer",
params: { kind: "all" },
});
},
addFileDiffPanel: (path: string, content?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? centerGroupId, {
id: `diff:file:${path}`,
component: "diff-viewer",
title: `Diff [${getFileName(path)}]`,
params: { kind: "file", path, content },
});
},
addCommitDetailPanel: (sha: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
addSimplePanel(api, groupId ?? centerGroupId, {
id: `commit:${sha}`,
component: "commit-detail",
title: sha.slice(0, 7),
params: { commitSha: sha },
});
},
addFileEditorPanel: (path: string, name: string, quiet?: boolean) => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(
api,
{
id: `file:${path}`,
component: "file-editor",
title: name,
params: { path },
position: { referenceGroup: centerGroupId },
},
quiet,
);
},
addBrowserPanel: (url?: string, groupId?: string) => {
const { api, centerGroupId } = get();
if (!api) return;
const browserId = url ? `browser:${url}` : `browser:${Date.now()}`;
addSimplePanel(api, groupId ?? centerGroupId, {
id: browserId,
component: "browser",
title: "Browser",
params: { url: url ?? "" },
});
},
};
}
const ids = applyLayout(api, state, freshPinned);
const hasSidebar = state.columns.some((c) => c.id === "sidebar");
const hasRight = state.columns.length > (hasSidebar ? 2 : 1);
set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
function buildExtraPanelActions(_set: StoreSet, get: StoreGet) {
return {
addVscodePanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
position: { referenceGroup: centerGroupId },
});
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
openInternalVscode: (_goto: { file: string; line: number; col: number } | null) => {
const { api } = get();
if (!api) return;
const pending = get().deferredPanelActions;
if (pending.length > 0) {
set({ deferredPanelActions: [] });
applyDeferredPanelActions(api, pending);
}
const existing = api.getPanel("vscode");
if (existing) {
existing.api.setActive();
return;
}
focusOrAddPanel(api, {
id: "vscode",
component: "vscode",
title: "VS Code",
position: { referencePanel: "chat", direction: "right" },
});
},
addPlanPanel: (groupId?: string) => {
const { api } = get();
if (!api) return;
const position = groupId
? { referenceGroup: groupId }
: { referencePanel: "chat" as const, direction: "right" as const };
focusOrAddPanel(api, { id: "plan", component: "plan", title: "Plan", position });
},
addPRPanel: () => {
const { api, centerGroupId } = get();
if (!api) return;
focusOrAddPanel(api, {
id: "pr-detail",
component: "pr-detail",
title: "Pull Request",
position: { referenceGroup: centerGroupId },
});
},
addTerminalPanel: (terminalId?: string, groupId?: string) => {
const { api, rightBottomGroupId } = get();
if (!api) return;
const id = terminalId ?? `terminal-${Date.now()}`;
addSimplePanel(api, groupId ?? rightBottomGroupId, {
id,
component: "terminal",
title: "Terminal",
params: { terminalId: id },
});
},
};
requestAnimationFrame(() => {
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
}
export const useDockviewStore = create<DockviewStore>((set, get) => ({
@@ -567,6 +441,8 @@ 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,
@@ -596,45 +472,15 @@ export const useDockviewStore = create<DockviewStore>((set, get) => ({
set({ isRestoringLayout: false });
}
},
buildDefaultLayout: (api) => {
const { userDefaultLayout } = get();
// Clear pinned overrides so fresh default gets clean preset widths
const freshPinned = new Map<string, number>();
set({ isRestoringLayout: true, pinnedWidths: freshPinned });
// Check if plan mode was queued before navigation (e.g. task created in plan mode).
// If so, use the plan preset directly and consume the deferred actions.
const pending = get().deferredPanelActions;
const hasPlanAction = pending.some((a) => a.id === "plan");
if (hasPlanAction) {
set({ deferredPanelActions: [] });
const planState = getPresetLayout("plan");
const ids = applyLayout(api, planState, freshPinned);
set({ ...ids, sidebarVisible: true, rightPanelsVisible: false });
// Apply any remaining non-plan deferred actions
const remaining = pending.filter((a) => a.id !== "plan");
if (remaining.length > 0) applyDeferredPanelActions(api, remaining);
} else {
const state = userDefaultLayout ?? getPresetLayout("default");
const ids = applyLayout(api, state, freshPinned);
const hasSidebar = state.columns.some((c) => c.id === "sidebar");
const hasRight = state.columns.length > (hasSidebar ? 2 : 1);
set({ ...ids, sidebarVisible: hasSidebar, rightPanelsVisible: hasRight });
if (pending.length > 0) {
set({ deferredPanelActions: [] });
applyDeferredPanelActions(api, pending);
}
}
requestAnimationFrame(() => {
syncPinnedWidthsFromApi(api, set);
set({ isRestoringLayout: false });
});
},
buildDefaultLayout: (api) => performBuildDefault(api, set, get),
resetLayout: () => {
const { api } = get();
if (api) get().buildDefaultLayout(api);
},
pendingChatScrollTop: null,
setPendingChatScrollTop: (value) => set({ pendingChatScrollTop: value }),
...buildPanelActions(set, get),
...buildExtraPanelActions(set, get),
...buildExtraPanelActions(get),
}));
/** Perform a layout switch between sessions. */
@@ -7,6 +7,8 @@ export type {
LayoutNode,
LayoutLeafNode,
LayoutBranchNode,
LayoutIntent,
LayoutIntentPanel,
} from "./types";
// Constants
@@ -42,3 +44,6 @@ export type { LayoutGroupIds } from "./applier";
// Merger
export { mergeCurrentPanelsIntoPreset } from "./merger";
// Intent
export { injectIntentPanels, applyActivePanelOverrides } from "./intent";
@@ -0,0 +1,61 @@
import type { LayoutState, LayoutIntentPanel, LayoutPanel } from "./types";
import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP } from "./constants";
/** Map short aliases to well-known group IDs. */
const GROUP_ALIASES: Record<string, string> = {
center: CENTER_GROUP,
"right-top": RIGHT_TOP_GROUP,
"right-bottom": RIGHT_BOTTOM_GROUP,
};
function resolveGroupId(target: string): string {
return GROUP_ALIASES[target] ?? target;
}
/** Inject LayoutIntentPanels into the matching groups of a LayoutState. */
export function injectIntentPanels(state: LayoutState, panels: LayoutIntentPanel[]): LayoutState {
const columns = state.columns.map((col) => {
const groups = col.groups.map((group) => {
const matching = panels.filter((p) => {
const targetId = resolveGroupId(p.targetGroup ?? "center");
if (group.id === targetId) return true;
// Fallback: match center column's first group when no explicit group ID
if (targetId === CENTER_GROUP && col.id === "center" && col.groups[0] === group) {
return true;
}
return false;
});
if (matching.length === 0) return group;
const newPanels: LayoutPanel[] = matching.map((p) => ({
id: p.id,
component: p.component,
title: p.title,
...(p.tabComponent ? { tabComponent: p.tabComponent } : {}),
...(p.params ? { params: p.params } : {}),
}));
return { ...group, panels: [...group.panels, ...newPanels] };
});
return { ...col, groups };
});
return { columns };
}
/** Set activePanel on groups that match the override map. */
export function applyActivePanelOverrides(
state: LayoutState,
overrides: Record<string, string>,
): LayoutState {
const columns = state.columns.map((col) => {
const groups = col.groups.map((group) => {
if (!group.id || !overrides[group.id]) return group;
const targetPanelId = overrides[group.id];
if (!group.panels.some((p) => p.id === targetPanelId)) return group;
return { ...group, activePanel: targetPanelId };
});
return { ...col, groups };
});
return { columns };
}
@@ -50,3 +50,30 @@ export type LayoutColumn = {
export type LayoutState = {
columns: LayoutColumn[];
};
// ─── Layout Intent ──────────────────────────────────────────────────────────
/** A panel to inject into the layout as part of an intent. */
export type LayoutIntentPanel = {
id: string;
component: string;
title: string;
tabComponent?: string;
params?: Record<string, unknown>;
/** Which group to add this panel to. Accepts a group ID (e.g. "group-center")
* or an alias: "center", "right-top", "right-bottom". Defaults to "center". */
targetGroup?: string;
};
/**
* Declarative description of the desired initial layout for a new session.
* Set before navigation, consumed by buildDefaultLayout on first render.
*/
export type LayoutIntent = {
/** Base preset to use. If omitted, uses userDefaultLayout or "default". */
preset?: string;
/** Additional panels to add after the base layout is applied. */
panels?: LayoutIntentPanel[];
/** Map of group ID → panel ID to set as the active tab in that group. */
activePanels?: Record<string, string>;
};
+2 -1
View File
@@ -18,9 +18,10 @@ function handlePlanUpsert(store: StoreApi<AppState>, message: PlanMessage) {
updated_at,
});
// Auto-open plan panel when agent creates/updates a plan for the active task
// Auto-open plan panel side-by-side with chat when agent writes a plan
if (created_by === "agent" && task_id === store.getState().tasks.activeTaskId) {
const dockview = useDockviewStore.getState();
if (dockview.isRestoringLayout) return;
if (dockview.api?.getPanel("plan")) return;
const activeSessionId = store.getState().tasks.activeSessionId;