feat: pr layout after start (#288)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user