diff --git a/src/components/DetailPanelHeader/index.tsx b/src/components/DetailPanelHeader/index.tsx index a5b01a479f..483868c5a2 100644 --- a/src/components/DetailPanelHeader/index.tsx +++ b/src/components/DetailPanelHeader/index.tsx @@ -13,7 +13,7 @@ import { HEADER_ICON_SIZE, } from "@src/config/workstation/tokens"; -import { useWindowDrag } from "./useWindowDrag"; +import { useWindowDrag } from "../FloatingWindow/useWindowDrag"; export interface DetailPanelHeaderProps { /** Title to display */ @@ -30,8 +30,10 @@ export interface DetailPanelHeaderProps { actions?: React.ReactNode; /** * When true, dragging the header repositions the nearest - * `[data-draggable-window]` ancestor. Used by the floating Kanban detail - * panel; docked panels leave this off. + * `[data-draggable-window]` ancestor, and the header drops its bottom + * border so the floating window reads as one continuous surface. Used by + * the floating windows (Kanban session preview, side chat); docked panels + * leave this off. */ draggable?: boolean; } @@ -50,7 +52,7 @@ const DetailPanelHeader: React.FC = ({
= [ + { edge: "n", className: "inset-x-2.5 top-0 h-[5px] cursor-ns-resize" }, + { edge: "s", className: "inset-x-2.5 bottom-0 h-[5px] cursor-ns-resize" }, + { edge: "e", className: "inset-y-2.5 right-0 w-[5px] cursor-ew-resize" }, + { edge: "w", className: "inset-y-2.5 left-0 w-[5px] cursor-ew-resize" }, + { edge: "nw", className: "left-0 top-0 h-2.5 w-2.5 cursor-nwse-resize" }, + { edge: "ne", className: "right-0 top-0 h-2.5 w-2.5 cursor-nesw-resize" }, + { edge: "sw", className: "bottom-0 left-0 h-2.5 w-2.5 cursor-nesw-resize" }, + { edge: "se", className: "bottom-0 right-0 h-2.5 w-2.5 cursor-nwse-resize" }, +]; + +export interface FloatingWindowResizeHandlesProps { + minWidth: number; + minHeight: number; + maxWidth?: number; + maxHeight?: number; +} + +const FloatingWindowResizeHandles: React.FC< + FloatingWindowResizeHandlesProps +> = ({ minWidth, minHeight, maxWidth, maxHeight }) => { + const startResize = useWindowResize({ + minWidth, + minHeight, + maxWidth, + maxHeight, + }); + return ( + <> + {HANDLES.map(({ edge, className }) => ( +
+ ))} + + ); +}; + +export default FloatingWindowResizeHandles; diff --git a/src/components/FloatingWindow/index.tsx b/src/components/FloatingWindow/index.tsx new file mode 100644 index 0000000000..907ecb2528 --- /dev/null +++ b/src/components/FloatingWindow/index.tsx @@ -0,0 +1,94 @@ +/** + * FloatingWindow + * + * Shared shell for floating in-pane windows (kanban session preview, chat + * pane side chat): a `pointer-events-none` overlay that defines the drag / + * resize bounds, plus a `data-draggable-window` surface inside it. + * + * - Dragging: give the window a header that spreads `useWindowDrag`'s + * handler (e.g. ``); the surface carries the + * `data-draggable-window` marker the hook looks for. + * - Resizing: enabled by default via invisible edge/corner handles. The + * first resize pins the surface to explicit px geometry; before that it + * follows whatever fluid CSS the caller's `surfaceClassName` sets up. + * - When the overlay itself resizes (app window, split-panel drag), a + * pinned surface is re-fitted so it can never be stranded off-screen. + * + * Callers own the look: pass the overlay class (positioning context, e.g. + * `WORK_MANAGEMENT_SESSION_PREVIEW_OVERLAY_CLASS`) and the surface class + * (initial size/anchor + chrome). The overlay must remain the surface's + * direct parent — both hooks clamp against it. + */ +import React, { useEffect, useRef } from "react"; + +import FloatingWindowResizeHandles from "./ResizeHandles"; +import { fitPinnedWindow } from "./windowGeometry"; + +const DEFAULT_MIN_WIDTH = 360; +const DEFAULT_MIN_HEIGHT = 240; + +export interface FloatingWindowProps { + /** Positioning context + anchoring for the window (pointer-events-none). */ + overlayClassName: string; + /** Initial fluid geometry + chrome of the window surface itself. */ + surfaceClassName: string; + /** Turn off the edge/corner resize handles (drag-only window). */ + resizable?: boolean; + minWidth?: number; + minHeight?: number; + /** Optional px resize caps; the overlay bounds always apply on top. */ + maxWidth?: number; + maxHeight?: number; + children: React.ReactNode; +} + +const FloatingWindow: React.FC = ({ + overlayClassName, + surfaceClassName, + resizable = true, + minWidth = DEFAULT_MIN_WIDTH, + minHeight = DEFAULT_MIN_HEIGHT, + maxWidth, + maxHeight, + children, +}) => { + const overlayRef = useRef(null); + const surfaceRef = useRef(null); + + useEffect(() => { + if (!resizable) return; + const overlay = overlayRef.current; + const surface = surfaceRef.current; + if (!overlay || !surface) return; + const observer = new ResizeObserver(() => { + fitPinnedWindow(surface, minWidth, minHeight); + }); + observer.observe(overlay); + return () => observer.disconnect(); + }, [minHeight, minWidth, resizable]); + + return ( +
+
+ {children} + {resizable && ( + + )} +
+
+ ); +}; + +export default FloatingWindow; diff --git a/src/components/DetailPanelHeader/useWindowDrag.ts b/src/components/FloatingWindow/useWindowDrag.ts similarity index 74% rename from src/components/DetailPanelHeader/useWindowDrag.ts rename to src/components/FloatingWindow/useWindowDrag.ts index 8c066833f4..651050884a 100644 --- a/src/components/DetailPanelHeader/useWindowDrag.ts +++ b/src/components/FloatingWindow/useWindowDrag.ts @@ -7,30 +7,27 @@ * * The transform is applied imperatively to that ancestor's `style`, so a drag * never re-renders the React tree — only one element's `transform` changes. - * The accumulated offset lives in a ref for the hook's lifetime, so the window - * keeps its position across re-renders (e.g. prev/next navigation) and resets - * naturally when the panel unmounts and re-opens. + * The accumulated offset lives on the window element itself (see + * `windowGeometry.ts`), so the position survives re-renders and header + * remounts, and resets naturally when the panel unmounts and re-opens. * * No-op when `enabled` is false or the handle has no `[data-draggable-window]` * ancestor, so the same header stays inert on docked (non-floating) panels. */ import { useCallback, useEffect, useRef } from "react"; +import { + applyWindowOffset, + clamp, + findFloatingWindow, + readWindowBounds, + readWindowOffset, +} from "./windowGeometry"; + const INTERACTIVE_SELECTOR = 'button, a, input, select, textarea, [role="button"], [data-no-window-drag]'; -interface Offset { - x: number; - y: number; -} - -function clamp(value: number, min: number, max: number): number { - if (max < min) return min; - return Math.min(Math.max(value, min), max); -} - export function useWindowDrag(enabled: boolean) { - const offsetRef = useRef({ x: 0, y: 0 }); const cleanupRef = useRef<(() => void) | null>(null); // Tear down listeners / restore the cursor if the panel unmounts mid-drag. @@ -44,14 +41,12 @@ export function useWindowDrag(enabled: boolean) { return; } - const win = event.currentTarget.closest( - "[data-draggable-window]" - ); + const win = findFloatingWindow(event.currentTarget); if (!win) return; - const bounds = win.parentElement?.getBoundingClientRect() ?? null; + const bounds = readWindowBounds(win); const rect = win.getBoundingClientRect(); - const start = offsetRef.current; + const start = readWindowOffset(win); // Untransformed origin of the window, so clamps read in viewport space. const baseLeft = rect.left - start.x; const baseTop = rect.top - start.y; @@ -62,8 +57,8 @@ export function useWindowDrag(enabled: boolean) { let nextX = start.x + (moveEvent.clientX - startX); let nextY = start.y + (moveEvent.clientY - startY); if (bounds) { - // Keep the whole window inside its overlay container (which clips - // overflow), so it can never be dragged out of sight. + // Keep the whole window inside its overlay's content box, so it + // can never be dragged out of sight or over the edge margin. nextX = clamp( nextX, bounds.left - baseLeft, @@ -75,8 +70,7 @@ export function useWindowDrag(enabled: boolean) { bounds.bottom - rect.height - baseTop ); } - offsetRef.current = { x: nextX, y: nextY }; - win.style.transform = `translate3d(${nextX}px, ${nextY}px, 0)`; + applyWindowOffset(win, { x: nextX, y: nextY }); }; const finish = () => { diff --git a/src/components/FloatingWindow/useWindowResize.ts b/src/components/FloatingWindow/useWindowResize.ts new file mode 100644 index 0000000000..3b08f4a0c4 --- /dev/null +++ b/src/components/FloatingWindow/useWindowResize.ts @@ -0,0 +1,132 @@ +/** + * useWindowResize + * + * Edge/corner resizing for a floating window (the nearest + * `[data-draggable-window]` ancestor of the handle). Returns a factory: + * `startResize("se")` gives the pointer-down handler for that handle. + * + * The first resize pins the window (explicit absolute px geometry — see + * `windowGeometry.ts`); from then on each edge moves independently of the + * window's original CSS centering. Like `useWindowDrag`, all writes go + * straight to the element's style, so resizing never re-renders React. + */ +import { useCallback, useEffect, useRef } from "react"; + +import { + clamp, + findFloatingWindow, + pinWindow, + readWindowBounds, +} from "./windowGeometry"; + +export type ResizeEdge = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; + +const EDGE_CURSOR: Record = { + n: "ns-resize", + s: "ns-resize", + e: "ew-resize", + w: "ew-resize", + ne: "nesw-resize", + sw: "nesw-resize", + nw: "nwse-resize", + se: "nwse-resize", +}; + +export interface UseWindowResizeOptions { + minWidth: number; + minHeight: number; + /** Optional px caps; the overlay bounds always apply on top. */ + maxWidth?: number; + maxHeight?: number; +} + +export function useWindowResize({ + minWidth, + minHeight, + maxWidth = Number.POSITIVE_INFINITY, + maxHeight = Number.POSITIVE_INFINITY, +}: UseWindowResizeOptions) { + const cleanupRef = useRef<(() => void) | null>(null); + + // Tear down listeners / restore the cursor if the panel unmounts mid-resize. + useEffect(() => () => cleanupRef.current?.(), []); + + return useCallback( + (edge: ResizeEdge) => (event: React.PointerEvent) => { + if (event.button !== 0) return; + const win = findFloatingWindow(event.currentTarget); + if (!win) return; + const bounds = readWindowBounds(win); + if (!bounds) return; + + event.preventDefault(); + event.stopPropagation(); + + pinWindow(win); + const startLeft = Number.parseFloat(win.style.left) || 0; + const startTop = Number.parseFloat(win.style.top) || 0; + const startWidth = Number.parseFloat(win.style.width) || 0; + const startHeight = Number.parseFloat(win.style.height) || 0; + const startX = event.clientX; + const startY = event.clientY; + + const handleMove = (moveEvent: PointerEvent) => { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + + // The non-dragged edge stays fixed; the window never leaves the + // overlay's content box (padding = edge margin), mirroring the drag + // clamps, and never exceeds the configured px caps. + if (edge.includes("e")) { + const width = clamp( + startWidth + dx, + minWidth, + Math.min(bounds.maxRight - startLeft, maxWidth) + ); + win.style.width = `${width}px`; + } else if (edge.includes("w")) { + const width = clamp( + startWidth - dx, + minWidth, + Math.min(startLeft + startWidth - bounds.minLeft, maxWidth) + ); + win.style.width = `${width}px`; + win.style.left = `${startLeft + startWidth - width}px`; + } + if (edge.includes("s")) { + const height = clamp( + startHeight + dy, + minHeight, + Math.min(bounds.maxBottom - startTop, maxHeight) + ); + win.style.height = `${height}px`; + } else if (edge.includes("n")) { + const height = clamp( + startHeight - dy, + minHeight, + Math.min(startTop + startHeight - bounds.minTop, maxHeight) + ); + win.style.height = `${height}px`; + win.style.top = `${startTop + startHeight - height}px`; + } + }; + + const finish = () => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", finish); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + cleanupRef.current = null; + }; + + cleanupRef.current = finish; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", finish); + document.body.style.cursor = EDGE_CURSOR[edge]; + document.body.style.userSelect = "none"; + }, + [minWidth, minHeight, maxWidth, maxHeight] + ); +} diff --git a/src/components/FloatingWindow/windowGeometry.ts b/src/components/FloatingWindow/windowGeometry.ts new file mode 100644 index 0000000000..a87f24e478 --- /dev/null +++ b/src/components/FloatingWindow/windowGeometry.ts @@ -0,0 +1,176 @@ +/** + * Floating-window geometry helpers. + * + * A floating window is the nearest ancestor carrying `data-draggable-window`. + * All geometry state lives on that DOM element itself (dataset + inline + * style), never in React state: drag and resize both mutate one element's + * style imperatively, so interactions never re-render the tree and the two + * hooks can't drift out of sync — whichever runs next reads the element. + * + * Two positioning modes: + * + * 1. Fluid (initial): the window keeps its CSS layout (e.g. bottom-anchored + * `mx-auto w-full max-h-[...]`) and dragging offsets it with a transform. + * 2. Pinned (after the first resize): the window is converted in place to + * explicit `position:absolute` + px geometry so edge/corner resizing can + * move one edge without CSS centering moving the opposite one. Dragging + * keeps working unchanged — it still just accumulates a transform. + */ + +export const FLOATING_WINDOW_ATTR = "data-draggable-window"; + +export interface WindowOffset { + x: number; + y: number; +} + +export function findFloatingWindow(from: HTMLElement): HTMLElement | null { + return from.closest(`[${FLOATING_WINDOW_ATTR}]`); +} + +export interface WindowBounds { + /** Content-box edges in viewport coordinates (for drag clamps). */ + left: number; + top: number; + right: number; + bottom: number; + /** + * Content-box edges relative to the overlay's padding box — the space + * absolute `left`/`top` resolve in (for pinned resize / re-fit clamps). + */ + minLeft: number; + minTop: number; + maxRight: number; + maxBottom: number; +} + +/** + * The area a floating window may occupy: its overlay's CONTENT box. + * `getBoundingClientRect()` alone returns the border box, which would let + * the window slide over the overlay's padding and touch the outer edge — the + * overlay's padding IS the edge margin, so it is subtracted here for drag, + * resize and re-fit clamps alike. + */ +export function readWindowBounds(win: HTMLElement): WindowBounds | null { + const overlay = win.parentElement; + if (!overlay) return null; + const rect = overlay.getBoundingClientRect(); + const style = getComputedStyle(overlay); + const padLeft = Number.parseFloat(style.paddingLeft) || 0; + const padTop = Number.parseFloat(style.paddingTop) || 0; + const padRight = Number.parseFloat(style.paddingRight) || 0; + const padBottom = Number.parseFloat(style.paddingBottom) || 0; + return { + left: rect.left + padLeft, + top: rect.top + padTop, + right: rect.right - padRight, + bottom: rect.bottom - padBottom, + minLeft: padLeft, + minTop: padTop, + maxRight: rect.width - padRight, + maxBottom: rect.height - padBottom, + }; +} + +export function clamp(value: number, min: number, max: number): number { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +} + +export function readWindowOffset(win: HTMLElement): WindowOffset { + const x = Number.parseFloat(win.dataset.fwOffsetX ?? ""); + const y = Number.parseFloat(win.dataset.fwOffsetY ?? ""); + return { + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, + }; +} + +export function applyWindowOffset( + win: HTMLElement, + offset: WindowOffset +): void { + win.dataset.fwOffsetX = String(offset.x); + win.dataset.fwOffsetY = String(offset.y); + win.style.transform = + offset.x === 0 && offset.y === 0 + ? "" + : `translate3d(${offset.x}px, ${offset.y}px, 0)`; +} + +export function isWindowPinned(win: HTMLElement): boolean { + return win.dataset.fwPinned === "true"; +} + +/** + * Convert the window to explicit absolute geometry at its current visual + * position, folding any drag transform into `left`/`top`. Safe to call + * repeatedly — each call re-bases on the current rect. The parent overlay is + * the containing block (it is `position:absolute` itself); absolute + * `left`/`top` resolve against its padding-box edge, and the resize / re-fit + * clamps constrain them to `readWindowBounds`' `minLeft..maxRight` range. + */ +export function pinWindow(win: HTMLElement): void { + const overlayRect = win.parentElement?.getBoundingClientRect(); + if (!overlayRect) return; + const rect = win.getBoundingClientRect(); + win.style.position = "absolute"; + win.style.left = `${rect.left - overlayRect.left}px`; + win.style.top = `${rect.top - overlayRect.top}px`; + win.style.width = `${rect.width}px`; + win.style.height = `${rect.height}px`; + win.style.margin = "0"; + win.style.maxWidth = "none"; + win.style.maxHeight = "none"; + applyWindowOffset(win, { x: 0, y: 0 }); + win.dataset.fwPinned = "true"; +} + +/** + * Re-fit a pinned window into its overlay after the overlay itself resized + * (app window resize, sidebar toggle). Shrinks first, then shifts, so the + * window can never be stranded outside the visible area. Fluid windows + * already follow the container through CSS and are left alone. + */ +export function fitPinnedWindow( + win: HTMLElement, + minWidth: number, + minHeight: number +): void { + if (!isWindowPinned(win)) return; + const bounds = readWindowBounds(win); + if (!bounds) return; + const availWidth = bounds.maxRight - bounds.minLeft; + const availHeight = bounds.maxBottom - bounds.minTop; + if (availWidth <= 0 || availHeight <= 0) return; + + // Fold any drag transform accumulated since the pin into `left`/`top`, + // so the clamps below act on the window's real visual position. + pinWindow(win); + + const width = clamp( + Number.parseFloat(win.style.width) || 0, + minWidth, + availWidth + ); + const height = clamp( + Number.parseFloat(win.style.height) || 0, + minHeight, + availHeight + ); + const left = clamp( + Number.parseFloat(win.style.left) || 0, + bounds.minLeft, + bounds.maxRight - width + ); + const top = clamp( + Number.parseFloat(win.style.top) || 0, + bounds.minTop, + bounds.maxBottom - height + ); + + win.style.width = `${width}px`; + win.style.height = `${height}px`; + win.style.left = `${left}px`; + win.style.top = `${top}px`; +} diff --git a/src/config/workManagementCardTokens.ts b/src/config/workManagementCardTokens.ts index 046a689fe8..32f2731198 100644 --- a/src/config/workManagementCardTokens.ts +++ b/src/config/workManagementCardTokens.ts @@ -37,7 +37,10 @@ export const WORK_MANAGEMENT_SESSION_CREATOR_OVERLAY_CLASS = export const WORK_MANAGEMENT_SESSION_CREATOR_SURFACE_CLASS = `mx-auto w-full ${WORK_MANAGEMENT_SESSION_CREATOR_MAX_WIDTH_CLASS} pointer-events-auto`; +// The padding is the floating window's hard edge margin: `FloatingWindow` +// clamps drag/resize to the overlay's content box, so the preview (incl. the +// team-session replay loader) can never touch or cross a board edge. export const WORK_MANAGEMENT_SESSION_PREVIEW_OVERLAY_CLASS = - "pointer-events-none absolute inset-x-0 bottom-0 top-0 z-[60] flex items-end px-2 pb-2 pt-1"; + "pointer-events-none absolute inset-0 z-[60] flex items-end p-3"; export const WORK_MANAGEMENT_SESSION_PREVIEW_SURFACE_CLASS = `pointer-events-auto mx-auto flex h-full max-h-[600px] w-full ${WORK_MANAGEMENT_SESSION_CREATOR_MAX_WIDTH_CLASS} flex-col overflow-hidden rounded-[12px] border border-border-2 bg-bg-2 shadow-2xl`; diff --git a/src/engines/ChatPanel/ChatPanelTabBar.test.ts b/src/engines/ChatPanel/ChatPanelTabBar.test.ts index 03aec3f79f..364312efce 100644 --- a/src/engines/ChatPanel/ChatPanelTabBar.test.ts +++ b/src/engines/ChatPanel/ChatPanelTabBar.test.ts @@ -295,6 +295,7 @@ describe("ChatPanelTabBar", () => { onOpenRuntime: vi.fn(), onNewProject: vi.fn(), onNewWorkItem: vi.fn(), + onOpenSideChat: vi.fn(), onClose: vi.fn(), }) ); @@ -303,5 +304,6 @@ describe("ChatPanelTabBar", () => { expect(markup).toContain("sessions:chat.startPage.newSession.title"); expect(markup).toContain("sessions:creator.createTarget.project"); expect(markup).toContain("chat.startPage.newWorkItem.title"); + expect(markup).toContain("sessions:chat.sideChat.title"); }); }); diff --git a/src/engines/ChatPanel/ChatPanelTabBar.tsx b/src/engines/ChatPanel/ChatPanelTabBar.tsx index 5e851a5f40..75e52ac7fa 100644 --- a/src/engines/ChatPanel/ChatPanelTabBar.tsx +++ b/src/engines/ChatPanel/ChatPanelTabBar.tsx @@ -50,6 +50,7 @@ import { ListTodo, Lock, MessageSquarePlus, + PictureInPicture2, Plus, Settings2, TerminalSquare, @@ -115,6 +116,7 @@ import { CHAT_PANEL_CREATE_TARGET, chatPanelCreateTargetAtom, } from "@src/store/ui/chatPanelAtom"; +import { openSideChatAtom } from "@src/store/ui/sideChatAtom"; import { WORK_MANAGEMENT_SECTION } from "@src/store/workstation"; import { isMacOS } from "@src/util/platform/tauri"; @@ -547,6 +549,7 @@ interface PlusMenuContentProps { onOpenRuntime: () => void; onNewProject: () => void; onNewWorkItem: () => void; + onOpenSideChat: () => void; onClose: () => void; } @@ -556,6 +559,7 @@ export function PlusMenuContent({ onOpenRuntime, onNewProject, onNewWorkItem, + onOpenSideChat, onClose, }: PlusMenuContentProps) { const { t } = useTranslation(["sessions", "navigation"]); @@ -595,6 +599,12 @@ export function PlusMenuContent({ label: t("chat.startPage.newWorkItem.title"), onClick: onNewWorkItem, }, + { + id: "side-chat", + icon: , + label: t("sessions:chat.sideChat.title"), + onClick: onOpenSideChat, + }, ] as const; return ( @@ -637,6 +647,7 @@ export interface ChatPanelPlusMenuProps { onOpenRuntime: () => void; onNewProject: () => void; onNewWorkItem: () => void; + onOpenSideChat: () => void; } export function ChatPanelPlusMenu({ @@ -645,6 +656,7 @@ export function ChatPanelPlusMenu({ onOpenRuntime, onNewProject, onNewWorkItem, + onOpenSideChat, }: ChatPanelPlusMenuProps): React.ReactNode { const { t } = useTranslation("sessions"); const [menuOpen, setMenuOpen] = useState(false); @@ -660,6 +672,7 @@ export function ChatPanelPlusMenu({ onOpenRuntime={onOpenRuntime} onNewProject={onNewProject} onNewWorkItem={onNewWorkItem} + onOpenSideChat={onOpenSideChat} onClose={closeMenu} /> } @@ -834,6 +847,13 @@ export function ChatPanelTabBar(): React.ReactNode { }, [openTeamInbox, requestSessionHandoff, t] ); + const openSideChat = useSetAtom(openSideChatAtom); + const handleOpenInSideChat = useCallback( + (reference: SessionReferenceOpen) => { + openSideChat(reference.sessionId); + }, + [openSideChat] + ); // Inline strip — no outer wrapper, fills the flex row in the header return ( @@ -928,6 +948,7 @@ export function ChatPanelTabBar(): React.ReactNode { : undefined } onCreateWorkItem={handleCreateWorkItem} + onOpenInSideChat={handleOpenInSideChat} onCloseTab={closeTab} onCloseOtherTabs={closeOtherTabs} onDismiss={handleDismissContextMenu} diff --git a/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx b/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx index 3b0deb356b..f2c9e24cf0 100644 --- a/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx +++ b/src/engines/ChatPanel/ChatPanelTabContextMenu.tsx @@ -16,6 +16,7 @@ export interface ChatPanelTabContextMenuProps { onCloseOtherTabs: (tabId: string) => void | Promise; sessionReference?: SessionReferenceOpen; onCreateWorkItem?: (reference: SessionReferenceOpen) => void; + onOpenInSideChat?: (reference: SessionReferenceOpen) => void; onDismiss: () => void; } @@ -44,6 +45,18 @@ export function ChatPanelTabContextMenu( const items: NativeMenuItemOptions[] = []; const sessionReference = propsRef.current.sessionReference; if (sessionReference) { + items.push({ + text: translate("sessions:chat.sideChat.openInSideChat", { + defaultValue: "Open in Side Chat", + }), + action: () => { + const current = propsRef.current; + if (current.sessionReference) { + current.onOpenInSideChat?.(current.sessionReference); + } + current.onDismiss(); + }, + }); items.push({ text: translate("teamInbox.handoff.createFromSession", { defaultValue: "Create team Work Item…", diff --git a/src/engines/ChatPanel/SideChat/index.tsx b/src/engines/ChatPanel/SideChat/index.tsx new file mode 100644 index 0000000000..a3094a4230 --- /dev/null +++ b/src/engines/ChatPanel/SideChat/index.tsx @@ -0,0 +1,285 @@ +/** + * ChatPanelSideChat + * + * Global floating picture-in-picture chat window. Hosted by `AppLayout` + * over the whole pane surface (chat slot + workbench), so it works whether + * the chat pane is open or a station fills the view. The window shell + * reuses the kanban session-preview machinery — `FloatingWindow` (drag + * bounds + resize handles) with a draggable `DetailPanelHeader`. + * + * # Why NOT `SessionContentView` / `ChatView` + * + * The full `ChatView` claims the single global event pipeline + * (`activeSessionIdAtom` → `derivedSnapshotAtom`), which can only hold ONE + * session's events. The kanban preview gets away with a `secondary` claim + * because the primary chat column is hidden while the board tab is active; + * the side chat instead floats NEXT TO a visible main chat, so claiming + * the pipeline would hijack the main transcript. We therefore render the + * side session the way subagent grid cells do (`SubagentChatPane`): + * `ChatSessionContext.Provider` + `ChatProvider` route `ChatHistory` to + * `chatEventsForSessionAtomFamily(sessionId)` — a per-session snapshot + * subscription that streams live without touching the global pipeline. + * Sending goes through `SessionService.sendMessage`, which is adapter- + * routed per session id, via the composer's `onSubmitOverride` (the + * `ChannelComposer` call shape). + * + * Two body modes, driven by `sideChatSessionIdAtom`: + * - session id → that session's live chat + composer; + * - `null` → the session creator (new-session mode); a successful + * background launch adopts the new session in place. + */ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { SquareArrowOutUpRight, SquarePen } from "lucide-react"; +import React, { useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import DetailPanelHeader from "@src/components/DetailPanelHeader"; +import FloatingWindow from "@src/components/FloatingWindow"; +import { SESSION_CONFIG } from "@src/config/sessionCreatorConfig"; +import { + HEADER_BUTTON, + HEADER_ICON_SIZE, +} from "@src/config/workstation/tokens"; +import { ChatProvider } from "@src/contexts/workspace/ChatContext"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { createLogger } from "@src/hooks/logger"; +import { openOrFocusSessionInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { sessionMapAtom } from "@src/store/session"; +import { + chatTurnPaginationEnabledAtom, + chatVisibleAtom, + restoreChatWidthAtom, +} from "@src/store/ui/chatPanelAtom"; +import { + closeSideChatAtom, + sideChatSessionIdAtom, + sideChatVisibleAtom, +} from "@src/store/ui/sideChatAtom"; +import { isSessionInProgress } from "@src/util/session/sessionInProgress"; +import { stripPillReferences } from "@src/util/session/stripPillReferences"; + +import ChatHistory from "../ChatHistory"; +import { ChatSessionContext } from "../ChatSessionContext"; +import InputArea from "../InputArea"; +import type { SubmitOverrideInput } from "../hooks/useInputArea/types"; +import type { ChatPanelProps } from "../types"; + +const log = createLogger("ChatPanelSideChat"); + +// The overlay is the drag/resize bounds: the whole pane surface (chat slot +// z-10 + workbench z-0) minus a 12px inset, so the window can never touch or +// cross an edge. z-[70] floats above both and above the kanban tab's own +// overlays (z-[60]). +const SIDE_CHAT_OVERLAY_CLASS = + "pointer-events-none absolute inset-0 z-[70] flex items-end justify-end p-3"; + +// Initial fluid geometry: bottom-right corner, px-capped (kanban preview +// pattern: fill small panes, stop growing past the cap on large ones). The +// first manual resize pins the surface to explicit px geometry. +// +// `@container/focusedchat` re-scopes the chat pane's container queries to +// this window: responsive chrome inside (e.g. the launchpad hero's +// "What do you want to build?" lines, which need a 640px container) sizes +// against the floating surface instead of the whole pane — so the hero +// shows just the agent-name pill here. +const SIDE_CHAT_SURFACE_CLASS = + "pointer-events-auto flex h-full max-h-[600px] min-h-[360px] w-[420px] max-w-full flex-col overflow-hidden rounded-[12px] border border-border-2 bg-bg-2 shadow-2xl @container/focusedchat"; + +// Manual-resize limits (the pane bounds still apply on top of the maxes). +const SIDE_CHAT_MIN_WIDTH = 320; +const SIDE_CHAT_MIN_HEIGHT = 360; +const SIDE_CHAT_MAX_WIDTH = 640; +const SIDE_CHAT_MAX_HEIGHT = 720; + +export interface ChatPanelSideChatProps { + /** + * Same injected creator the chat pane start page renders — passed through + * so new-session mode shares the pane's launch surface (and its ADE + * awareness) instead of ChatPanel depending on SessionCreator directly. + */ + SessionCreatorSlot?: ChatPanelProps["sessionCreatorSlot"]; +} + +const ChatPanelSideChat: React.FC = ({ + SessionCreatorSlot, +}) => { + const visible = useAtomValue(sideChatVisibleAtom); + if (!visible) return null; + return ; +}; + +const SideChatWindow: React.FC = ({ + SessionCreatorSlot, +}) => { + const { t } = useTranslation("sessions"); + const { t: tCommon } = useTranslation("common"); + const [sessionId, setSessionId] = useAtom(sideChatSessionIdAtom); + const closeSideChat = useSetAtom(closeSideChatAtom); + const openSessionTab = useSetAtom(openOrFocusSessionInChatPanelTabAtom); + const chatVisible = useAtomValue(chatVisibleAtom); + const restoreChatWidth = useSetAtom(restoreChatWidthAtom); + const sessionMap = useAtomValue(sessionMapAtom); + const session = sessionId ? sessionMap.get(sessionId) : undefined; + + const sessionName = + session?.name && session.name !== SESSION_CONFIG.DEFAULT_SESSION_NAME + ? session.name + : undefined; + const title = sessionId + ? sessionName || + stripPillReferences(session?.user_input ?? "") || + t("chat.sideChat.title") + : t("chat.newSession"); + + const handleNewSession = useCallback(() => { + setSessionId(null); + }, [setSessionId]); + + const handleOpenInTab = useCallback(() => { + if (!sessionId) return; + openSessionTab({ sessionId }); + // The side chat floats globally, so the chat pane may be collapsed + // (station-only view); reopen it or the promoted tab would be invisible. + if (!chatVisible) restoreChatWidth(); + closeSideChat(); + }, [chatVisible, closeSideChat, openSessionTab, restoreChatWidth, sessionId]); + + const handleSessionStart = useCallback( + (info: { sessionId: string }) => { + setSessionId(info.sessionId); + }, + [setSessionId] + ); + + return ( + + + + +
+ ) : undefined + } + /> + {sessionId ? ( + + ) : SessionCreatorSlot ? ( + // Same launcher format as the chat pane start page: `launchpad` + // layout brings the centered agent hero and the glowing + // `composer-breathing` shell, and forces dropdowns upward. + // `hideWorkItemAttachmentControl` (with no `heroFooterSlot`) drops + // the launchpad action-card grid — no room for it in this window. + // The scoped override narrows the creator's full-pane side padding + // (px-4) to fit the small floating surface. +
+ +
+ ) : null} + + ); +}; + +interface SideChatSessionBodyProps { + sessionId: string; + isLive: boolean; +} + +const SideChatSessionBody: React.FC = ({ + sessionId, + isLive, +}) => { + const turnPaginationEnabled = useAtomValue(chatTurnPaginationEnabledAtom); + + const handleSubmit = useCallback( + async ({ + displayText, + agentContent, + imageDataUrls, + }: SubmitOverrideInput): Promise => { + const content = agentContent ?? displayText; + if (!content.trim()) return false; + try { + await SessionService.sendMessage({ + sessionId, + content, + displayText, + imageDataUrls, + turnIntentSource: "user_submit", + directUserIntent: true, + }); + return true; + } catch (error) { + log.error(`Failed to send side-chat message to ${sessionId}:`, error); + return false; + } + }, + [sessionId] + ); + + return ( + + +
+
+ +
+
+ +
+
+
+
+ ); +}; + +export default ChatPanelSideChat; diff --git a/src/engines/ChatPanel/index.tsx b/src/engines/ChatPanel/index.tsx index 4fab0187cf..14ef17413a 100644 --- a/src/engines/ChatPanel/index.tsx +++ b/src/engines/ChatPanel/index.tsx @@ -62,6 +62,7 @@ import { chatPanelStartPageOpenAtom, chatWidthAtom, } from "@src/store/ui/chatPanelAtom"; +import { openSideChatAtom } from "@src/store/ui/sideChatAtom"; import type { WorkItemDraft } from "@src/store/workstation/projectManager"; import { isHumanSession } from "@src/util/session/sessionDispatch"; @@ -387,6 +388,13 @@ const ChatPanel: React.FC = memo( [openLaunchedSessionTab] ); + const openSideChat = useSetAtom(openSideChatAtom); + const handleOpenSideChat = useCallback(() => { + // Creator mode — the side chat exists to start/watch a session + // without leaving the active tab. + openSideChat(null); + }, [openSideChat]); + const handleChatPanelCollabOrgCreated = useCallback( (_result: CreatedOrgResult) => { bumpProjectListRefresh((previous) => previous + 1); @@ -554,6 +562,7 @@ const ChatPanel: React.FC = memo( onOpenRuntime={handleShowRuntime} onNewProject={handleStartPageNewProject} onNewWorkItem={handleStartPageNewWorkItem} + onOpenSideChat={handleOpenSideChat} /> ); diff --git a/src/features/TaskKanban/components/TaskDetailPanel/index.scss b/src/features/TaskKanban/components/TaskDetailPanel/index.scss index 147369ab9a..4489ed0112 100644 --- a/src/features/TaskKanban/components/TaskDetailPanel/index.scss +++ b/src/features/TaskKanban/components/TaskDetailPanel/index.scss @@ -10,13 +10,8 @@ height: 100%; background: var(--color-bg-2); /* border-left removed - ResizableSplitPanel's resize handle provides the border */ - - /* Both headers (DetailPanelHeader + meta-strip) sit flush against the - chat body — drop their bottom borders so the kanban detail panel - reads as one continuous surface. */ - > :first-child { - border-bottom: none; - } + /* The header's bottom border is dropped by DetailPanelHeader's `draggable` + mode (shared with the side chat), so the panel reads as one surface. */ } .task-detail-panel__content { diff --git a/src/features/TaskKanban/index.tsx b/src/features/TaskKanban/index.tsx index 2ac067cd62..b14d2550c8 100644 --- a/src/features/TaskKanban/index.tsx +++ b/src/features/TaskKanban/index.tsx @@ -24,6 +24,7 @@ import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import Button from "@src/components/Button"; +import FloatingWindow from "@src/components/FloatingWindow"; import { WORK_MANAGEMENT_SESSION_PREVIEW_OVERLAY_CLASS, WORK_MANAGEMENT_SESSION_PREVIEW_SURFACE_CLASS, @@ -424,23 +425,19 @@ const Kanban: React.FC = ({ )} {detailPanelVisible && ( -
-
- -
-
+ + )}
); diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index b5d0c099c1..36ef4fce44 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -1001,6 +1001,10 @@ "newTab": "Neuer Tab", "terminals": "Terminals" }, + "sideChat": { + "title": "Seitenchat", + "openInSideChat": "Im Seitenchat öffnen" + }, "collapseAll": "Alle einklappen", "copyEventJson": "Event-JSON kopieren", "copyEventJsonCopied": "Kopiert!", diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index 193d9f3f82..b1107d0555 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -1058,6 +1058,10 @@ "newTab": "New tab", "terminals": "Terminals" }, + "sideChat": { + "title": "Side Chat", + "openInSideChat": "Open in Side Chat" + }, "collapseAll": "Collapse all", "copyEventJson": "Copy event JSON", "copyEventJsonCopied": "Copied!", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index a7d439f346..50b48506da 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -1003,6 +1003,10 @@ "newTab": "Nueva pestaña", "terminals": "Terminales" }, + "sideChat": { + "title": "Chat lateral", + "openInSideChat": "Abrir en el chat lateral" + }, "collapseAll": "Contraer todo", "copyEventJson": "Copiar JSON de eventos", "copyEventJsonCopied": "¡Copiado!", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index f15b7750f3..deebd49fa1 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -1003,6 +1003,10 @@ "newTab": "Nouvel onglet", "terminals": "Terminaux" }, + "sideChat": { + "title": "Chat latéral", + "openInSideChat": "Ouvrir dans le chat latéral" + }, "collapseAll": "Tout réduire", "copyEventJson": "Copier le JSON des événements", "copyEventJsonCopied": "Copié !", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index a741871004..df9eac675a 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -1002,6 +1002,10 @@ "newTab": "新しいタブ", "terminals": "ターミナル" }, + "sideChat": { + "title": "サイドチャット", + "openInSideChat": "サイドチャットで開く" + }, "collapseAll": "すべて折りたたむ", "copyEventJson": "イベント JSON をコピー", "copyEventJsonCopied": "コピーしました!", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index f4a0f8bb25..25542b0e23 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -1002,6 +1002,10 @@ "newTab": "새 탭", "terminals": "터미널" }, + "sideChat": { + "title": "사이드 채팅", + "openInSideChat": "사이드 채팅에서 열기" + }, "collapseAll": "모두 접기", "copyEventJson": "이벤트 JSON 복사", "copyEventJsonCopied": "복사됨!", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index b626308843..5553226708 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -1004,6 +1004,10 @@ "newTab": "Nowa karta", "terminals": "Terminale" }, + "sideChat": { + "title": "Czat boczny", + "openInSideChat": "Otwórz w czacie bocznym" + }, "collapseAll": "Zwiń wszystko", "copyEventJson": "Kopiuj JSON zdarzeń", "copyEventJsonCopied": "Skopiowano!", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index 2d7bbe2dc5..21dce73fcf 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -1002,6 +1002,10 @@ "newTab": "Nova aba", "terminals": "Terminais" }, + "sideChat": { + "title": "Chat lateral", + "openInSideChat": "Abrir no chat lateral" + }, "collapseAll": "Recolher tudo", "copyEventJson": "Copiar JSON de eventos", "copyEventJsonCopied": "Copiado!", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 7bf0a3ba6a..e5f1636d9f 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -1007,6 +1007,10 @@ "newTab": "Новая вкладка", "terminals": "Терминалы" }, + "sideChat": { + "title": "Боковой чат", + "openInSideChat": "Открыть в боковом чате" + }, "collapseAll": "Свернуть все", "copyEventJson": "Скопировать JSON событий", "copyEventJsonCopied": "Скопировано!", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index 8f27addab7..eab4f3d661 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -1003,6 +1003,10 @@ "newTab": "Yeni sekme", "terminals": "Terminaller" }, + "sideChat": { + "title": "Yan sohbet", + "openInSideChat": "Yan sohbette aç" + }, "collapseAll": "Tümünü daralt", "copyEventJson": "Olay JSON'unu kopyala", "copyEventJsonCopied": "Kopyalandı!", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index b3d9f80d20..4545babf56 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -1000,6 +1000,10 @@ "newTab": "Tab mới", "terminals": "Terminal" }, + "sideChat": { + "title": "Trò chuyện bên", + "openInSideChat": "Mở trong trò chuyện bên" + }, "collapseAll": "Thu gọn tất cả", "copyEventJson": "Sao chép JSON sự kiện", "copyEventJsonCopied": "Đã sao chép!", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 5a32a425e6..9549938eee 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -1017,6 +1017,10 @@ "newTab": "新分頁", "terminals": "終端機" }, + "sideChat": { + "title": "側邊聊天", + "openInSideChat": "在側邊聊天中開啟" + }, "collapseAll": "全部摺疊", "copyEventJson": "複製事件 JSON", "copyEventJsonCopied": "複製!", diff --git a/src/i18n/locales/zh/sessions.json b/src/i18n/locales/zh/sessions.json index d36b60a16b..ac8a952d65 100644 --- a/src/i18n/locales/zh/sessions.json +++ b/src/i18n/locales/zh/sessions.json @@ -1049,6 +1049,10 @@ "newTab": "新标签页", "terminals": "终端" }, + "sideChat": { + "title": "侧边聊天", + "openInSideChat": "在侧边聊天中打开" + }, "collapseAll": "全部折叠", "copyEventJson": "复制事件 JSON", "copyEventJsonCopied": "复制!", diff --git a/src/modules/shared/layouts/AppLayout.tsx b/src/modules/shared/layouts/AppLayout.tsx index 51feef738a..8045dd366c 100644 --- a/src/modules/shared/layouts/AppLayout.tsx +++ b/src/modules/shared/layouts/AppLayout.tsx @@ -22,6 +22,7 @@ import { WindowsTopBar } from "@src/components/WindowChrome"; import { ChatProvider } from "@src/contexts/workspace/ChatContext"; import { DataProvider } from "@src/contexts/workspace/DataContext"; import ChatPanel from "@src/engines/ChatPanel"; +import ChatPanelSideChat from "@src/engines/ChatPanel/SideChat"; import { CHAT_WIDTH_CSS_VAR, clampChatWidth, @@ -322,6 +323,12 @@ const AppLayoutComponent: React.FC = ({ {!isChatOnLeft && chatSlot} + {/* Global floating side chat: hosted over the whole pane + surface (chat slot + workbench), so it stays usable when + the chat pane is hidden and a station fills the view. */} + diff --git a/src/store/ui/index.ts b/src/store/ui/index.ts index 84eb9f400e..ccd822b033 100644 --- a/src/store/ui/index.ts +++ b/src/store/ui/index.ts @@ -51,6 +51,7 @@ export * from "./integrationsToolbarAtom"; export * from "./kanbanViewStateAtom"; export * from "./kanbanReplayAtom"; export * from "./workManagementCreatorAtom"; +export * from "./sideChatAtom"; export * from "./modelSelectorAtom"; export * from "./settingsToolbarAtom"; export * from "./globalTabsTypes"; diff --git a/src/store/ui/sideChatAtom.ts b/src/store/ui/sideChatAtom.ts new file mode 100644 index 0000000000..3c70f27bad --- /dev/null +++ b/src/store/ui/sideChatAtom.ts @@ -0,0 +1,34 @@ +/** + * Chat pane floating side chat. + * + * A picture-in-picture chat window floating over the chat pane, so a second + * session can be watched and driven without leaving the active tab. While + * visible, `sessionId === null` means the window is in new-session mode and + * shows the session creator; a successful launch flips it to the session. + */ +import { atom } from "jotai"; + +export const sideChatVisibleAtom = atom(false); +sideChatVisibleAtom.debugLabel = "chatPanel/sideChat/visible"; + +export const sideChatSessionIdAtom = atom(null); +sideChatSessionIdAtom.debugLabel = "chatPanel/sideChat/sessionId"; + +/** + * Open the side chat on a session, or on the creator (`null`) to start a new + * session in it. + */ +export const openSideChatAtom = atom( + null, + (_get, set, sessionId: string | null) => { + set(sideChatSessionIdAtom, sessionId); + set(sideChatVisibleAtom, true); + } +); +openSideChatAtom.debugLabel = "chatPanel/sideChat/open"; + +export const closeSideChatAtom = atom(null, (_get, set) => { + set(sideChatVisibleAtom, false); + set(sideChatSessionIdAtom, null); +}); +closeSideChatAtom.debugLabel = "chatPanel/sideChat/close";