diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index b1bbd43902..2d29434823 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -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 diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 5d325e0bd3..8a7909f1dc 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -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)); diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 167cc6c78a..a9e9b7b979 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -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} /> diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index 274c4fa091..9bf3b6ecf5 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -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} diff --git a/apps/web/components/task/chat/message-list-virtuoso.tsx b/apps/web/components/task/chat/message-list-virtuoso.tsx index 7715d83672..a223f0cdb7 100644 --- a/apps/web/components/task/chat/message-list-virtuoso.tsx +++ b/apps/web/components/task/chat/message-list-virtuoso.tsx @@ -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(null); const itemCount = items.length; @@ -128,6 +136,7 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) { isTurnActive={isRunning} messages={messages} sessionState={sessionState} + taskState={taskState} onScrollToMessage={handleScrollToMessage} /> @@ -146,6 +155,7 @@ function useVirtuosoCallbacks(props: VirtuosoBodyProps) { isRunning, messages, sessionState, + taskState, handleScrollToMessage, ], ); diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index ecb70e34f6..d8a440da68 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -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; childrenByParentToolCallId?: Map; @@ -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 ( <> ; childrenByParentToolCallId?: Map; @@ -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, diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index b3ff972f03..36f49733c5 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -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(".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(".chat-message-list"); - if (el) el.scrollTop = savedScrollTop; document.querySelector(".tiptap.ProseMirror")?.focus(); }); } diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 01122dccc8..14a0089541 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -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} /> diff --git a/apps/web/hooks/domains/kanban/use-kanban-navigation.ts b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts index b98a8678ae..56f29ac9aa 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-navigation.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-navigation.ts @@ -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 { } } +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; } diff --git a/apps/web/lib/state/dockview-panel-actions.ts b/apps/web/lib/state/dockview-panel-actions.ts new file mode 100644 index 0000000000..b7165ea039 --- /dev/null +++ b/apps/web/lib/state/dockview-panel-actions.ts @@ -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; +}; + +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 }, + }); + }, + }; +} diff --git a/apps/web/lib/state/dockview-scroll-preserve.ts b/apps/web/lib/state/dockview-scroll-preserve.ts new file mode 100644 index 0000000000..1f5b573630 --- /dev/null +++ b/apps/web/lib/state/dockview-scroll-preserve.ts @@ -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(".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(".chat-message-list"); + if (el) el.scrollTop = savedScrollTop; + useDockviewStore.getState().setPendingChatScrollTop(null); + }); + } + }); +} diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index eee98c9655..2ea5e13b8d 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -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; -}; +function performBuildDefault(api: DockviewApi, set: StoreSet, get: StoreGet): void { + const { userDefaultLayout, layoutIntent } = get(); + const freshPinned = new Map(); + 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((set, get) => ({ @@ -567,6 +441,8 @@ export const useDockviewStore = create((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((set, get) => ({ set({ isRestoringLayout: false }); } }, - buildDefaultLayout: (api) => { - const { userDefaultLayout } = get(); - // Clear pinned overrides so fresh default gets clean preset widths - const freshPinned = new Map(); - 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. */ diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts index b11a4c8f48..e0e0191d32 100644 --- a/apps/web/lib/state/layout-manager/index.ts +++ b/apps/web/lib/state/layout-manager/index.ts @@ -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"; diff --git a/apps/web/lib/state/layout-manager/intent.ts b/apps/web/lib/state/layout-manager/intent.ts new file mode 100644 index 0000000000..09c43331a1 --- /dev/null +++ b/apps/web/lib/state/layout-manager/intent.ts @@ -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 = { + 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, +): 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 }; +} diff --git a/apps/web/lib/state/layout-manager/types.ts b/apps/web/lib/state/layout-manager/types.ts index b19457c9f0..3d1251799a 100644 --- a/apps/web/lib/state/layout-manager/types.ts +++ b/apps/web/lib/state/layout-manager/types.ts @@ -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; + /** 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; +}; diff --git a/apps/web/lib/ws/handlers/task-plans.ts b/apps/web/lib/ws/handlers/task-plans.ts index bd5569235e..fb671622c6 100644 --- a/apps/web/lib/ws/handlers/task-plans.ts +++ b/apps/web/lib/ws/handlers/task-plans.ts @@ -18,9 +18,10 @@ function handlePlanUpsert(store: StoreApi, 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;