diff --git a/.changeset/split-canvas-conversation-panes.md b/.changeset/split-canvas-conversation-panes.md new file mode 100644 index 000000000..93beb8af6 --- /dev/null +++ b/.changeset/split-canvas-conversation-panes.md @@ -0,0 +1,8 @@ +--- +"helmor": minor +--- + +Split the workspace center column into a multi-pane conversation canvas. + +- Drag a conversation onto another's edge to split the center column into side-by-side or stacked panes (up to 4), resize the splits, and rearrange or close panes; the layout persists per workspace and survives navigating away and back. ⌘W closes the focused pane. +- Agents sent from a split now know about their sibling panes: the Helmor system prompt lists the other open sessions and the `helmor` CLI commands to inspect, read, or hand them work, so panes can coordinate across the canvas. Single-pane sends are byte-for-byte unchanged. diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 3b737ff62..d988df49c 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -197,6 +197,13 @@ pub struct AgentSendRequest { /// text — this never alters the wire payload. #[serde(default)] pub pasted_texts: Option>, + /// Session IDs of the OTHER conversation panes sharing this send's + /// split-canvas (the frontend owns the canvas layout). When non-empty, + /// the Helmor system-prompt preamble gains a "sibling panes" addendum + /// listing them + the `helmor` CLI commands to inspect or message them. + /// Empty/absent ⇒ single-pane behaviour, no addendum. + #[serde(default)] + pub sibling_session_ids: Option>, } #[cfg(test)] diff --git a/src-tauri/src/agents/streaming/mod.rs b/src-tauri/src/agents/streaming/mod.rs index 8e7373ffc..6033ad8ee 100644 --- a/src-tauri/src/agents/streaming/mod.rs +++ b/src-tauri/src/agents/streaming/mod.rs @@ -125,6 +125,7 @@ pub(super) fn stream_via_sidecar( .and_then(|(_, _, workspace_id)| workspace_id.as_deref()), working_directory, request.permission_mode.as_deref(), + request.sibling_session_ids.as_deref(), ); // Combine the optional hidden preamble with the user's prompt. Only @@ -1468,10 +1469,11 @@ pub(crate) fn build_helmor_system_prompt_for_workspace( workspace_id: Option<&str>, working_directory: &std::path::Path, permission_mode: Option<&str>, + sibling_session_ids: Option<&[String]>, ) -> Option { use crate::agents::system_prompt::{ build_helmor_chat_prompt, build_helmor_system_prompt, HelmorChatPromptContext, - HelmorSystemPromptContext, + HelmorSystemPromptContext, SiblingSessionInfo, }; let workspace_id = workspace_id?; @@ -1555,6 +1557,29 @@ pub(crate) fn build_helmor_system_prompt_for_workspace( Ok(Some(ref v)) if v == "true" ); + // Resolve sibling pane titles from the same workspace. The frontend + // sends only the sibling session IDs (the split-canvas layout it owns); + // we look up their titles here so the addendum reads naturally. Unknown + // IDs (e.g. a just-deleted pane) are skipped. Best-effort: a failed + // lookup just elides the addendum rather than failing the send. + let sibling_sessions = match sibling_session_ids { + Some(ids) if !ids.is_empty() => { + let titles: std::collections::HashMap = + crate::models::sessions::list_workspace_sessions(workspace_id) + .map(|sessions| sessions.into_iter().map(|s| (s.id, s.title)).collect()) + .unwrap_or_default(); + ids.iter() + .filter_map(|id| { + titles.get(id).map(|title| SiblingSessionInfo { + id: id.clone(), + title: title.clone(), + }) + }) + .collect() + } + _ => Vec::new(), + }; + let ctx = HelmorSystemPromptContext { workspace_label, workspace_root_path: working_directory.display().to_string(), @@ -1565,6 +1590,7 @@ pub(crate) fn build_helmor_system_prompt_for_workspace( stack, permission_mode: permission_mode.map(str::to_string), mdx_planning, + sibling_sessions, }; Some(build_helmor_system_prompt(&ctx)) } diff --git a/src-tauri/src/agents/system_prompt.rs b/src-tauri/src/agents/system_prompt.rs index 207f34e0c..a4f4f1325 100644 --- a/src-tauri/src/agents/system_prompt.rs +++ b/src-tauri/src/agents/system_prompt.rs @@ -96,6 +96,20 @@ pub struct HelmorSystemPromptContext { /// MDX plan-authoring contract so the agent writes its plan as an MDX /// file under `.helmor/plans/` instead of an inline plan. pub mdx_planning: bool, + /// Other conversation panes sharing this session's split-canvas. Empty + /// elides the entire "sibling panes" addendum (single-pane case). When + /// non-empty, the preamble lists each sibling + the concrete `helmor` + /// CLI commands to inspect or hand it work. + pub sibling_sessions: Vec, +} + +/// A sibling conversation pane the current agent can reach via the CLI. +#[derive(Debug, Clone)] +pub struct SiblingSessionInfo { + /// The sibling's Helmor session id (`--session `). + pub id: String, + /// Human-friendly title, shown so the agent can pick the right pane. + pub title: String, } /// Lightweight stacked-PR context injected into the workspace preamble so the @@ -205,6 +219,22 @@ pub fn build_helmor_system_prompt(ctx: &HelmorSystemPromptContext) -> String { "\nIf the user asks for help with Helmor itself, point them at the feedback button at the bottom of Helmor's sidebar.\n", ); + if !ctx.sibling_sessions.is_empty() { + let cli = &ctx.cli_command_name; + out.push_str( + "\nYou are one of several conversation panes open together on this workspace. You can inspect or message the sibling panes with the Helmor CLI (already on PATH):\n", + ); + for sibling in &ctx.sibling_sessions { + let _ = writeln!(out, " - \"{}\" — session {}", sibling.title, sibling.id); + } + let _ = write!( + out, + "To see which siblings are still working: `{cli} session list --json` (each session's `status` is `idle` or `streaming`).\n\ + To read what a sibling did: `{cli} session get-messages --session --json`.\n\ + To hand a sibling work: `{cli} send --session \"\"` — this BLOCKS until that pane finishes the turn and prints its reply, so there is no need to poll afterwards.\n", + ); + } + if ctx.permission_mode.as_deref() == Some("plan") && ctx.mdx_planning { out.push_str(MDX_PLAN_AUTHORING_BLOCK); } @@ -306,6 +336,7 @@ mod tests { stack: None, permission_mode: None, mdx_planning: false, + sibling_sessions: Vec::new(), } } @@ -318,6 +349,41 @@ mod tests { assert!(prompt.contains("`/Users/me/helmor/workspaces/dohooo/feature-x`")); } + /// Single-pane (no siblings) must NOT render the cross-chat addendum, + /// so existing one-pane behaviour is byte-for-byte unchanged. + #[test] + fn omits_sibling_addendum_when_no_siblings() { + let prompt = build_helmor_system_prompt(&ctx_with_defaults()); + assert!(!prompt.contains("several conversation panes")); + } + + /// Multi-pane: the addendum lists each sibling and the concrete CLI + /// commands, and makes the blocking nature of `send` explicit (no poll). + #[test] + fn renders_sibling_addendum_with_titles_and_commands() { + let mut ctx = ctx_with_defaults(); + ctx.sibling_sessions = vec![ + SiblingSessionInfo { + id: "a1b2".to_string(), + title: "Tests pane".to_string(), + }, + SiblingSessionInfo { + id: "c3d4".to_string(), + title: "Docs pane".to_string(), + }, + ]; + let prompt = build_helmor_system_prompt(&ctx); + assert!(prompt.contains("several conversation panes")); + assert!(prompt.contains("\"Tests pane\" — session a1b2")); + assert!(prompt.contains("\"Docs pane\" — session c3d4")); + assert!(prompt.contains("session get-messages --session ")); + assert!(prompt.contains("send --session ")); + // The blocking contract must be explicit so the agent doesn't poll. + assert!(prompt.contains("BLOCKS")); + // Addendum stays inside the helmor_context envelope. + assert!(prompt.trim_end().ends_with("")); + } + /// Resolved target + base branch → the diff/PR commands are /// pre-substituted with the real branch names. This is the load- /// bearing line the agent uses to decide where to base PRs. diff --git a/src-tauri/src/service.rs b/src-tauri/src/service.rs index cc73ce1e9..b4cbad765 100644 --- a/src-tauri/src/service.rs +++ b/src-tauri/src/service.rs @@ -301,6 +301,8 @@ pub fn send_message( Some(&workspace_id), std::path::Path::new(&cwd), params.permission_mode.as_deref(), + // CLI-launched sends carry no split-canvas, so no sibling addendum. + None, ); let wire_prompt = match helmor_prefix.as_deref() { Some(helmor) => format!("{helmor}\n\nUser request:\n{}", params.prompt), diff --git a/src/features/conversation/hooks/use-streaming.ts b/src/features/conversation/hooks/use-streaming.ts index 6b754f8c3..32bfd9ef6 100644 --- a/src/features/conversation/hooks/use-streaming.ts +++ b/src/features/conversation/hooks/use-streaming.ts @@ -141,6 +141,12 @@ type UseConversationStreamingArgs = { getSessionContextReferences?: ( sessionId: string, ) => readonly SessionContextReference[]; + /** Session IDs of the OTHER split-canvas panes open alongside this one. + * Stable getter (mirrors `getSessionContextReferences`) so it can ride + * the send without churning the submit callback's identity. Sent to the + * backend as `siblingSessionIds`, which drives the cross-chat addendum in + * the agent's system prompt. Absent/empty ⇒ single-pane, no addendum. */ + getSiblingSessionIds?: (sessionId: string) => readonly string[]; onInteractionSessionsChange?: ( sessionWorkspaceMap: Map, interactionCounts: Map, @@ -160,6 +166,7 @@ export function useConversationStreaming({ submitQueue, activeStreams, getSessionContextReferences, + getSiblingSessionIds, onInteractionSessionsChange, onSessionCompleted, onSessionAborted, @@ -972,6 +979,7 @@ export function useConversationStreaming({ const { flushStreamMessages, scheduleFlush } = flushers; cleanup = flushers.cleanup; + const siblingSessionIds = getSiblingSessionIds?.(targetSessionId) ?? []; await startAgentMessageStream( { provider: model.provider, @@ -988,6 +996,8 @@ export function useConversationStreaming({ files: filePaths, images: imagePaths, pastedTexts: pastedTexts.length > 0 ? pastedTexts : null, + siblingSessionIds: + siblingSessionIds.length > 0 ? [...siblingSessionIds] : null, }, createStreamEventDispatcher({ contextKey, @@ -1079,6 +1089,7 @@ export function useConversationStreaming({ displayedSessionId, displayedWorkspaceId, getSessionContextReferences, + getSiblingSessionIds, invalidateConversationQueries, markSendingState, pushToast, diff --git a/src/features/conversation/index.tsx b/src/features/conversation/index.tsx index 02e6b1923..389e7e05c 100644 --- a/src/features/conversation/index.tsx +++ b/src/features/conversation/index.tsx @@ -10,6 +10,7 @@ import { WorkspaceComposerContainer } from "@/features/composer/container"; import type { StartSubmitMode } from "@/features/composer/start-submit-mode"; import type { UserInputResponseHandler } from "@/features/composer/user-input"; import { WorkspacePanelContainer } from "@/features/panel/container"; +import type { CanvasGroupTab } from "@/features/panel/header"; import { FileLinkProvider } from "@/features/panel/message-components/file-link-context"; import type { SessionCloseRequest } from "@/features/panel/use-confirm-session-close"; import { @@ -137,6 +138,21 @@ export type WorkspaceConversationContainerProps = { contextPreviewActive?: boolean; onSelectContextPreview?: () => void; onCloseContextPreview?: () => void; + /** Split-canvas: stable getter returning the session IDs of the OTHER + * panes open alongside this one. Forwarded to `useConversationStreaming` + * so a send carries its siblings to the backend system-prompt builder. + * Omitted in the single-pane case. */ + getSiblingSessionIds?: (sessionId: string) => readonly string[]; + /** Split-canvas: a pane hides its own header tab strip (the canvas renders + * one shared tab bar above all panes instead). */ + hideHeader?: boolean; + /** Split-canvas: collapse the listed sessions into a single "split" tab in + * this conversation's header (used while viewing a non-split session so the + * user can click back into the persisted split). */ + canvasGroup?: CanvasGroupTab | null; + /** Split-canvas: split the current conversation from its header. */ + onCanvasSplit?: (direction: "row" | "col") => void; + canvasSplitDisabled?: boolean; /** Prompt queued by an external caller (e.g. the inspector Git commit * button or a drained CLI send) to be auto-submitted once the displayed * session matches. Per-session config (model / effort / fast-mode / @@ -231,6 +247,11 @@ export const WorkspaceConversationContainer = memo( contextPreviewActive = false, onSelectContextPreview, onCloseContextPreview, + getSiblingSessionIds, + hideHeader = false, + canvasGroup = null, + onCanvasSplit, + canvasSplitDisabled = false, pendingPromptForSession = null, pendingCreatedWorkspaceSubmit = null, onPendingCreatedWorkspaceSubmitConsumed, @@ -433,6 +454,7 @@ export const WorkspaceConversationContainer = memo( submitQueue: submitQueueApi, activeStreams, getSessionContextReferences, + getSiblingSessionIds, onInteractionSessionsChange, onSessionCompleted, onSessionAborted, @@ -846,6 +868,10 @@ export const WorkspaceConversationContainer = memo( displayedWorkspaceId={displayedWorkspaceId} selectedSessionId={selectedSessionId} displayedSessionId={displayedSessionId} + hideHeader={hideHeader} + canvasGroup={canvasGroup} + onCanvasSplit={onCanvasSplit} + canvasSplitDisabled={canvasSplitDisabled} sessionSelectionHistory={sessionSelectionHistory} sending={sendingForPanel} busySessionIds={panelBusySessionIds} diff --git a/src/features/panel/container.tsx b/src/features/panel/container.tsx index 6a8cb3c0c..a47801669 100644 --- a/src/features/panel/container.tsx +++ b/src/features/panel/container.tsx @@ -30,6 +30,7 @@ import { type WorkspaceScriptType, } from "@/lib/workspace-script-actions"; import { publishShellEvent } from "@/shell/event-bus"; +import type { CanvasGroupTab } from "./header"; import { WorkspacePanel } from "./index"; import type { SessionCloseRequest } from "./use-confirm-session-close"; @@ -79,6 +80,17 @@ type WorkspacePanelContainerProps = { * before the real send actually fires, swapped out as soon as the real * user message lands in DB. */ optimisticPendingSubmit?: OptimisticPendingSubmit | null; + /** Split-canvas: collapse the listed sessions into one "split" tab. */ + canvasGroup?: CanvasGroupTab | null; + /** Split-canvas: split the current conversation (header control). */ + onCanvasSplit?: (direction: "row" | "col") => void; + canvasSplitDisabled?: boolean; + /** Render ONLY the header — used as the single shared tab bar above a + * multi-pane canvas. Suppresses the body + workspace-level side effects + * (auto-create, plan surfacing) that the panes themselves own. */ + headerOnly?: boolean; + /** Render the body but NOT the header (each canvas pane). */ + hideHeader?: boolean; }; export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({ @@ -104,6 +116,11 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({ headerActions, headerLeading, optimisticPendingSubmit = null, + canvasGroup = null, + onCanvasSplit, + canvasSplitDisabled = false, + headerOnly = false, + hideHeader = false, }: WorkspacePanelContainerProps) { const queryClient = useQueryClient(); const { settings } = useSettings(); @@ -138,6 +155,10 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({ const autoCreatingWorkspaceRef = useRef>(new Set()); useEffect(() => { + // Header-only host doesn't own workspace lifecycle — the panes do. + if (headerOnly) { + return; + } if (!displayedWorkspaceId || selectedWorkspaceId !== displayedWorkspaceId) { return; } @@ -260,6 +281,7 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({ cancelled = true; }; }, [ + headerOnly, displayedWorkspaceId, detailQuery.isFetchedAfterMount, queryClient, @@ -354,7 +376,9 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({ // Selecting a plan tab is local panel state — distinct from the session // selection so switching back to a session is a single click. const planListQuery = usePlanList( - settings.mdxPlanningEnabled ? threadSessionId : null, + // The header-only host renders no thread, so it owns no plan surface — + // the panes do. Skip plan surfacing here to avoid a duplicate watcher. + !headerOnly && settings.mdxPlanningEnabled ? threadSessionId : null, ); // The plan surface shows AT MOST ONE plan at a time. Opening another plan // SWAPS it into the same single tab rather than stacking a new one, so there @@ -818,6 +842,11 @@ export const WorkspacePanelContainer = memo(function WorkspacePanelContainer({ missingScriptTypes={missingScriptTypes} onInitializeScript={handleInitializeScript} changeRequest={workspaceChangeRequest} + canvasGroup={canvasGroup} + onCanvasSplit={onCanvasSplit} + canvasSplitDisabled={canvasSplitDisabled} + headerOnly={headerOnly} + hideHeader={hideHeader} /> ); }); diff --git a/src/features/panel/header.tsx b/src/features/panel/header.tsx index d7204b92f..cefee4d46 100644 --- a/src/features/panel/header.tsx +++ b/src/features/panel/header.tsx @@ -6,6 +6,7 @@ import { ChevronDown, ClipboardList, Clock3, + Columns2, Copy, GitBranch, History, @@ -14,6 +15,7 @@ import { MessageCircle, Pencil, RotateCcw, + Rows2, Trash2, X, } from "lucide-react"; @@ -31,6 +33,7 @@ import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -83,6 +86,20 @@ export type PlanTab = { title: string; }; +/** Split-canvas: when the conversation is split into multiple panes, the + * member sessions collapse into ONE "group" tab in the single tab bar (a + * title + a badge with the pane count) instead of one tab per pane — the panes + * are all on screen at once, so they don't each need a sub-tab. */ +export type CanvasGroupTab = { + /** Sessions that are panes of the current canvas — collapsed into one tab. */ + sessionIds: string[]; + /** Number of panes (drives the badge). */ + count: number; + /** The focused pane's session — the group tab's value + highlight target. + * Its title labels the group tab (resolved here, where titles are known). */ + activeSessionId: string; +}; + /** Tab value prefix for a plan tab. The slug is appended so the existing * `onValueChange` handler can decode which plan was selected — mirroring the * hardcoded `__context_preview__` context-preview tab value. @@ -128,6 +145,16 @@ type WorkspacePanelHeaderProps = { onSelectWorkspace?: (workspaceId: string) => void; newSessionShortcut?: string | null; newSessionMenuShortcut?: string | null; + /** When set, the listed sessions render as a SINGLE collapsed "split" tab + * (title + pane-count badge) instead of one tab each. Absent ⇒ normal + * one-tab-per-session behaviour (single-pane). */ + canvasGroup?: CanvasGroupTab | null; + /** Split-canvas: split the current conversation toward the given direction + * (`row` = side by side, `col` = stacked). When provided, the header shows + * split controls next to the history button. */ + onCanvasSplit?: (direction: "row" | "col") => void; + /** Disable the split controls (e.g. at the 4-pane cap). */ + canvasSplitDisabled?: boolean; }; const SESSION_TITLE_TOOLTIP_MAX_CHARS = 240; @@ -165,7 +192,16 @@ export const WorkspacePanelHeader = memo(function WorkspacePanelHeader({ onSelectWorkspace, newSessionShortcut, newSessionMenuShortcut, + canvasGroup = null, + onCanvasSplit, + canvasSplitDisabled = false, }: WorkspacePanelHeaderProps) { + const canvasSessionIdSet = canvasGroup + ? new Set(canvasGroup.sessionIds) + : null; + // Emit the collapsed group tab exactly once, at the position of the first + // canvas-member session in the strip. + let canvasGroupEmitted = false; const branchTone = getWorkspaceBranchTone({ workspaceState: workspace?.state, status: workspace?.status, @@ -612,6 +648,50 @@ export const WorkspacePanelHeader = memo(function WorkspacePanelHeader({ ) : null} {sessions.map((session) => { + // Split-canvas: collapse all canvas-member sessions into a + // single "split" tab (rendered once, at the first member). + if (canvasSessionIdSet?.has(session.id) && canvasGroup) { + if (canvasGroupEmitted) { + return null; + } + canvasGroupEmitted = true; + const groupSelected = canvasSessionIdSet.has( + selectedSessionId ?? "", + ); + const activeGroupSession = sessions.find( + (s) => s.id === canvasGroup.activeSessionId, + ); + const groupTitle = activeGroupSession + ? displaySessionTitle(activeGroupSession) + : "Split view"; + return ( + + + + {groupTitle} + + + + ); + } const selected = session.id === selectedSessionId; const isActivelySending = busySessionIds?.has(session.id) === true || @@ -632,6 +712,12 @@ export const WorkspacePanelHeader = memo(function WorkspacePanelHeader({ { onPrefetchSession?.(session.id); }} @@ -822,83 +908,118 @@ export const WorkspacePanelHeader = memo(function WorkspacePanelHeader({ disabled={!workspace} /> - - - - - + + + + + + + + Split conversation + + + + onCanvasSplit("row")}> + + Split right + + onCanvasSplit("col")}> + + Split down + + + + ) : null} + + {onCanvasSplit ? null : ( + - {hiddenHistory.hiddenSessions.length > 0 ? ( - hiddenHistory.hiddenSessions.map((session) => ( - - -
-
- - - {displaySessionTitle(session)} - -
-
- - + + + + + {hiddenHistory.hiddenSessions.length > 0 ? ( + hiddenHistory.hiddenSessions.map((session) => ( + + +
+
+ + + {displaySessionTitle(session)} + +
+
+ + +
-
- - - - {displayTooltipTitle(displaySessionTitle(session))} - - - - )) - ) : ( -
- No hidden sessions -
- )} - - + + + + {displayTooltipTitle(displaySessionTitle(session))} + + + + )) + ) : ( +
+ No hidden sessions +
+ )} + + + )}
); diff --git a/src/features/panel/index.test.tsx b/src/features/panel/index.test.tsx index 53576da33..6207dd360 100644 --- a/src/features/panel/index.test.tsx +++ b/src/features/panel/index.test.tsx @@ -181,6 +181,99 @@ describe("WorkspacePanel", () => { expect(screen.getByTestId("thinking-indicator")).toBeInTheDocument(); }); + it("collapses canvas-member sessions into a single split tab with a pane-count badge", () => { + const sessions: WorkspaceSessionSummary[] = [ + { ...SESSIONS[0], id: "session-1", title: "Session 1" }, + { ...SESSIONS[0], id: "session-2", title: "Session 2", active: false }, + { ...SESSIONS[0], id: "session-3", title: "Session 3", active: false }, + ]; + render( + + + + + , + ); + + // One merged "split" tab with a badge of 2 … + const groupTab = screen.getByLabelText("Split view, 2 panes"); + expect(groupTab).toBeInTheDocument(); + expect(within(groupTab).getByText("2")).toBeInTheDocument(); + // … the second member is NOT shown as its own tab … + expect(screen.queryByText("Session 2")).not.toBeInTheDocument(); + // … but the non-canvas session keeps its own tab. + expect(screen.getByText("Session 3")).toBeInTheDocument(); + }); + + it("splits via the header dropdown and hides the history button", async () => { + const onCanvasSplit = vi.fn(); + const user = userEvent.setup(); + render( + + + + + , + ); + + // A single split button (with a dropdown) — and NO history button while + // the split control is present. + expect(screen.getByLabelText("Split conversation")).toBeInTheDocument(); + expect(screen.queryByLabelText("Session history")).not.toBeInTheDocument(); + + await user.click(screen.getByLabelText("Split conversation")); + await user.click(await screen.findByText("Split right")); + expect(onCanvasSplit).toHaveBeenCalledWith("row"); + + await user.click(screen.getByLabelText("Split conversation")); + await user.click(await screen.findByText("Split down")); + expect(onCanvasSplit).toHaveBeenCalledWith("col"); + }); + + it("shows the history button and no split control when onCanvasSplit is absent", () => { + render( + + + + + , + ); + expect( + screen.queryByLabelText("Split conversation"), + ).not.toBeInTheDocument(); + expect(screen.getByLabelText("Session history")).toBeInTheDocument(); + }); + it("keeps conversation header actions outside Tauri drag regions", () => { render( diff --git a/src/features/panel/index.tsx b/src/features/panel/index.tsx index f46b4ad8e..d75ba1f48 100644 --- a/src/features/panel/index.tsx +++ b/src/features/panel/index.tsx @@ -13,7 +13,11 @@ import { HelmorProfiler } from "@/lib/dev-react-profiler"; import type { ContextCard } from "@/lib/sources/types"; import { cn } from "@/lib/utils"; import type { WorkspaceScriptType } from "@/lib/workspace-script-actions"; -import { type PlanTab, WorkspacePanelHeader } from "./header"; +import { + type CanvasGroupTab, + type PlanTab, + WorkspacePanelHeader, +} from "./header"; import { EmptyState, preloadStreamdown } from "./message-components"; import { ActiveThreadViewport, @@ -66,6 +70,17 @@ type WorkspacePanelProps = { newSessionMenuShortcut?: string | null; missingScriptTypes?: WorkspaceScriptType[]; onInitializeScript?: (scriptType: WorkspaceScriptType) => void; + /** Split-canvas: collapse the listed sessions into one "split" tab. */ + canvasGroup?: CanvasGroupTab | null; + /** Split-canvas: split the current conversation (header control). */ + onCanvasSplit?: (direction: "row" | "col") => void; + canvasSplitDisabled?: boolean; + /** Render ONLY the header (no thread/composer body) — used as the single + * shared tab bar above a multi-pane canvas. */ + headerOnly?: boolean; + /** Render the body but NOT the header — used by each canvas pane so the tab + * bar isn't duplicated per pane. */ + hideHeader?: boolean; }; export const WorkspacePanel = memo(function WorkspacePanel({ @@ -105,6 +120,11 @@ export const WorkspacePanel = memo(function WorkspacePanel({ newSessionMenuShortcut, missingScriptTypes = [], onInitializeScript, + canvasGroup = null, + onCanvasSplit, + canvasSplitDisabled = false, + headerOnly = false, + hideHeader = false, }: WorkspacePanelProps) { const planActive = activePlanSlug != null && !contextPreviewActive; const selectedSession = @@ -169,99 +189,112 @@ export const WorkspacePanel = memo(function WorkspacePanel({ return (
- + {hideHeader ? null : ( + + )} -
- {terminalSessions.map((session) => ( -
- + {terminalSessions.map((session) => ( +
-
- ))} - {planActive && activePlanSlug && planSessionId && workspace ? ( - - - - ) : contextPreviewActive && contextPreviewCard ? ( -
- -
- ) : visibleTerminalId ? null : activePane?.hasLoaded ? ( - - ) : loadingWorkspace || loadingSession ? ( - - ) : ( -
- + +
+ ))} + {planActive && activePlanSlug && planSessionId && workspace ? ( + + + + ) : contextPreviewActive && contextPreviewCard ? ( +
+ +
+ ) : visibleTerminalId ? null : activePane?.hasLoaded ? ( + -
- )} -
+ ) : loadingWorkspace || loadingSession ? ( + + ) : ( +
+ +
+ )} +
+ )}
); diff --git a/src/lib/api.ts b/src/lib/api.ts index a3c7a01f1..7f0d2f487 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -271,6 +271,11 @@ export type AgentSendRequest = { * user_prompt so those spans render as tag chips; the agent still * receives the full prompt text. */ pastedTexts?: PastedTextRange[] | null; + /** Session IDs of the OTHER split-canvas panes open alongside this send. + * Drives the cross-chat "sibling panes" addendum in the agent's system + * prompt (titles are resolved backend-side). Empty/absent ⇒ single-pane, + * no addendum. */ + siblingSessionIds?: string[] | null; }; export type WorkspaceSummary = { diff --git a/src/shell/canvas/canvas-dnd.test.ts b/src/shell/canvas/canvas-dnd.test.ts new file mode 100644 index 000000000..69c29aa36 --- /dev/null +++ b/src/shell/canvas/canvas-dnd.test.ts @@ -0,0 +1,116 @@ +import { + cleanup, + createEvent, + fireEvent, + renderHook, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveDropEdge, useCanvasTabDnd } from "./canvas-dnd"; + +const rect = { left: 0, top: 0, width: 100, height: 100 }; + +describe("resolveDropEdge", () => { + it("picks the nearest edge to the pointer", () => { + expect(resolveDropEdge(rect, 5, 50)).toBe("left"); + expect(resolveDropEdge(rect, 95, 50)).toBe("right"); + expect(resolveDropEdge(rect, 50, 5)).toBe("top"); + expect(resolveDropEdge(rect, 50, 95)).toBe("bottom"); + }); + + it("resolves corners by the closest side", () => { + // top-left corner, marginally closer to the top edge + expect(resolveDropEdge({ ...rect }, 10, 8)).toBe("top"); + // near the left edge but vertically centred + expect(resolveDropEdge({ ...rect }, 8, 40)).toBe("left"); + }); + + it("accounts for the rect offset", () => { + const offset = { left: 200, top: 100, width: 100, height: 100 }; + expect(resolveDropEdge(offset, 205, 150)).toBe("left"); + expect(resolveDropEdge(offset, 295, 150)).toBe("right"); + expect(resolveDropEdge(offset, 250, 105)).toBe("top"); + }); +}); + +describe("useCanvasTabDnd — click vs drag", () => { + afterEach(() => { + cleanup(); + document.body.innerHTML = ""; + }); + + function mountTab() { + const tab = document.createElement("button"); + tab.setAttribute("data-canvas-drag-session", "s1"); + document.body.appendChild(tab); + return tab; + } + + it("suppresses the native mousedown selection on a drag-source tab", () => { + const tab = mountTab(); + renderHook(() => useCanvasTabDnd({ enabled: true, onDrop: vi.fn() })); + const mousedown = createEvent.mouseDown(tab, { button: 0 }); + fireEvent(tab, mousedown); + expect(mousedown.defaultPrevented).toBe(true); + }); + + it("does NOT suppress mousedown on an inner action control (close/rename)", () => { + const tab = mountTab(); + const action = document.createElement("span"); + action.setAttribute("role", "button"); + tab.appendChild(action); + renderHook(() => useCanvasTabDnd({ enabled: true, onDrop: vi.fn() })); + const mousedown = createEvent.mouseDown(action, { button: 0 }); + fireEvent(action, mousedown); + expect(mousedown.defaultPrevented).toBe(false); + }); + + // jsdom's PointerEvent drops clientX/clientY; dispatch coordinate-bearing + // MouseEvents typed as pointer events so the threshold math is exercised. + function pointer(type: string, x: number, y: number): MouseEvent { + return new MouseEvent(type, { + clientX: x, + clientY: y, + button: 0, + bubbles: true, + cancelable: true, + }); + } + + it("activates the session on a click (press + release, no movement)", () => { + const tab = mountTab(); + const onActivateSession = vi.fn(); + renderHook(() => + useCanvasTabDnd({ enabled: true, onDrop: vi.fn(), onActivateSession }), + ); + tab.dispatchEvent(pointer("pointerdown", 10, 10)); + tab.dispatchEvent(pointer("pointerup", 10, 10)); + expect(onActivateSession).toHaveBeenCalledWith("s1"); + }); + + it("does NOT activate the session when the press becomes a drag", () => { + const tab = mountTab(); + const onActivateSession = vi.fn(); + renderHook(() => + useCanvasTabDnd({ enabled: true, onDrop: vi.fn(), onActivateSession }), + ); + tab.dispatchEvent(pointer("pointerdown", 10, 10)); + // Move well past the activation threshold → becomes a drag. + document.body.dispatchEvent(pointer("pointermove", 60, 60)); + document.body.dispatchEvent(pointer("pointerup", 60, 60)); + expect(onActivateSession).not.toHaveBeenCalled(); + }); + + it("ignores tabs entirely when disabled", () => { + const tab = mountTab(); + const onActivateSession = vi.fn(); + renderHook(() => + useCanvasTabDnd({ enabled: false, onDrop: vi.fn(), onActivateSession }), + ); + const mousedown = createEvent.mouseDown(tab, { button: 0 }); + fireEvent(tab, mousedown); + expect(mousedown.defaultPrevented).toBe(false); + tab.dispatchEvent(pointer("pointerdown", 10, 10)); + tab.dispatchEvent(pointer("pointerup", 10, 10)); + expect(onActivateSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shell/canvas/canvas-dnd.ts b/src/shell/canvas/canvas-dnd.ts new file mode 100644 index 000000000..598232df5 --- /dev/null +++ b/src/shell/canvas/canvas-dnd.ts @@ -0,0 +1,338 @@ +// Pointer-based drag-to-split for the split-canvas. Mirrors the workspace +// sidebar DnD pattern (`features/navigation/dnd`): no HTML5 `draggable`, no new +// library — a global capture pointer-down detects a gesture starting on any +// element tagged `data-canvas-drag-session`, an activation threshold separates +// a click (select the tab) from a drag, and `elementsFromPoint` hit-tests the +// pane the pointer is over. Dropping splits/moves toward the nearest edge. +// +// The drag SOURCE needs only a `data-canvas-drag-session` (and optional +// `data-canvas-drag-pane`) attribute on the tab element — zero React callback +// threading through the header. All logic lives here in the canvas module. + +import { + createElement, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import type { DropEdge } from "./tree-model"; + +/** Pointer travel (px) before a press becomes a drag rather than a click. */ +const ACTIVATE_PX = 4; + +type Rect = { left: number; top: number; width: number; height: number }; + +/** Nearest edge of `rect` to the point — the side the dropped pane snaps to. */ +export function resolveDropEdge(rect: Rect, x: number, y: number): DropEdge { + const distances: Array<[DropEdge, number]> = [ + ["left", x - rect.left], + ["right", rect.left + rect.width - x], + ["top", y - rect.top], + ["bottom", rect.top + rect.height - y], + ]; + let best: DropEdge = "left"; + let bestDist = Number.POSITIVE_INFINITY; + for (const [edge, dist] of distances) { + if (dist < bestDist) { + bestDist = dist; + best = edge; + } + } + return best; +} + +type DropTarget = { paneId: string; edge: DropEdge; rect: Rect }; + +type DragState = { + sessionId: string; + sourcePaneId: string | null; + pointerX: number; + pointerY: number; + target: DropTarget | null; +}; + +type PendingStart = { + sessionId: string; + sourcePaneId: string | null; + sourceEl: HTMLElement; + startX: number; + startY: number; + pointerId: number; +}; + +type UseCanvasTabDndArgs = { + enabled: boolean; + onDrop: ( + sessionId: string, + sourcePaneId: string | null, + targetPaneId: string, + edge: DropEdge, + ) => void; + /** Called for a genuine CLICK on a drag-source tab (press + release with no + * drag). We suppress the tab's native mousedown selection so a drag never + * activates the tab, then replay the selection here on a real click. */ + onActivateSession?: (sessionId: string) => void; +}; + +/** True for the tab's inner action controls (rename / close) — these keep + * their native behaviour and never start a drag or a deferred selection. */ +function isTabActionControl(target: EventTarget | null): boolean { + return Boolean((target as HTMLElement | null)?.closest?.('[role="button"]')); +} + +/** The drag-source tab element under `target`, if any. */ +function findDragSource(target: EventTarget | null): HTMLElement | null { + const el = (target as HTMLElement | null)?.closest?.( + "[data-canvas-drag-session]", + ); + return el instanceof HTMLElement ? el : null; +} + +export function useCanvasTabDnd({ + enabled, + onDrop, + onActivateSession, +}: UseCanvasTabDndArgs): { + overlay: ReactNode; +} { + const [drag, setDrag] = useState(null); + const pendingRef = useRef(null); + const dragRef = useRef(null); + const onDropRef = useRef(onDrop); + onDropRef.current = onDrop; + const onActivateRef = useRef(onActivateSession); + onActivateRef.current = onActivateSession; + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + + useEffect(() => { + function commit(next: DragState | null) { + dragRef.current = next; + setDrag(next); + } + + function resolveTarget(x: number, y: number): DropTarget | null { + if (typeof document.elementsFromPoint !== "function") { + return null; + } + const stack = document.elementsFromPoint(x, y); + for (const el of stack) { + const zone = (el as HTMLElement).closest?.("[data-canvas-dropzone]"); + if (zone instanceof HTMLElement) { + const paneId = zone.getAttribute("data-canvas-dropzone"); + if (!paneId) continue; + const r = zone.getBoundingClientRect(); + const rect = { + left: r.left, + top: r.top, + width: r.width, + height: r.height, + }; + return { paneId, edge: resolveDropEdge(rect, x, y), rect }; + } + } + return null; + } + + // Block the tab's native mousedown/focus selection for drag-source tabs + // so pressing-to-drag never activates the tab. Radix's Tabs.Trigger + // selects directly in `onMouseDown` (and via focus); `preventDefault` + // here (capture phase, before React's handler) makes its + // `composeEventHandlers` skip the activation AND stops the button from + // focusing. Selection is replayed on a real click in `onPointerUp`. + function onMouseDownCapture(event: MouseEvent) { + if (!enabledRef.current || event.button !== 0) return; + if (isTabActionControl(event.target)) return; + if (findDragSource(event.target)) { + event.preventDefault(); + } + } + + function onPointerDown(event: PointerEvent) { + if (!enabledRef.current) return; + if (isTabActionControl(event.target)) return; + const source = findDragSource(event.target); + if (!source) return; + const sessionId = source.getAttribute("data-canvas-drag-session"); + if (!sessionId) return; + pendingRef.current = { + sessionId, + sourcePaneId: source.getAttribute("data-canvas-drag-pane"), + sourceEl: source, + startX: event.clientX, + startY: event.clientY, + pointerId: event.pointerId, + }; + } + + function onPointerMove(event: PointerEvent) { + const active = dragRef.current; + if (active) { + event.preventDefault(); + commit({ + ...active, + pointerX: event.clientX, + pointerY: event.clientY, + target: resolveTarget(event.clientX, event.clientY), + }); + return; + } + const pending = pendingRef.current; + if (!pending || event.pointerId !== pending.pointerId) return; + const moved = Math.hypot( + event.clientX - pending.startX, + event.clientY - pending.startY, + ); + if (moved < ACTIVATE_PX) return; + document.documentElement.style.cursor = "grabbing"; + commit({ + sessionId: pending.sessionId, + sourcePaneId: pending.sourcePaneId, + pointerX: event.clientX, + pointerY: event.clientY, + target: resolveTarget(event.clientX, event.clientY), + }); + } + + function endDrag() { + document.documentElement.style.removeProperty("cursor"); + pendingRef.current = null; + commit(null); + } + + function onPointerUp(event: PointerEvent) { + const active = dragRef.current; + if (!active) { + // No drag activated → a genuine click on the tab. Replay the + // selection we suppressed at mousedown, and restore keyboard + // focus (already-selected ⇒ no double activation from onFocus). + const pending = pendingRef.current; + pendingRef.current = null; + if (pending && pending.pointerId === event.pointerId) { + onActivateRef.current?.(pending.sessionId); + pending.sourceEl.focus?.(); + } + return; + } + const target = resolveTarget(event.clientX, event.clientY); + if (target) { + onDropRef.current( + active.sessionId, + active.sourcePaneId, + target.paneId, + target.edge, + ); + // Swallow the click that would otherwise re-select the tab. + const swallow = (click: MouseEvent) => { + click.stopPropagation(); + click.preventDefault(); + }; + window.addEventListener("click", swallow, { + capture: true, + once: true, + }); + } + endDrag(); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape" && dragRef.current) { + endDrag(); + } + } + + window.addEventListener("mousedown", onMouseDownCapture, { capture: true }); + window.addEventListener("pointerdown", onPointerDown, { capture: true }); + window.addEventListener("pointermove", onPointerMove, { passive: false }); + window.addEventListener("pointerup", onPointerUp); + window.addEventListener("pointercancel", endDrag); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("mousedown", onMouseDownCapture, { + capture: true, + }); + window.removeEventListener("pointerdown", onPointerDown, { + capture: true, + }); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + window.removeEventListener("pointercancel", endDrag); + window.removeEventListener("keydown", onKeyDown); + document.documentElement.style.removeProperty("cursor"); + }; + }, []); + + const overlay = + drag && typeof document !== "undefined" + ? createPortal(renderOverlay(drag), document.body) + : null; + + return { overlay }; +} + +function renderOverlay(drag: DragState): ReactNode { + const children: ReactNode[] = [ + // Ghost following the pointer. + createElement( + "div", + { + key: "ghost", + style: { + position: "fixed", + left: drag.pointerX + 12, + top: drag.pointerY + 12, + zIndex: 9999, + pointerEvents: "none", + }, + className: + "rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md", + }, + "Drop on a pane edge to split", + ), + ]; + if (drag.target) { + const { rect, edge } = drag.target; + const half = edgeHighlightRect(rect, edge); + children.push( + createElement("div", { + key: "edge", + style: { + position: "fixed", + left: half.left, + top: half.top, + width: half.width, + height: half.height, + zIndex: 9998, + pointerEvents: "none", + }, + className: + "rounded-sm bg-ring/25 ring-2 ring-inset ring-ring transition-all", + }), + ); + } + return createElement("div", { "aria-hidden": "true" }, ...children); +} + +/** The half-pane band the dropped session would occupy, for the highlight. */ +function edgeHighlightRect(rect: Rect, edge: DropEdge): Rect { + switch (edge) { + case "left": + return { ...rect, width: rect.width / 2 }; + case "right": + return { + ...rect, + left: rect.left + rect.width / 2, + width: rect.width / 2, + }; + case "top": + return { ...rect, height: rect.height / 2 }; + case "bottom": + return { + ...rect, + top: rect.top + rect.height / 2, + height: rect.height / 2, + }; + } +} diff --git a/src/shell/canvas/canvas-persistence.test.ts b/src/shell/canvas/canvas-persistence.test.ts new file mode 100644 index 000000000..990beab5c --- /dev/null +++ b/src/shell/canvas/canvas-persistence.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + canvasStorageKey, + deserializeCanvas, + ensureFocused, + serializeCanvas, + singleLeafCanvas, +} from "./canvas-persistence"; +import { makeLeaf, splitLeaf } from "./tree-model"; + +describe("canvasStorageKey", () => { + it("namespaces the key by workspace id", () => { + expect(canvasStorageKey("ws-1")).toBe("helmor.workspaceCanvas:ws-1"); + }); +}); + +describe("singleLeafCanvas", () => { + it("wraps a session in a one-leaf canvas focused on that leaf", () => { + const canvas = singleLeafCanvas("s1"); + expect(canvas.root).toEqual(makeLeaf("s1")); + expect(canvas.focusedPaneId).toBe("pane-s1"); + }); +}); + +describe("serialize / deserialize round-trip", () => { + it("restores a multi-leaf canvas", () => { + const canvas = { + root: splitLeaf(makeLeaf("a"), "pane-a", "row", "b"), + focusedPaneId: "pane-b", + }; + const restored = deserializeCanvas(serializeCanvas(canvas)); + expect(restored).toEqual(canvas); + }); + + it("returns null for null/garbage input", () => { + expect(deserializeCanvas(null)).toBeNull(); + expect(deserializeCanvas("not json")).toBeNull(); + expect(deserializeCanvas("{}")).toBeNull(); + expect(deserializeCanvas('{"root":{"type":"leaf"}}')).toBeNull(); + }); + + it("rejects a tree whose split is structurally invalid", () => { + const bad = JSON.stringify({ + root: { type: "split", direction: "row", children: [], sizes: [] }, + focusedPaneId: "x", + }); + expect(deserializeCanvas(bad)).toBeNull(); + }); + + it("rejects a focusedPaneId that is not present in the tree", () => { + const bad = JSON.stringify({ + root: makeLeaf("a"), + focusedPaneId: "pane-missing", + }); + expect(deserializeCanvas(bad)).toBeNull(); + }); +}); + +describe("ensureFocused", () => { + it("keeps a valid focus untouched", () => { + const canvas = singleLeafCanvas("a"); + expect(ensureFocused(canvas)).toBe(canvas); + }); + + it("repoints focus to the first leaf when the focused pane is gone", () => { + const canvas = { + root: splitLeaf(makeLeaf("a"), "pane-a", "row", "b"), + focusedPaneId: "pane-gone", + }; + expect(ensureFocused(canvas).focusedPaneId).toBe("pane-a"); + }); +}); diff --git a/src/shell/canvas/canvas-persistence.ts b/src/shell/canvas/canvas-persistence.ts new file mode 100644 index 000000000..6c8b5a027 --- /dev/null +++ b/src/shell/canvas/canvas-persistence.ts @@ -0,0 +1,102 @@ +// Pure (de)serialization + validation for a workspace canvas. Kept free of +// React so the round-trip + structural validation are unit-testable; the +// `useCanvasState` hook layers localStorage + reducers on top. + +import { + collectLeaves, + makeLeaf, + type PaneNode, + type PaneSplit, +} from "./tree-model"; + +/** A persisted canvas: the pane tree plus which leaf is active. */ +export type CanvasState = { + root: PaneNode; + focusedPaneId: string; +}; + +/** localStorage namespace; one entry per workspace. */ +export const CANVAS_STORAGE_PREFIX = "helmor.workspaceCanvas:"; + +export function canvasStorageKey(workspaceId: string): string { + return `${CANVAS_STORAGE_PREFIX}${workspaceId}`; +} + +/** The zero-risk default: one leaf for `sessionId`, focused. */ +export function singleLeafCanvas(sessionId: string): CanvasState { + const leaf = makeLeaf(sessionId); + return { root: leaf, focusedPaneId: leaf.paneId }; +} + +function isValidNode(value: unknown): value is PaneNode { + if (!value || typeof value !== "object") { + return false; + } + const node = value as Record; + if (node.type === "leaf") { + return ( + typeof node.paneId === "string" && typeof node.sessionId === "string" + ); + } + if (node.type === "split") { + const split = value as Partial; + if (split.direction !== "row" && split.direction !== "col") { + return false; + } + if (!Array.isArray(split.children) || split.children.length < 2) { + return false; + } + if ( + !Array.isArray(split.sizes) || + split.sizes.length !== split.children.length || + !split.sizes.every((size) => typeof size === "number") + ) { + return false; + } + return split.children.every(isValidNode); + } + return false; +} + +export function isValidCanvas(value: unknown): value is CanvasState { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Record; + if (typeof candidate.focusedPaneId !== "string") { + return false; + } + if (!isValidNode(candidate.root)) { + return false; + } + const paneIds = new Set( + collectLeaves(candidate.root as PaneNode).map((leaf) => leaf.paneId), + ); + return paneIds.has(candidate.focusedPaneId); +} + +export function serializeCanvas(state: CanvasState): string { + return JSON.stringify(state); +} + +export function deserializeCanvas(raw: string | null): CanvasState | null { + if (!raw) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + return isValidCanvas(parsed) ? parsed : null; +} + +/** If the focused pane no longer exists, repoint focus to the first leaf. */ +export function ensureFocused(state: CanvasState): CanvasState { + const leaves = collectLeaves(state.root); + if (leaves.some((leaf) => leaf.paneId === state.focusedPaneId)) { + return state; + } + return { ...state, focusedPaneId: leaves[0]?.paneId ?? state.focusedPaneId }; +} diff --git a/src/shell/canvas/pane-tree-view.test.tsx b/src/shell/canvas/pane-tree-view.test.tsx new file mode 100644 index 000000000..55a5d5bf1 --- /dev/null +++ b/src/shell/canvas/pane-tree-view.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PaneTreeView } from "./pane-tree-view"; +import { makeLeaf, splitLeaf } from "./tree-model"; + +afterEach(cleanup); + +const renderLeaf = (leaf: { sessionId: string }) => ( +
{leaf.sessionId}
+); + +describe("PaneTreeView", () => { + it("renders one body per leaf and a separator between siblings", () => { + const tree = splitLeaf(makeLeaf("a"), "pane-a", "row", "b"); + const { container } = render( + {}} + onResize={() => {}} + />, + ); + expect(screen.getByTestId("body-a")).toBeTruthy(); + expect(screen.getByTestId("body-b")).toBeTruthy(); + expect(container.querySelectorAll("[data-canvas-resize]")).toHaveLength(1); + }); + + it("exposes a dropzone + pane id on each leaf", () => { + const tree = splitLeaf(makeLeaf("a"), "pane-a", "row", "b"); + const { container } = render( + {}} + onResize={() => {}} + />, + ); + expect(container.querySelectorAll("[data-canvas-dropzone]")).toHaveLength( + 2, + ); + expect(container.querySelector('[data-pane-id="pane-b"]')).not.toBeNull(); + }); + + it("focuses a leaf on pointer-down", () => { + const onFocusPane = vi.fn(); + const tree = splitLeaf(makeLeaf("a"), "pane-a", "row", "b"); + const { container } = render( + {}} + />, + ); + const leafB = container.querySelector('[data-pane-id="pane-b"]'); + if (!leafB) throw new Error("leaf b missing"); + fireEvent.pointerDown(leafB); + expect(onFocusPane).toHaveBeenCalledWith("pane-b"); + }); + + it("renders a nested split with the right number of separators", () => { + let tree = splitLeaf(makeLeaf("a"), "pane-a", "row", "b"); + tree = splitLeaf(tree, "pane-b", "col", "c"); + const { container } = render( + {}} + onResize={() => {}} + />, + ); + // outer row (1 sep) + inner col (1 sep) = 2 + expect(container.querySelectorAll("[data-canvas-resize]")).toHaveLength(2); + expect(screen.getByTestId("body-c")).toBeTruthy(); + }); +}); diff --git a/src/shell/canvas/pane-tree-view.tsx b/src/shell/canvas/pane-tree-view.tsx new file mode 100644 index 000000000..1e8ff487e --- /dev/null +++ b/src/shell/canvas/pane-tree-view.tsx @@ -0,0 +1,247 @@ +// Recursive renderer for the split-canvas pane tree. A `split` node becomes a +// flex row/col with a draggable separator between each pair of children; a +// `leaf` node renders the caller-supplied conversation body wrapped in a +// focus-scope element. Resize writes new fractional `sizes` back through the +// `onResize(path, sizes)` callback (path = child-index sequence to the split). + +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useRef, +} from "react"; +import { cn } from "@/lib/utils"; +import type { PaneLeaf, PaneNode } from "./tree-model"; + +/** Smallest fraction a pane may shrink to during a resize drag. */ +const MIN_PANE_FRACTION = 0.12; + +export type PaneTreeViewProps = { + node: PaneNode; + focusedPaneId: string | null; + /** Render a leaf's conversation body. */ + renderLeaf: (leaf: PaneLeaf) => React.ReactNode; + onFocusPane: (paneId: string) => void; + onResize: (path: number[], sizes: number[]) => void; + /** Optional per-leaf overlay (split / close controls), absolutely + * positioned inside the leaf wrapper. Kept caller-supplied so the renderer + * stays free of conversation/session coupling. */ + renderPaneOverlay?: (leaf: PaneLeaf) => React.ReactNode; + /** Child-index path from the root to `node`. Root call passes `[]`. */ + path?: number[]; +}; + +export function PaneTreeView({ + node, + focusedPaneId, + renderLeaf, + onFocusPane, + onResize, + renderPaneOverlay, + path = [], +}: PaneTreeViewProps) { + if (node.type === "leaf") { + const isFocused = node.paneId === focusedPaneId; + return ( +
onFocusPane(node.paneId)} + className={cn( + "relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden", + "transition-shadow", + isFocused + ? "shadow-[inset_0_0_0_1.5px_var(--color-ring,theme(colors.ring))]" + : "shadow-[inset_0_0_0_1px_transparent]", + )} + > + {renderLeaf(node)} + {renderPaneOverlay ? renderPaneOverlay(node) : null} +
+ ); + } + + const isRow = node.direction === "row"; + return ( +
+ {node.children.map((child, index) => ( + + ))} +
+ ); +} + +function childKey(child: PaneNode, index: number): string { + return child.type === "leaf" ? child.paneId : `split-${index}`; +} + +type CanvasChildProps = { + child: PaneNode; + index: number; + parent: Extract; + path: number[]; + focusedPaneId: string | null; + renderLeaf: (leaf: PaneLeaf) => React.ReactNode; + onFocusPane: (paneId: string) => void; + onResize: (path: number[], sizes: number[]) => void; + renderPaneOverlay?: (leaf: PaneLeaf) => React.ReactNode; +}; + +function CanvasChild({ + child, + index, + parent, + path, + focusedPaneId, + renderLeaf, + onFocusPane, + onResize, + renderPaneOverlay, +}: CanvasChildProps) { + const fraction = parent.sizes[index] ?? 1 / parent.children.length; + const isRow = parent.direction === "row"; + const isLast = index === parent.children.length - 1; + + return ( + <> +
+ +
+ {!isLast && ( + { + const a = parent.sizes[index] ?? 1 / parent.children.length; + const b = parent.sizes[index + 1] ?? 1 / parent.children.length; + const total = a + b; + let nextA = a + deltaFraction; + nextA = Math.max( + MIN_PANE_FRACTION, + Math.min(total - MIN_PANE_FRACTION, nextA), + ); + const sizes = parent.sizes.slice(); + sizes[index] = nextA; + sizes[index + 1] = total - nextA; + onResize(path, sizes); + }} + /> + )} + + ); +} + +type SeparatorProps = { + direction: "row" | "col"; + /** Called per pointer-move with the fractional delta since drag start. */ + onResize: (deltaFraction: number) => void; +}; + +function CanvasResizeSeparator({ direction, onResize }: SeparatorProps) { + const isRow = direction === "row"; + const stateRef = useRef<{ + origin: number; + span: number; + pointerId: number; + } | null>(null); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + const state = stateRef.current; + if (!state || state.span <= 0) { + return; + } + const current = isRow ? event.clientX : event.clientY; + onResize((current - state.origin) / state.span); + }, + [isRow, onResize], + ); + + const handlePointerUp = useCallback(() => { + stateRef.current = null; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + document.documentElement.style.removeProperty("cursor"); + }, [handlePointerMove]); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + // Resize is its own gesture — don't let it bubble into a leaf focus + // or a tab drag. + event.preventDefault(); + event.stopPropagation(); + const container = event.currentTarget.parentElement; + if (!container) { + return; + } + const rect = container.getBoundingClientRect(); + stateRef.current = { + origin: isRow ? event.clientX : event.clientY, + span: isRow ? rect.width : rect.height, + pointerId: event.pointerId, + }; + document.documentElement.style.cursor = isRow + ? "col-resize" + : "row-resize"; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + }, + [isRow, handlePointerMove, handlePointerUp], + ); + + return ( + // Pointer-only resize affordance (no keyboard/value semantics → no + // `separator` role, which would require `aria-valuenow`). +
+ {/* Invisible widened hit-area so the 1px divider is easy to grab. */} +
+ ); +} diff --git a/src/shell/canvas/tree-model.test.ts b/src/shell/canvas/tree-model.test.ts new file mode 100644 index 000000000..7da6a497b --- /dev/null +++ b/src/shell/canvas/tree-model.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + closeLeaf, + collectLeaves, + insertLeaf, + leafCount, + MAX_LEAVES, + makeLeaf, + moveLeaf, + type PaneNode, + type PaneSplit, + resizeSplit, + splitLeaf, +} from "./tree-model"; + +const leaf = (sessionId: string): PaneNode => makeLeaf(sessionId); + +function deepFreeze(value: T): T { + if (value && typeof value === "object") { + Object.freeze(value); + for (const key of Object.keys(value as object)) { + deepFreeze((value as Record)[key]); + } + } + return value; +} + +describe("makeLeaf", () => { + it("creates a leaf node with a derived pane id", () => { + const node = makeLeaf("session-a"); + expect(node).toEqual({ + type: "leaf", + paneId: "pane-session-a", + sessionId: "session-a", + }); + }); + + it("accepts an explicit pane id", () => { + expect(makeLeaf("session-a", "p1").paneId).toBe("p1"); + }); +}); + +describe("leafCount / collectLeaves", () => { + it("counts a single leaf as one", () => { + expect(leafCount(leaf("a"))).toBe(1); + }); + + it("counts all leaves across nested splits", () => { + const tree: PaneSplit = { + type: "split", + direction: "row", + children: [ + leaf("a"), + { + type: "split", + direction: "col", + children: [leaf("b"), leaf("c")], + sizes: [0.5, 0.5], + }, + ], + sizes: [0.5, 0.5], + }; + expect(leafCount(tree)).toBe(3); + expect(collectLeaves(tree).map((l) => l.sessionId)).toEqual([ + "a", + "b", + "c", + ]); + }); +}); + +describe("splitLeaf", () => { + it("splits a single leaf into a row of two leaves", () => { + const result = splitLeaf(leaf("a"), "pane-a", "row", "b"); + expect(result).toEqual({ + type: "split", + direction: "row", + children: [leaf("a"), leaf("b")], + sizes: [0.5, 0.5], + }); + }); + + it("splits into a column when direction is col", () => { + const result = splitLeaf(leaf("a"), "pane-a", "col", "b") as PaneSplit; + expect(result.direction).toBe("col"); + }); + + it("splits a nested leaf in place", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b") as PaneSplit; + const result = splitLeaf(tree, "pane-b", "col", "c") as PaneSplit; + expect(leafCount(result)).toBe(3); + // 'a' untouched, 'b' replaced by a col split [b, c] + expect(result.children[0]).toEqual(leaf("a")); + expect(result.children[1]).toMatchObject({ + type: "split", + direction: "col", + }); + }); + + it("is a no-op when the leaf cap is reached", () => { + let tree: PaneNode = leaf("a"); + tree = splitLeaf(tree, "pane-a", "row", "b"); + tree = splitLeaf(tree, "pane-b", "row", "c"); + tree = splitLeaf(tree, "pane-c", "row", "d"); + expect(leafCount(tree)).toBe(MAX_LEAVES); + const blocked = splitLeaf(tree, "pane-a", "row", "e"); + expect(leafCount(blocked)).toBe(MAX_LEAVES); + expect(blocked).toBe(tree); + }); + + it("does not mutate the input tree", () => { + const input = deepFreeze(leaf("a")); + expect(() => splitLeaf(input, "pane-a", "row", "b")).not.toThrow(); + }); +}); + +describe("closeLeaf", () => { + it("collapses a single-child split back to a bare leaf", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b"); + const result = closeLeaf(tree, "pane-b"); + expect(result).toEqual(leaf("a")); + }); + + it("returns null when the last remaining leaf is closed", () => { + expect(closeLeaf(leaf("a"), "pane-a")).toBeNull(); + }); + + it("removes a deeply nested leaf and collapses the orphaned split", () => { + let tree: PaneNode = leaf("a"); + tree = splitLeaf(tree, "pane-a", "row", "b"); + tree = splitLeaf(tree, "pane-b", "col", "c"); + // tree = row[a, col[b, c]]; closing c collapses col → leaf b + const result = closeLeaf(tree, "pane-c") as PaneSplit; + expect(result.children).toEqual([leaf("a"), leaf("b")]); + }); + + it("is a no-op for an unknown pane id", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b"); + expect(closeLeaf(tree, "pane-missing")).toBe(tree); + }); +}); + +describe("resizeSplit", () => { + it("replaces the sizes of the split at the given path", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b") as PaneSplit; + const result = resizeSplit(tree, [], [0.3, 0.7]) as PaneSplit; + expect(result.sizes).toEqual([0.3, 0.7]); + expect(result.children).toEqual(tree.children); + }); + + it("resizes a nested split addressed by child index path", () => { + let tree: PaneNode = leaf("a"); + tree = splitLeaf(tree, "pane-a", "row", "b"); + tree = splitLeaf(tree, "pane-b", "col", "c"); + // tree = row[a, col[b, c]]; nested split is at child index [1] + const result = resizeSplit(tree, [1], [0.2, 0.8]) as PaneSplit; + expect((result.children[1] as PaneSplit).sizes).toEqual([0.2, 0.8]); + expect(result.sizes).toEqual([0.5, 0.5]); + }); +}); + +describe("moveLeaf", () => { + it("moves a leaf next to a target on the right edge (row, after)", () => { + // start: row[a, b]; move a to the right of b → row[b, a] + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b"); + const result = moveLeaf(tree, "pane-a", "pane-b", "right") as PaneSplit; + expect(collectLeaves(result).map((l) => l.sessionId)).toEqual(["b", "a"]); + }); + + it("moves a leaf to the left edge (row, before)", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b"); + const result = moveLeaf(tree, "pane-b", "pane-a", "left") as PaneSplit; + expect(collectLeaves(result).map((l) => l.sessionId)).toEqual(["b", "a"]); + }); + + it("moves a leaf to the bottom edge as a column split", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b"); + const result = moveLeaf(tree, "pane-a", "pane-b", "bottom") as PaneSplit; + // 'a' removed from row (row collapses to leaf b), then b split col[b, a] + expect(result.direction).toBe("col"); + expect(collectLeaves(result).map((l) => l.sessionId)).toEqual(["b", "a"]); + }); + + it("is a no-op when source and target are the same pane", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b"); + expect(moveLeaf(tree, "pane-a", "pane-a", "right")).toBe(tree); + }); +}); + +describe("insertLeaf", () => { + it("inserts a NEW session leaf to the right of a single leaf", () => { + const result = insertLeaf(leaf("a"), "b", "pane-a", "right") as PaneSplit; + expect(result.direction).toBe("row"); + expect(collectLeaves(result).map((l) => l.sessionId)).toEqual(["a", "b"]); + }); + + it("inserts to the left/top as the first child", () => { + const right = insertLeaf(leaf("a"), "b", "pane-a", "left") as PaneSplit; + expect(collectLeaves(right).map((l) => l.sessionId)).toEqual(["b", "a"]); + const top = insertLeaf(leaf("a"), "b", "pane-a", "top") as PaneSplit; + expect(top.direction).toBe("col"); + expect(collectLeaves(top).map((l) => l.sessionId)).toEqual(["b", "a"]); + }); + + it("inserts adjacent to a nested target leaf", () => { + const tree = splitLeaf(leaf("a"), "pane-a", "row", "b"); + const result = insertLeaf(tree, "c", "pane-b", "bottom") as PaneSplit; + expect(leafCount(result)).toBe(3); + expect(collectLeaves(result).map((l) => l.sessionId)).toEqual([ + "a", + "b", + "c", + ]); + }); + + it("is a no-op when the leaf cap is reached", () => { + let tree: PaneNode = leaf("a"); + tree = splitLeaf(tree, "pane-a", "row", "b"); + tree = splitLeaf(tree, "pane-b", "row", "c"); + tree = splitLeaf(tree, "pane-c", "row", "d"); + expect(insertLeaf(tree, "e", "pane-a", "right")).toBe(tree); + }); + + it("is a no-op for an unknown target pane", () => { + const input = leaf("a"); + expect(insertLeaf(input, "b", "pane-missing", "right")).toBe(input); + }); +}); diff --git a/src/shell/canvas/tree-model.ts b/src/shell/canvas/tree-model.ts new file mode 100644 index 000000000..4ebf0e15c --- /dev/null +++ b/src/shell/canvas/tree-model.ts @@ -0,0 +1,269 @@ +// Pure pane-tree model for the split-canvas center column. No React, no DOM — +// fully unit-testable. A canvas is a recursive tree whose leaves each host one +// conversation session and whose splits arrange children in a row (side by +// side) or a column (stacked). Every op returns a NEW tree (or the same +// reference when it would be a no-op) and never mutates its input. + +/** A leaf hosts exactly one conversation session. */ +export type PaneLeaf = { + type: "leaf"; + paneId: string; + sessionId: string; +}; + +/** A split arranges its children horizontally (`row`) or vertically (`col`). */ +export type PaneSplit = { + type: "split"; + direction: "row" | "col"; + children: PaneNode[]; + /** Fractional sizes, one per child, summing to ~1. */ + sizes: number[]; +}; + +export type PaneNode = PaneLeaf | PaneSplit; + +/** Drag-to-split target edge of a leaf. */ +export type DropEdge = "left" | "right" | "top" | "bottom"; + +/** Hard cap on simultaneously-open conversation panes in one canvas. */ +export const MAX_LEAVES = 4; + +/** Build a leaf, deriving a stable pane id from the session id by default. */ +export function makeLeaf(sessionId: string, paneId?: string): PaneLeaf { + return { type: "leaf", paneId: paneId ?? `pane-${sessionId}`, sessionId }; +} + +export function leafCount(node: PaneNode): number { + if (node.type === "leaf") { + return 1; + } + return node.children.reduce((sum, child) => sum + leafCount(child), 0); +} + +export function collectLeaves(node: PaneNode): PaneLeaf[] { + if (node.type === "leaf") { + return [node]; + } + return node.children.flatMap(collectLeaves); +} + +function evenSizes(count: number): number[] { + return Array.from({ length: count }, () => 1 / count); +} + +/** + * Replace the leaf identified by `paneId` with a split that contains the + * original leaf plus a freshly-created leaf for `newSessionId`. No-op (returns + * the same reference) when the cap is reached or the pane id is absent. + */ +export function splitLeaf( + root: PaneNode, + paneId: string, + direction: "row" | "col", + newSessionId: string, + newPaneId?: string, +): PaneNode { + if (leafCount(root) >= MAX_LEAVES) { + return root; + } + + let replaced = false; + const next = mapLeaf(root, paneId, (leaf) => { + replaced = true; + const fresh = makeLeaf(newSessionId, newPaneId); + return { + type: "split", + direction, + children: [leaf, fresh], + sizes: evenSizes(2), + }; + }); + + return replaced ? next : root; +} + +/** + * Remove the leaf identified by `paneId`. Splits left with a single child + * collapse into that child. Returns `null` when the very last leaf is closed, + * or the same reference when the pane id is absent. + */ +export function closeLeaf(root: PaneNode, paneId: string): PaneNode | null { + if (root.type === "leaf") { + return root.paneId === paneId ? null : root; + } + + let changed = false; + const children: PaneNode[] = []; + const sizes: number[] = []; + root.children.forEach((child, index) => { + const pruned = closeLeaf(child, paneId); + if (pruned === null) { + changed = true; + return; + } + if (pruned !== child) { + changed = true; + } + children.push(pruned); + sizes.push(root.sizes[index] ?? 1 / root.children.length); + }); + + if (!changed) { + return root; + } + + if (children.length === 1) { + return children[0]; + } + + return { ...root, children, sizes: normalize(sizes) }; +} + +/** + * Replace the `sizes` of the split addressed by `path` (a sequence of child + * indices from the root). An empty path targets the root split. + */ +export function resizeSplit( + root: PaneNode, + path: number[], + sizes: number[], +): PaneNode { + if (path.length === 0) { + if (root.type !== "split") { + return root; + } + return { ...root, sizes }; + } + + if (root.type !== "split") { + return root; + } + + const [index, ...rest] = path; + const target = root.children[index]; + if (!target) { + return root; + } + + const updated = resizeSplit(target, rest, sizes); + if (updated === target) { + return root; + } + + const children = root.children.slice(); + children[index] = updated; + return { ...root, children }; +} + +/** + * Drag-to-split: detach the leaf `paneId` and re-insert it adjacent to + * `targetPaneId` on the given `edge`. `left`/`right` produce a row split, + * `top`/`bottom` a column split; `left`/`top` place the moved leaf first. + */ +export function moveLeaf( + root: PaneNode, + paneId: string, + targetPaneId: string, + edge: DropEdge, +): PaneNode { + if (paneId === targetPaneId) { + return root; + } + + const moving = findLeaf(root, paneId); + if (!moving) { + return root; + } + + const detached = closeLeaf(root, paneId); + if (detached === null) { + return root; + } + + return insertAdjacent(detached, moving, targetPaneId, edge) ?? root; +} + +/** + * Drop-to-add: insert a freshly-created leaf for `newSessionId` adjacent to + * `targetPaneId` on the given `edge`. Used when dragging a session that is NOT + * yet in the canvas onto a pane. No-op (same reference) if the cap is reached + * or the target is absent. + */ +export function insertLeaf( + root: PaneNode, + newSessionId: string, + targetPaneId: string, + edge: DropEdge, + newPaneId?: string, +): PaneNode { + if (leafCount(root) >= MAX_LEAVES) { + return root; + } + const fresh = makeLeaf(newSessionId, newPaneId); + return insertAdjacent(root, fresh, targetPaneId, edge) ?? root; +} + +// --- internals ----------------------------------------------------------- + +/** Insert `leaf` next to `targetPaneId` at `edge`. Returns null on no match. */ +function insertAdjacent( + root: PaneNode, + leaf: PaneNode, + targetPaneId: string, + edge: DropEdge, +): PaneNode | null { + const direction: "row" | "col" = + edge === "left" || edge === "right" ? "row" : "col"; + const before = edge === "left" || edge === "top"; + + let inserted = false; + const next = mapLeaf(root, targetPaneId, (target) => { + inserted = true; + const children = before ? [leaf, target] : [target, leaf]; + return { type: "split", direction, children, sizes: evenSizes(2) }; + }); + + return inserted ? next : null; +} + +function mapLeaf( + node: PaneNode, + paneId: string, + fn: (leaf: PaneLeaf) => PaneNode, +): PaneNode { + if (node.type === "leaf") { + return node.paneId === paneId ? fn(node) : node; + } + + let changed = false; + const children = node.children.map((child) => { + const updated = mapLeaf(child, paneId, fn); + if (updated !== child) { + changed = true; + } + return updated; + }); + + return changed ? { ...node, children } : node; +} + +function findLeaf(node: PaneNode, paneId: string): PaneLeaf | null { + if (node.type === "leaf") { + return node.paneId === paneId ? node : null; + } + for (const child of node.children) { + const found = findLeaf(child, paneId); + if (found) { + return found; + } + } + return null; +} + +function normalize(sizes: number[]): number[] { + const total = sizes.reduce((sum, value) => sum + value, 0); + if (total <= 0) { + return evenSizes(sizes.length); + } + return sizes.map((value) => value / total); +} diff --git a/src/shell/canvas/use-canvas-state.test.tsx b/src/shell/canvas/use-canvas-state.test.tsx new file mode 100644 index 000000000..003771657 --- /dev/null +++ b/src/shell/canvas/use-canvas-state.test.tsx @@ -0,0 +1,91 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { canvasStorageKey, deserializeCanvas } from "./canvas-persistence"; +import { useCanvasState } from "./use-canvas-state"; + +afterEach(cleanup); +beforeEach(() => window.localStorage.clear()); + +describe("useCanvasState", () => { + it("starts with no split", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + expect(result.current.hasSplit).toBe(false); + expect(result.current.canvas).toBeNull(); + expect(result.current.splitSessionIds).toEqual([]); + expect(window.localStorage.getItem(canvasStorageKey("ws"))).toBeNull(); + }); + + it("startSplit creates a 2-pane split, focuses the new pane, and persists", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => result.current.startSplit("s1", "row", "s2")); + expect(result.current.hasSplit).toBe(true); + expect(result.current.splitSessionIds).toEqual(["s1", "s2"]); + expect(result.current.focusedPaneId).toBe("pane-s2"); + const stored = deserializeCanvas( + window.localStorage.getItem(canvasStorageKey("ws")), + ); + expect(stored?.root.type).toBe("split"); + }); + + it("startSplitByDrop honors the drop edge ordering", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => result.current.startSplitByDrop("s1", "s2", "left")); + // dropped on the LEFT edge of s1 → dropped session comes first + expect(result.current.splitSessionIds).toEqual(["s2", "s1"]); + expect(result.current.canvas?.root.type).toBe("split"); + }); + + it("splitFocused and splitPane extend an existing split", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => result.current.startSplit("s1", "row", "s2")); + act(() => result.current.splitFocused("col", "s3")); + expect(result.current.splitSessionIds).toEqual(["s1", "s2", "s3"]); + act(() => result.current.splitPane("pane-s1", "row", "s4")); + expect(result.current.leaves).toHaveLength(4); + }); + + it("closePane dissolves the split (and clears persistence) when one remains", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => result.current.startSplit("s1", "row", "s2")); + act(() => result.current.closePane("pane-s2")); + expect(result.current.hasSplit).toBe(false); + expect(result.current.canvas).toBeNull(); + expect(window.localStorage.getItem(canvasStorageKey("ws"))).toBeNull(); + }); + + it("closePane keeps the split when more than one pane remains", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => result.current.startSplit("s1", "row", "s2")); + act(() => result.current.splitFocused("col", "s3")); + act(() => result.current.closePane("pane-s3")); + expect(result.current.hasSplit).toBe(true); + expect(result.current.splitSessionIds).toEqual(["s1", "s2"]); + }); + + it("PERSISTS the split across remount — navigating away never destroys it", () => { + const first = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => first.result.current.startSplit("s1", "col", "s2")); + first.unmount(); + + // A fresh mount (e.g. returning to the split after viewing another + // session) restores the SAME split rather than collapsing it. + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + expect(result.current.hasSplit).toBe(true); + expect(result.current.splitSessionIds).toEqual(["s1", "s2"]); + }); + + it("inserts a dropped session as a new pane and focuses it", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => result.current.startSplit("s1", "row", "s2")); + act(() => result.current.insertPane("s3", "pane-s1", "right")); + expect(result.current.splitSessionIds).toContain("s3"); + expect(result.current.focusedPaneId).toBe("pane-s3"); + }); + + it("focuses a pane on demand", () => { + const { result } = renderHook(() => useCanvasState({ workspaceId: "ws" })); + act(() => result.current.startSplit("s1", "row", "s2")); + act(() => result.current.focusPane("pane-s1")); + expect(result.current.focusedPaneId).toBe("pane-s1"); + }); +}); diff --git a/src/shell/canvas/use-canvas-state.ts b/src/shell/canvas/use-canvas-state.ts new file mode 100644 index 000000000..3c687deb7 --- /dev/null +++ b/src/shell/canvas/use-canvas-state.ts @@ -0,0 +1,307 @@ +// Stateful glue over the pure pane-tree model. Owns the per-workspace SPLIT: +// a multi-leaf pane tree (or null when there is no split). The split is a +// PERSISTENT entity — it survives navigating to other (non-split) sessions and +// back, and is only dissolved by closing panes down to one. Single sessions are +// NOT represented here; they render through the normal conversation path. +// +// Persisted to localStorage per workspace (multi-leaf only). + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type CanvasState, + canvasStorageKey, + deserializeCanvas, + ensureFocused, + serializeCanvas, +} from "./canvas-persistence"; +import { + closeLeaf, + collectLeaves, + type DropEdge, + insertLeaf, + leafCount, + makeLeaf, + moveLeaf, + type PaneLeaf, + type PaneSplit, + resizeSplit, + splitLeaf, +} from "./tree-model"; + +type Params = { + workspaceId: string | null; +}; + +export type CanvasStateApi = { + /** The current split (always ≥2 leaves), or null when there is no split. */ + canvas: CanvasState | null; + leaves: PaneLeaf[]; + /** Session IDs of every pane in the split (empty when no split). */ + splitSessionIds: string[]; + hasSplit: boolean; + focusedPaneId: string | null; + /** Start a fresh 2-pane split from a single session (single view → split). + * Replaces any existing split. */ + startSplit: ( + sourceSessionId: string, + direction: "row" | "col", + newSessionId: string, + ) => void; + /** Start a split by dropping `droppedSessionId` onto a single session at an + * edge (drag-to-split from the non-split single view). */ + startSplitByDrop: ( + sourceSessionId: string, + droppedSessionId: string, + edge: DropEdge, + ) => void; + /** Extend the existing split at the focused pane. */ + splitFocused: (direction: "row" | "col", newSessionId: string) => void; + /** Extend the existing split at a specific pane. */ + splitPane: ( + paneId: string, + direction: "row" | "col", + newSessionId: string, + ) => void; + /** Close a pane. When only one leaf would remain, the split is dissolved + * (returns to null) — its remaining session becomes a normal single tab. */ + closePane: (paneId: string) => void; + resize: (path: number[], sizes: number[]) => void; + movePane: (paneId: string, targetPaneId: string, edge: DropEdge) => void; + /** Drop a session NOT yet in the split next to a pane (drag-to-split). */ + insertPane: ( + newSessionId: string, + targetPaneId: string, + edge: DropEdge, + ) => void; + focusPane: (paneId: string) => void; +}; + +function readPersisted(workspaceId: string | null): CanvasState | null { + if (!workspaceId || typeof window === "undefined") { + return null; + } + try { + return deserializeCanvas( + window.localStorage.getItem(canvasStorageKey(workspaceId)), + ); + } catch { + return null; + } +} + +function twoPaneSplit( + first: PaneLeaf, + second: PaneLeaf, + direction: "row" | "col", +): PaneSplit { + return { + type: "split", + direction, + children: [first, second], + sizes: [0.5, 0.5], + }; +} + +export function useCanvasState({ workspaceId }: Params): CanvasStateApi { + // Seed strictly from the persisted split — single sessions are not part of + // the canvas, so there is nothing to seed when no split is saved. + const [canvas, setCanvas] = useState(() => { + const persisted = readPersisted(workspaceId); + return persisted ? ensureFocused(persisted) : null; + }); + + // Re-seed the split when the workspace changes. + const workspaceRef = useRef(workspaceId); + useEffect(() => { + if (workspaceRef.current === workspaceId) { + return; + } + workspaceRef.current = workspaceId; + const persisted = readPersisted(workspaceId); + setCanvas(persisted ? ensureFocused(persisted) : null); + }, [workspaceId]); + + // Persist the split; clear the key when there is no split. + useEffect(() => { + if (!workspaceId || typeof window === "undefined") { + return; + } + const key = canvasStorageKey(workspaceId); + try { + if (canvas && leafCount(canvas.root) > 1) { + window.localStorage.setItem(key, serializeCanvas(canvas)); + } else { + window.localStorage.removeItem(key); + } + } catch { + // Storage full / unavailable — non-fatal, layout just won't persist. + } + }, [workspaceId, canvas]); + + const startSplit = useCallback( + ( + sourceSessionId: string, + direction: "row" | "col", + newSessionId: string, + ) => { + const root = twoPaneSplit( + makeLeaf(sourceSessionId), + makeLeaf(newSessionId), + direction, + ); + setCanvas({ root, focusedPaneId: makeLeaf(newSessionId).paneId }); + }, + [], + ); + + const startSplitByDrop = useCallback( + (sourceSessionId: string, droppedSessionId: string, edge: DropEdge) => { + const direction: "row" | "col" = + edge === "left" || edge === "right" ? "row" : "col"; + const before = edge === "left" || edge === "top"; + const source = makeLeaf(sourceSessionId); + const dropped = makeLeaf(droppedSessionId); + const root = before + ? twoPaneSplit(dropped, source, direction) + : twoPaneSplit(source, dropped, direction); + setCanvas({ root, focusedPaneId: dropped.paneId }); + }, + [], + ); + + const splitPane = useCallback( + (paneId: string, direction: "row" | "col", newSessionId: string) => { + setCanvas((current) => { + if (!current) { + return current; + } + const root = splitLeaf(current.root, paneId, direction, newSessionId); + if (root === current.root) { + return current; // cap reached or pane absent — no-op + } + return { root, focusedPaneId: makeLeaf(newSessionId).paneId }; + }); + }, + [], + ); + + const splitFocused = useCallback( + (direction: "row" | "col", newSessionId: string) => { + setCanvas((current) => { + if (!current) { + return current; + } + const root = splitLeaf( + current.root, + current.focusedPaneId, + direction, + newSessionId, + ); + if (root === current.root) { + return current; + } + return { root, focusedPaneId: makeLeaf(newSessionId).paneId }; + }); + }, + [], + ); + + const closePane = useCallback((paneId: string) => { + setCanvas((current) => { + if (!current) { + return current; + } + const root = closeLeaf(current.root, paneId); + if (root === current.root) { + return current; + } + // Dissolved to a single leaf (or nothing) → no more split. + if (root === null || root.type === "leaf") { + return null; + } + return ensureFocused({ ...current, root }); + }); + }, []); + + const resize = useCallback((path: number[], sizes: number[]) => { + setCanvas((current) => { + if (!current) { + return current; + } + const root = resizeSplit(current.root, path, sizes); + if (root === current.root) { + return current; + } + return { ...current, root }; + }); + }, []); + + const movePane = useCallback( + (paneId: string, targetPaneId: string, edge: DropEdge) => { + setCanvas((current) => { + if (!current) { + return current; + } + const root = moveLeaf(current.root, paneId, targetPaneId, edge); + if (root === current.root) { + return current; + } + if (root.type === "leaf") { + return null; + } + return ensureFocused({ root, focusedPaneId: paneId }); + }); + }, + [], + ); + + const insertPane = useCallback( + (newSessionId: string, targetPaneId: string, edge: DropEdge) => { + setCanvas((current) => { + if (!current) { + return current; + } + const root = insertLeaf(current.root, newSessionId, targetPaneId, edge); + if (root === current.root) { + return current; + } + return { root, focusedPaneId: makeLeaf(newSessionId).paneId }; + }); + }, + [], + ); + + const focusPane = useCallback((paneId: string) => { + setCanvas((current) => { + if (!current || current.focusedPaneId === paneId) { + return current; + } + const exists = collectLeaves(current.root).some( + (leaf) => leaf.paneId === paneId, + ); + return exists ? { ...current, focusedPaneId: paneId } : current; + }); + }, []); + + const leaves = useMemo( + () => (canvas ? collectLeaves(canvas.root) : []), + [canvas], + ); + + return { + canvas, + leaves, + splitSessionIds: leaves.map((l) => l.sessionId), + hasSplit: canvas != null, + focusedPaneId: canvas?.focusedPaneId ?? null, + startSplit, + startSplitByDrop, + splitFocused, + splitPane, + closePane, + resize, + movePane, + insertPane, + focusPane, + }; +} diff --git a/src/shell/components/shell-canvas-conversation.tsx b/src/shell/components/shell-canvas-conversation.tsx new file mode 100644 index 000000000..eb2475ad7 --- /dev/null +++ b/src/shell/components/shell-canvas-conversation.tsx @@ -0,0 +1,460 @@ +// Split-canvas wrapper for the workspace conversation surface. Sits exactly +// where `ShellWorkspaceConversation` used to: it reads the same router/store +// selection, then either renders the UNCHANGED single-conversation path (the +// common case, zero behavioural change) or, when the canvas holds >1 leaf, +// renders a recursive `PaneTreeView` with one full `WorkspaceConversationContainer` +// per leaf. The focused leaf drives the existing single-session selection so +// the inspector, router URL, and shortcut scope follow the active pane. +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo } from "react"; +import { useStore } from "zustand"; +import { useShallow } from "zustand/react/shallow"; +import { + WorkspaceConversationContainer, + type WorkspaceConversationContainerProps, +} from "@/features/conversation"; +import { WorkspacePanelContainer } from "@/features/panel/container"; +import { closeWorkspaceSession } from "@/features/panel/session-close"; +import { createSession } from "@/lib/api"; +import { + workspaceDetailQueryOptions, + workspaceSessionsQueryOptions, +} from "@/lib/query-client"; +import { cn } from "@/lib/utils"; +import { useWorkspaceToast } from "@/lib/workspace-toast-context"; +import { useRouterSelection } from "@/router/use-router-selection"; +import { useCanvasTabDnd } from "@/shell/canvas/canvas-dnd"; +import { PaneTreeView } from "@/shell/canvas/pane-tree-view"; +import type { DropEdge, PaneLeaf } from "@/shell/canvas/tree-model"; +import { useCanvasState } from "@/shell/canvas/use-canvas-state"; +import { useSelectionStore } from "@/shell/controllers/selection-store-context"; +import { publishShellEvent, useShellEvent } from "@/shell/event-bus"; +import { ShellWorkspaceConversation } from "./shell-workspace-conversation"; + +type Props = Omit< + WorkspaceConversationContainerProps, + | "selectedWorkspaceId" + | "displayedWorkspaceId" + | "selectedSessionId" + | "displayedSessionId" +>; + +export function ShellCanvasConversation(props: Props) { + const { sessionId: selectedSessionId } = useRouterSelection(); + const { displayedWorkspaceId } = useStore( + useSelectionStore(), + useShallow((s) => ({ + displayedWorkspaceId: s.displayedWorkspaceId, + })), + ); + const queryClient = useQueryClient(); + const pushToast = useWorkspaceToast(); + + const canvas = useCanvasState({ workspaceId: displayedWorkspaceId }); + + // Workspace + session list — needed to close a pane's session for real + // (same flow the tab × uses), not just remove it from the split. + const detailQuery = useQuery({ + ...workspaceDetailQueryOptions(displayedWorkspaceId ?? "__none__"), + enabled: Boolean(displayedWorkspaceId), + }); + const sessionsQuery = useQuery({ + ...workspaceSessionsQueryOptions(displayedWorkspaceId ?? "__none__"), + enabled: Boolean(displayedWorkspaceId), + }); + const workspace = detailQuery.data ?? null; + const sessions = useMemo( + () => sessionsQuery.data ?? [], + [sessionsQuery.data], + ); + + const { onSelectSession } = props; + + const splitSet = new Set(canvas.splitSessionIds); + // Are we currently looking AT the split (selection is one of its panes)? + // The split persists even when we're not — navigating to another session + // just shows that session; the split is restored on returning to a member. + const viewingSplit = + canvas.hasSplit && !!selectedSessionId && splitSet.has(selectedSessionId); + + const focusedLeaf = + canvas.leaves.find((l) => l.paneId === canvas.focusedPaneId) ?? + canvas.leaves[0] ?? + null; + + // Focus a leaf → make its session the selected one so the inspector, + // router, and chat shortcuts follow the active pane. + const handleFocusPane = useCallback( + (paneId: string) => { + canvas.focusPane(paneId); + const leaf = canvas.leaves.find((l) => l.paneId === paneId); + if (leaf && leaf.sessionId !== selectedSessionId) { + onSelectSession(leaf.sessionId); + } + }, + [canvas, onSelectSession, selectedSessionId], + ); + + const invalidateSessions = useCallback(async () => { + if (!displayedWorkspaceId) return; + await queryClient.invalidateQueries({ + queryKey: workspaceSessionsQueryOptions(displayedWorkspaceId).queryKey, + }); + }, [displayedWorkspaceId, queryClient]); + + // Extend the split: create a fresh session and split the given pane toward it. + const handleSplitPane = useCallback( + async (paneId: string, direction: "row" | "col") => { + if (!displayedWorkspaceId) return; + try { + const { sessionId } = await createSession(displayedWorkspaceId); + canvas.splitPane(paneId, direction, sessionId); + await invalidateSessions(); + onSelectSession(sessionId); + } catch (error) { + console.error("Failed to split canvas pane", error); + } + }, + [canvas, displayedWorkspaceId, invalidateSessions, onSelectSession], + ); + + // Start a brand-new split from the currently-viewed single session. + const handleStartSplit = useCallback( + async (direction: "row" | "col") => { + if (!displayedWorkspaceId || !selectedSessionId) return; + try { + const { sessionId } = await createSession(displayedWorkspaceId); + canvas.startSplit(selectedSessionId, direction, sessionId); + await invalidateSessions(); + onSelectSession(sessionId); + } catch (error) { + console.error("Failed to start split", error); + } + }, + [ + canvas, + displayedWorkspaceId, + invalidateSessions, + onSelectSession, + selectedSessionId, + ], + ); + + const { onRequestCloseSession } = props; + const handleClosePane = useCallback( + (paneId: string) => { + const leaf = canvas.leaves.find((l) => l.paneId === paneId); + const remaining = canvas.leaves.filter((l) => l.paneId !== paneId); + // Remove the pane from the split (instant) and keep a survivor selected + // so focus doesn't land on the session we're about to close. + canvas.closePane(paneId); + if ( + remaining.length >= 1 && + (remaining.length === 1 || leaf?.sessionId === selectedSessionId) + ) { + onSelectSession(remaining[0].sessionId); + } + + // Actually CLOSE the session (hide / delete) — same as the tab × — + // rather than only un-splitting it. Running sessions route through the + // shared confirm-close flow when available. + const session = leaf + ? sessions.find((s) => s.id === leaf.sessionId) + : null; + if (!workspace || !session) { + return; + } + if (onRequestCloseSession) { + onRequestCloseSession({ + workspace, + sessions, + session, + activateAdjacent: false, + provider: null, + onSessionsChanged: () => void invalidateSessions(), + }); + } else { + void closeWorkspaceSession({ + queryClient, + workspace, + sessions, + sessionId: session.id, + activateAdjacent: false, + onSessionsChanged: () => void invalidateSessions(), + pushToast, + }); + } + }, + [ + canvas, + onRequestCloseSession, + onSelectSession, + selectedSessionId, + sessions, + workspace, + queryClient, + pushToast, + invalidateSessions, + ], + ); + + // A pane is "closeable via ⌘W" when we're actively viewing the split. Tell + // the global shortcut handler so ⌘W closes the focused pane (removing it + // from the split) before it would close the session itself. + useEffect(() => { + publishShellEvent({ + type: "canvas-pane-closeable-changed", + active: viewingSplit, + }); + return () => { + publishShellEvent({ + type: "canvas-pane-closeable-changed", + active: false, + }); + }; + }, [viewingSplit]); + + // ⌘W (routed here by the shortcut handler) closes the focused pane. + const focusedPaneId = canvas.focusedPaneId; + useShellEvent("close-focused-canvas-pane", () => { + if (focusedPaneId) { + handleClosePane(focusedPaneId); + } + }); + + // Drag-to-split: from the no-split single view a drop STARTS a split; within + // an existing split a drop MOVES a member pane or INSERTS a new one. + const handleCanvasDrop = useCallback( + ( + sessionId: string, + _sourcePaneId: string | null, + targetPaneId: string, + edge: DropEdge, + ) => { + if (!canvas.hasSplit) { + if (selectedSessionId && sessionId !== selectedSessionId) { + canvas.startSplitByDrop(selectedSessionId, sessionId, edge); + onSelectSession(sessionId); + } + return; + } + const existing = canvas.leaves.find((l) => l.sessionId === sessionId); + if (existing) { + if (existing.paneId !== targetPaneId) { + canvas.movePane(existing.paneId, targetPaneId, edge); + onSelectSession(sessionId); + } + return; + } + canvas.insertPane(sessionId, targetPaneId, edge); + onSelectSession(sessionId); + }, + [canvas, onSelectSession, selectedSessionId], + ); + + const { overlay: dndOverlay } = useCanvasTabDnd({ + enabled: Boolean(displayedWorkspaceId), + onDrop: handleCanvasDrop, + // A real click (no drag) replays the selection we suppress at mousedown + // so dragging a tab never activates it, but clicking still does. + onActivateSession: onSelectSession, + }); + + // Phase 2 (cross-chat connection): a pane's siblings are every OTHER pane. + const siblingSessionIdSet = canvas.splitSessionIds.join(","); + const getSiblingSessionIds = useCallback( + (sessionId: string) => + siblingSessionIdSet + ? siblingSessionIdSet.split(",").filter((id) => id !== sessionId) + : [], + [siblingSessionIdSet], + ); + + // Per-pane overlay is CLOSE-ONLY now — splitting moved to the header (next to + // history) so the split icons no longer overlap the header's editor / right- + // sidebar buttons. The focused pane keeps its × always visible. + const renderPaneOverlay = useCallback( + (leaf: PaneLeaf) => ( + + ), + [canvas.focusedPaneId, handleClosePane], + ); + + // Split the focused pane (header control while viewing the split). + const handleSplitFocused = useCallback( + (direction: "row" | "col") => { + if (focusedLeaf) { + void handleSplitPane(focusedLeaf.paneId, direction); + } + }, + [focusedLeaf, handleSplitPane], + ); + + const canvasGroup = + canvas.hasSplit && focusedLeaf + ? { + sessionIds: canvas.splitSessionIds, + count: canvas.leaves.length, + activeSessionId: focusedLeaf.sessionId, + } + : null; + + // CASE 1 — no split: today's single-conversation path. The split control + // lives in the conversation header (next to history); a dragged tab can also + // start a split via the dropzone. + if (!canvas.hasSplit) { + const dropPaneId = selectedSessionId + ? `pane-${selectedSessionId}` + : undefined; + return ( +
+ + {dndOverlay} +
+ ); + } + + // CASE 2 — split exists but we're viewing a NON-member session: render that + // session normally; its header carries the collapsed "split" group tab so + // the user can click it to return to the split (which is NOT destroyed). + if (!viewingSplit) { + return ( +
+ + {dndOverlay} +
+ ); + } + + // CASE 3 — viewing the split: one shared tab bar above all panes, each pane + // a complete headerless conversation scoped to its own session. + const renderLeaf = (leaf: PaneLeaf) => ( + + ); + + if (!canvas.canvas) { + return ; + } + + return ( +
+ = 4} + selectedWorkspaceId={displayedWorkspaceId} + displayedWorkspaceId={displayedWorkspaceId} + selectedSessionId={focusedLeaf?.sessionId ?? null} + displayedSessionId={focusedLeaf?.sessionId ?? null} + sessionSelectionHistory={props.sessionSelectionHistory} + sending={false} + busySessionIds={props.busySessionIds} + interactionRequiredSessionIds={props.interactionRequiredSessionIds} + workspaceChangeRequest={props.workspaceChangeRequest} + onSelectSession={onSelectSession} + onSelectWorkspace={props.onSelectWorkspace} + onResolveDisplayedSession={props.onResolveDisplayedSession} + onQueuePendingPromptForSession={props.onQueuePendingPromptForSession} + onRequestCloseSession={props.onRequestCloseSession} + contextPreviewCard={props.contextPreviewCard} + contextPreviewActive={props.contextPreviewActive} + onSelectContextPreview={props.onSelectContextPreview} + onCloseContextPreview={props.onCloseContextPreview} + headerActions={props.headerActions} + headerLeading={props.headerLeading} + /> +
+ + {dndOverlay} +
+
+ ); +} + +type PaneCloseControlProps = { + paneId: string; + /** Keep the close button visible without hovering — used for the focused + * pane so closing the selected pane is always one click away. */ + alwaysVisible?: boolean; + onClose: (paneId: string) => void; +}; + +// Per-pane close affordance (top-right of each pane). Splitting now lives in the +// header next to the history button, so this overlay only carries the ×. +function PaneCloseControl({ + paneId, + alwaysVisible = false, + onClose, +}: PaneCloseControlProps) { + return ( +
event.stopPropagation()} + > + +
+ ); +} + +function CloseIcon() { + return ( + + ); +} diff --git a/src/shell/components/workspace-pane-surface.tsx b/src/shell/components/workspace-pane-surface.tsx index c95f20bdc..8794d9213 100644 --- a/src/shell/components/workspace-pane-surface.tsx +++ b/src/shell/components/workspace-pane-surface.tsx @@ -24,7 +24,7 @@ import type { ShellViewMode, } from "@/shell/controllers/use-selection-controller"; import type { StartSurfaceActions } from "@/shell/controllers/use-start-surface-controller"; -import { ShellWorkspaceConversation } from "./shell-workspace-conversation"; +import { ShellCanvasConversation } from "./shell-canvas-conversation"; import { StartSurfacePane } from "./start-surface-pane"; type ConversationProps = WorkspaceConversationContainerProps; @@ -207,7 +207,7 @@ export function WorkspacePaneSurface({ composerAtBottom={startComposerAtBottom} /> ) : ( - setPlanSurfaceActive(e.active)); + const [canvasPaneCloseable, setCanvasPaneCloseable] = useState(false); + useShellEvent("canvas-pane-closeable-changed", (e) => + setCanvasPaneCloseable(e.active), + ); + useGlobalShortcutHandlers({ appSettings, updateSettings, planSurfaceActive, + canvasPaneCloseable, onClosePlan: dispatchClosePlan, contextPanelActions: sel.contextPanelActions, canEditEditorSession: data.canEditEditorSession, diff --git a/src/shell/hooks/use-global-shortcut-handlers.ts b/src/shell/hooks/use-global-shortcut-handlers.ts index 6ee487d10..74f36e51d 100644 --- a/src/shell/hooks/use-global-shortcut-handlers.ts +++ b/src/shell/hooks/use-global-shortcut-handlers.ts @@ -36,6 +36,7 @@ export function useGlobalShortcutHandlers({ appSettings, updateSettings, planSurfaceActive, + canvasPaneCloseable, onClosePlan, contextPanelActions, canEditEditorSession, @@ -72,6 +73,7 @@ export function useGlobalShortcutHandlers({ appSettings: AppSettings; updateSettings: (patch: Partial) => void | Promise; planSurfaceActive: boolean; + canvasPaneCloseable: boolean; onClosePlan: () => void; contextPanelActions: ContextPanelActions; canEditEditorSession: boolean; @@ -191,6 +193,12 @@ export function useGlobalShortcutHandlers({ onClosePlan(); return; } + // Split-canvas: ⌘W closes the focused pane (removes it from the + // split) before falling through to closing the session itself. + if (canvasPaneCloseable) { + publishShellEvent({ type: "close-focused-canvas-pane" }); + return; + } if (workspacePreviewActive && workspacePreviewCard) { contextPanelActions.closeWorkspaceContextPreview(); return; @@ -201,6 +209,7 @@ export function useGlobalShortcutHandlers({ enabled: workspaceViewMode === "conversation" && (planSurfaceActive || + canvasPaneCloseable || Boolean(workspacePreviewCard) || Boolean(getCloseableCurrentSession())), }, @@ -362,6 +371,7 @@ export function useGlobalShortcutHandlers({ workspaceViewMode, canEditEditorSession, planSurfaceActive, + canvasPaneCloseable, onClosePlan, ], );