diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d4e3dec7e32..08cc1f1adb5 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -373,6 +373,9 @@ pub fn run() { .inner_size(1100.0, 720.0) .visible(false) .user_agent(&window::webview_user_agent()) + // Keep dashboard HTML drag/drop (including chat panes) in the webview; + // Tauri's native drop handler intercepts it on Windows. + .disable_drag_drop_handler() // Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on // macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted // to the loopback dashboard by `capabilities/dashboard-zoom.json`. diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md index 939ea624de9..1ebc0f725a8 100644 --- a/docs-site/src/content/docs/guides/remote-workspace.md +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -171,6 +171,29 @@ Prompt submission acknowledges acceptance immediately; the dashboard polls the s cancels an active Executor command, and prevents a late response from reopening the stopped session. +## Work with sessions side by side + +The session list on the left opens a session in the focused pane. Drag a session to the left, +right, top, or bottom edge of another pane to split the conversation area. You can show up to +four different sessions at once. An already visible session moves between panes instead of +opening a duplicate composer. + +Use **Split right** or **Split below** next to a session for the keyboard-accessible alternative. +Drag a divider to resize the panes, or focus the divider and use its arrow keys. **Home** and +**End** set the divider to its minimum and maximum size. Each session +keeps its own draft, submission state, and Stop action, so a pending turn in one pane does not +block a different session. **Close pane** changes the layout only; it does not stop the session. + +The pane layout is restored for this Hub in the same browser. Only session identifiers and pane +positions and divider sizes are saved; draft text and model output are not written to browser storage. Reloading +the page clears unsent drafts. On screens up to 760px wide, panes stack vertically without +resize dividers; the saved split positions and sizes return when the screen is wider. + +This view is shared by the browser dashboard and the OpenCodex desktop dashboard. CLI users +can open the dashboard with `ocx gui` and select **Remote Workspace** after its normal Hub setup. +It does not change the terminal interface of `codex` or the conversation layout inside the +separate official ChatGPT/Codex desktop app. + ## Restart and reconnect behavior The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart, diff --git a/docs-site/src/content/docs/ko/guides/remote-workspace.md b/docs-site/src/content/docs/ko/guides/remote-workspace.md index ef5b6490aec..338234f4da3 100644 --- a/docs-site/src/content/docs/ko/guides/remote-workspace.md +++ b/docs-site/src/content/docs/ko/guides/remote-workspace.md @@ -95,6 +95,28 @@ ocx remote-workspace status 프롬프트 실행 중에도 **Stop**을 사용할 수 있습니다. Hub 코딩 에이전트의 현재 턴을 중단하고 활성 Executor 명령을 취소하며, 늦게 온 응답이 중지된 세션을 다시 열지 못하게 합니다. +## 여러 세션을 분할해서 보기 + +왼쪽 세션 목록에서 세션을 클릭하면 현재 선택한 창에서 열립니다. 세션을 다른 창의 +왼쪽·오른쪽·위·아래 가장자리로 끌어다 놓으면 화면이 분할됩니다. 최대 네 개의 서로 다른 +세션을 동시에 표시할 수 있고, 이미 표시 중인 세션은 중복 창을 만드는 대신 이동합니다. + +세션 옆 **오른쪽으로 분할** 또는 **아래로 분할** 버튼도 사용할 수 있습니다. 경계선을 +드래그하거나 경계선에 키보드 초점을 놓고 방향키를 눌러 크기를 조절하세요. **Home**과 +**End** 키는 최소·최대 크기로 조절합니다. 각 세션의 +입력 중인 글, 전송 상태, 중지 동작은 독립적입니다. **창 닫기**는 배치만 변경하며 세션을 +중지하지 않습니다. + +같은 브라우저에서 이 Hub를 다시 열면 창 배치가 복원됩니다. 브라우저 저장소에는 세션 +식별자, 분할 배치와 경계선 크기만 저장하며, 작성 중인 글이나 모델 응답은 저장하지 않습니다. +새로고침하면 아직 보내지 않은 글은 지워집니다. 화면 너비가 760px 이하이면 경계선 없이 +창을 세로로 쌓아 표시하고, 화면이 넓어지면 저장된 분할 배치와 크기로 돌아갑니다. + +이 화면은 OpenCodex 브라우저 대시보드와 데스크톱 앱에서 공통으로 사용합니다. CLI에서는 +`ocx gui`로 대시보드를 열고 정상적인 Hub 설정 후 **원격 워크스페이스**를 선택하세요. +이 기능은 `codex` 터미널 화면이나 별도 공식 ChatGPT/Codex 앱 내부의 대화 배치를 변경하지 +않습니다. + ## 재시작과 재연결 동작 Hub는 제한된 세션 메타데이터와 최근 이벤트의 작은 스냅샷을 저장합니다. Hub 재시작 뒤 완료되지 않은 세션은 원래 Executor를 기다립니다. 해당 기기가 다시 연결되면 다음 프롬프트가 원래 Codex 작업, Claude Code 세션 또는 Pi 세션 ID를 이어갑니다. diff --git a/gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx b/gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx new file mode 100644 index 00000000000..61f28ad2cf8 --- /dev/null +++ b/gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState, type CSSProperties, type DragEvent, type ReactNode } from "react"; +import { IconArrowDown, IconChevron, IconGrip, IconX } from "../../icons"; +import { useT } from "../../i18n/shared"; +import { Notice } from "../../ui"; +import { EVENT_TKEY, PROFILE_LABEL, STATUS_TKEY, type RemoteDevice, type RemoteSession } from "./types"; +import { MAX_PANES, minLayoutWidth, openPane, paneIds, parseLayout, reconcileLayout, removePane, resizeSplit, splitPane, type PaneLayout, type SplitEdge } from "./split-layout"; +import { useSessionActions } from "./use-session-actions"; + +const DRAG_TYPE = "application/x-opencodex-session"; +const EDGES: SplitEdge[] = ["left", "right", "up", "down"]; + +// Only a nested split reserves its width; a leaf pane stays free to follow the ratio. +const trackMin = (child: PaneLayout) => child.type === "split" ? minLayoutWidth(child) : 0; + +function SplitBranch({ node, renderPane, resize }: { + node: PaneLayout; renderPane: (id: string) => ReactNode; resize: (id: string, ratio: number) => void; +}) { + const t = useT(); + const container = useRef(null); + const dragging = useRef(false); + if (node.type === "pane") return renderPane(node.sessionId); + const style = { "--split-ratio": node.ratio, "--split-min-width": minLayoutWidth(node), + "--split-first-min": trackMin(node.first), "--split-second-min": trackMin(node.second) } as CSSProperties; + return
+
+
{ if (event.button !== 0) return; event.preventDefault(); event.currentTarget.focus(); dragging.current = true; event.currentTarget.setPointerCapture(event.pointerId); }} + onPointerMove={event => { + if (!dragging.current || !container.current) return; + const rect = container.current.getBoundingClientRect(); + const size = node.axis === "row" ? rect.width : rect.height; + if (size > 0) resize(node.id, (node.axis === "row" ? event.clientX - rect.left : event.clientY - rect.top) / size); + }} + onPointerUp={event => { dragging.current = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} + onPointerCancel={() => { dragging.current = false; }} onLostPointerCapture={() => { dragging.current = false; }} + onKeyDown={event => { + const backward = node.axis === "row" ? "ArrowLeft" : "ArrowUp", forward = node.axis === "row" ? "ArrowRight" : "ArrowDown"; + if (![backward, forward, "Home", "End"].includes(event.key)) return; + event.preventDefault(); + resize(node.id, event.key === "Home" ? 0.2 : event.key === "End" ? 0.8 : node.ratio + (event.key === backward ? -0.05 : 0.05)); + }} /> +
+
; +} + +export function RemoteWorkspaceChats({ apiBase, sessions, devices, selectedSessionId, stale, refresh }: { + apiBase: string; sessions: RemoteSession[]; devices: RemoteDevice[]; selectedSessionId: string; stale: boolean; refresh: () => unknown; +}) { + const t = useT(); + const storageKey = `ocx-remote-workspace-layout:${apiBase}`; + const [layout, setLayout] = useState(() => { + try { return parseLayout(localStorage.getItem(storageKey)); } catch { return null; } + }); + const [active, setActive] = useState(""); + const [dragged, setDragged] = useState(null); + const [dropTarget, setDropTarget] = useState<{ id: string; edge: SplitEdge } | null>(null); + const [focusRequest, setFocusRequest] = useState<{ id: string } | null>(null); + const [lastSelection, setLastSelection] = useState(selectedSessionId); + const host = useRef(null); + const actions = useSessionActions(apiBase, sessions, devices, stale, refresh); + const ids = actions.sessions.map(session => session.id); + const fallback = [...actions.sessions].reverse().find(session => session.status !== "stopped")?.id ?? ids.at(-1); + const effectiveLayout = reconcileLayout(layout, ids, fallback); + const visible = paneIds(effectiveLayout); + const activeId = visible.includes(active) ? active : visible[0] ?? ""; + + if (lastSelection !== selectedSessionId) { + setLastSelection(selectedSessionId); + if (selectedSessionId && ids.includes(selectedSessionId)) { + setLayout(openPane(effectiveLayout, selectedSessionId, activeId)); + setActive(selectedSessionId); + } + } else if (layout !== effectiveLayout) setLayout(effectiveLayout); + + useEffect(() => { + try { localStorage.setItem(storageKey, JSON.stringify(effectiveLayout)); } catch { /* The workspace remains usable without storage. */ } + }, [effectiveLayout, storageKey]); + useEffect(() => { + if (!focusRequest) return; + const heading = [...(host.current?.querySelectorAll("[data-pane-heading]") ?? [])].find(element => element.dataset.paneHeading === focusRequest.id); + heading?.focus(); + }, [focusRequest]); + + const open = (id: string) => { setLayout(openPane(effectiveLayout, id, activeId)); setActive(id); }; + const split = (id: string, target: string, edge: SplitEdge) => { + if (!ids.includes(id)) return; + const next = splitPane(effectiveLayout, id, target, edge); + setLayout(next); + if (paneIds(next).includes(id)) setActive(id); + setDragged(null); setDropTarget(null); + }; + const close = (id: string) => { + const next = removePane(effectiveLayout, id); + const nextId = paneIds(next)[0] ?? ""; + setLayout(next); setActive(nextId); setFocusRequest({ id: nextId }); + }; + const startDrag = (event: DragEvent, id: string) => { event.dataTransfer.setData(DRAG_TYPE, id); event.dataTransfer.effectAllowed = "move"; setDragged(id); }; + const endDrag = () => { setDragged(null); setDropTarget(null); }; + const canSplit = (id: string, target: string) => id !== target && (visible.includes(id) || visible.length < MAX_PANES); + + const renderPane = (id: string) => { + const session = actions.sessions.find(value => value.id === id); + if (!session) return null; + const disabled = session.status === "stopped" || (session.status === "failed" && session.resumable === false); + return
setActive(id)} onPointerDown={() => setActive(id)}> +
+
startDrag(event, id)} onDragEnd={endDrag}> +
{PROFILE_LABEL[session.profile]}{session.deviceName}/{session.rootLabel}
+
+ {t(STATUS_TKEY[session.status])} + +
+ {session.accessMode === "read-only" ? t("remote.access.readOnly") : session.capabilities.includes("workspace.exec") ? t("remote.access.workspace") : t("remote.access.workspaceFilesOnly")} +
+ {session.events.length === 0 ?

{t("remote.noEvents")}

: session.events.map(event =>
+ {event.type === "assistant" ? PROFILE_LABEL[session.profile] : t(EVENT_TKEY[event.type])}

{event.text}

+
)} +
+ {session.status === "failed" && session.resumable === false ? {t("remote.notResumable")} : null} + {actions.errors[id] ? {actions.errors[id]} : null} +