Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
23 changes: 23 additions & 0 deletions docs-site/src/content/docs/guides/remote-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions docs-site/src/content/docs/ko/guides/remote-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 이어갑니다.
Expand Down
157 changes: 157 additions & 0 deletions gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 <div ref={container} className={`remote-split-branch remote-split-branch--${node.axis}`} style={style}>
<div className="remote-split-child"><SplitBranch node={node.first} renderPane={renderPane} resize={resize} /></div>
<div role="separator" tabIndex={0} className="remote-split-divider" aria-label={t("remote.split.resize")}
aria-orientation={node.axis === "row" ? "vertical" : "horizontal"} aria-valuemin={20} aria-valuemax={80} aria-valuenow={Math.round(node.ratio * 100)}
onPointerDown={event => { 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));
}} />
<div className="remote-split-child"><SplitBranch node={node.second} renderPane={renderPane} resize={resize} /></div>
</div>;
}

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<PaneLayout | null>(() => {
try { return parseLayout(localStorage.getItem(storageKey)); } catch { return null; }
});
const [active, setActive] = useState("");
const [dragged, setDragged] = useState<string | null>(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<HTMLElement>(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<HTMLElement>("[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 <section key={id} className={`remote-chat-pane remote-console-panel${id === activeId ? " is-active" : ""}`} data-session-id={id}
aria-label={`${PROFILE_LABEL[session.profile]} · ${session.deviceName}/${session.rootLabel}`} onFocusCapture={() => setActive(id)} onPointerDown={() => setActive(id)}>
<header className="remote-chat-pane-head">
<div tabIndex={-1} data-pane-heading={id} className="remote-chat-pane-name" draggable onDragStart={event => startDrag(event, id)} onDragEnd={endDrag}>
<IconGrip /><div><strong>{PROFILE_LABEL[session.profile]}</strong><small>{session.deviceName}/{session.rootLabel}</small></div>
</div>
<span className={`remote-status remote-status--${session.status}`}>{t(STATUS_TKEY[session.status])}</span>
<button type="button" className="btn btn-ghost btn-sm remote-pane-close" aria-label={t("remote.split.close")} title={t("remote.split.close")} disabled={visible.length === 1} onClick={() => close(id)}><IconX /></button>
</header>
<small className="remote-chat-access">{session.accessMode === "read-only" ? t("remote.access.readOnly") : session.capabilities.includes("workspace.exec") ? t("remote.access.workspace") : t("remote.access.workspaceFilesOnly")}</small>
<div className="remote-events" aria-live="polite" aria-label={t("remote.events")}>
{session.events.length === 0 ? <p className="remote-empty">{t("remote.noEvents")}</p> : session.events.map(event => <div key={event.sequence} className={`remote-event remote-event--${event.type}`}>
<span>{event.type === "assistant" ? PROFILE_LABEL[session.profile] : t(EVENT_TKEY[event.type])}</span><p>{event.text}</p>
</div>)}
</div>
{session.status === "failed" && session.resumable === false ? <Notice tone="err">{t("remote.notResumable")}</Notice> : null}
{actions.errors[id] ? <Notice tone="err">{actions.errors[id]}</Notice> : null}
<label className="remote-composer"><span className="field-label">{t("remote.prompt")}</span>
<textarea className="input" rows={3} value={actions.drafts[id] ?? ""} onChange={event => actions.setDraft(id, event.target.value)} placeholder={t("remote.promptPlaceholder")} disabled={disabled}
onKeyDown={event => { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { event.preventDefault(); void actions.send(session); } }} />
</label>
<div className="remote-console-actions">
<button type="button" className="btn btn-primary" disabled={!actions.canSend(session)} onClick={() => void actions.send(session)}>{t("remote.send")}</button>
<button type="button" className="btn btn-danger" disabled={actions.pending[id]?.stop || session.status === "stopped"} onClick={() => void actions.stop(session)}>{t("remote.stop")}</button>
</div>
{dragged && canSplit(dragged, id) ? <div className="remote-pane-drop-targets">{EDGES.map(edge => <div key={edge} className={`remote-pane-drop remote-pane-drop--${edge}${dropTarget?.id === id && dropTarget.edge === edge ? " is-over" : ""}`}
onDragOver={event => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setDropTarget({ id, edge }); }}
onDrop={event => { event.preventDefault(); const source = event.dataTransfer.getData(DRAG_TYPE); if (source === dragged) split(source, id, edge); else endDrag(); }}>
{t(`remote.split.${edge}`)}
</div>)}</div> : null}
</section>;
};

return <section ref={host} className="remote-chats-workspace" aria-label={t("remote.split.title")}>
<div className="remote-chats-toolbar"><h3>{t("remote.sessions")}</h3><p>{t("remote.split.hint")}</p></div>
<div className="remote-chats-layout">
<aside className="remote-session-rail" aria-label={t("remote.sessions")}>
{actions.sessions.map(session => <div key={session.id} className={`remote-session-row${activeId === session.id ? " is-active" : ""}`} draggable onDragStart={event => startDrag(event, session.id)} onDragEnd={endDrag}>
<button type="button" className="remote-session-open" data-session-open={session.id} aria-pressed={activeId === session.id} title={t("remote.split.open")} onClick={() => open(session.id)}>
<IconGrip /><span><strong>{PROFILE_LABEL[session.profile]} · {session.rootLabel}</strong><small>{session.deviceName} · {t(STATUS_TKEY[session.status])}</small></span>
</button>
<div className="remote-session-split-actions">
<button type="button" className="btn btn-ghost btn-sm" title={t("remote.split.right")} aria-label={t("remote.split.right")} disabled={!activeId || !canSplit(session.id, activeId)} onClick={() => split(session.id, activeId, "right")}><IconChevron /></button>
<button type="button" className="btn btn-ghost btn-sm" title={t("remote.split.down")} aria-label={t("remote.split.down")} disabled={!activeId || !canSplit(session.id, activeId)} onClick={() => split(session.id, activeId, "down")}><IconArrowDown /></button>
</div>
</div>)}
{visible.length >= MAX_PANES ? <p className="remote-split-limit">{t("remote.split.limit")}</p> : null}
</aside>
<div className="remote-pane-surface">{effectiveLayout ? <SplitBranch node={effectiveLayout} renderPane={renderPane} resize={(id, ratio) => setLayout(resizeSplit(effectiveLayout, id, ratio))} /> : <p className="remote-empty remote-empty--console">{t("remote.noSessions")}</p>}</div>
</div>
</section>;
}
Loading
Loading