Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
"@shikijs/transformers": "^4.0.2",
"@tanstack/react-query": "^5.90.3",
"@tanstack/react-virtual": "^3.13.23",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"ai": "^6.0.146",
"boring-avatars": "^2.0.4",
"class-variance-authority": "^0.7.1",
Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/app/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ declare global {
onPanelOpened?: (callback: () => void) => () => void;
onPanelClosed?: (callback: () => void) => () => void;
};
terminal?: {
create?: (options: { cwd: string; cols: number; rows: number }) => Promise<{ terminalId: string }>;
write?: (terminalId: string, data: string) => Promise<void>;
resize?: (terminalId: string, cols: number, rows: number) => Promise<void>;
kill?: (terminalId: string) => Promise<void>;
onData?: (callback: (payload: { terminalId: string; data: string }) => void) => () => void;
onExit?: (callback: (payload: { terminalId: string; exitCode: number | null; signal?: number }) => void) => () => void;
};
meta?: {
initialDeepLinks?: string[];
platform?: "darwin" | "linux" | "windows";
Expand Down
23 changes: 20 additions & 3 deletions apps/app/src/react-app/domains/session/chat/session-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { isElectronRuntime } from "../../../../app/utils";
import { isCollectibleArtifactTarget, isLocalhostBrowserTarget, type OpenTarget } from "../artifacts/open-target";
import { VoicePanel } from "../voice/voice-panel";
import { SidePanel } from "../panel/side-panel";
import { TerminalDock } from "../terminal/terminal-dock";
import { useActivePanelTab, usePanelTabStore, useSessionPanelState } from "../panel/panel-tab-store";
import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout";
import { useControlAction, type OpenworkControlAction } from "../../../shell/control/control-provider";
Expand Down Expand Up @@ -178,6 +179,8 @@ export type SessionPageProps = {
onAccessibleTargetsChange?: (targets: OpenTarget[]) => void;
/** Settings content rendered inside the right pane when the settings rail icon is active. */
settingsSlot?: React.ReactNode;
terminalOpen?: boolean;
onTerminalOpenChange?: (open: boolean) => void;
};

function getSidebarInitialLoading(props: SessionPageSidebarProps) {
Expand Down Expand Up @@ -822,8 +825,9 @@ export function SessionPage(props: SessionPageProps) {
</div>
</header>

<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="relative min-w-0 flex-1 overflow-hidden bg-dls-surface mac:bg-dls-surface/85 mac:backdrop-blur-2xl mac:backdrop-saturate-150">
<ResizablePanelGroup orientation="vertical" className="min-h-0 flex-1 overflow-hidden">
<ResizablePanel minSize="180px" className="min-h-0">
<div className="relative h-full min-w-0 overflow-hidden bg-dls-surface mac:bg-dls-surface/85 mac:backdrop-blur-2xl mac:backdrop-saturate-150">
{showStartupSkeleton ? (
<div className="px-6 py-14" role="status" aria-live="polite">
<div className="mx-auto max-w-2xl space-y-6">
Expand Down Expand Up @@ -1114,7 +1118,20 @@ export function SessionPage(props: SessionPageProps) {
</div>
) : null}
</div>
</div>
</ResizablePanel>
{props.terminalOpen ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Hiding the panel unmounts and kills the PTY, so reopening loses the shell session instead of just hiding it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/react-app/domains/session/chat/session-page.tsx, line 1122:

<comment>Hiding the panel unmounts and kills the PTY, so reopening loses the shell session instead of just hiding it.</comment>

<file context>
@@ -1114,7 +1118,20 @@ export function SessionPage(props: SessionPageProps) {
             </div>
-          </div>
+            </ResizablePanel>
+            {props.terminalOpen ? (
+              <>
+                <ResizableHandle withHandle />
</file context>

<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="280px" minSize="160px" maxSize="55%" className="min-h-0">
<TerminalDock
workspaceRoot={props.selectedWorkspaceRoot}
isRemoteWorkspace={props.selectedWorkspaceDisplay.workspaceType === "remote"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Terminal remote gating uses workspace display type instead of runtime remote state. This can incorrectly enable a local PTY on remote sessions or block terminal on local sessions when metadata differs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/react-app/domains/session/chat/session-page.tsx, line 1128:

<comment>Terminal remote gating uses workspace display type instead of runtime remote state. This can incorrectly enable a local PTY on remote sessions or block terminal on local sessions when metadata differs.</comment>

<file context>
@@ -1114,7 +1118,20 @@ export function SessionPage(props: SessionPageProps) {
+                <ResizablePanel defaultSize="280px" minSize="160px" maxSize="55%" className="min-h-0">
+                  <TerminalDock
+                    workspaceRoot={props.selectedWorkspaceRoot}
+                    isRemoteWorkspace={props.selectedWorkspaceDisplay.workspaceType === "remote"}
+                    onClose={() => props.onTerminalOpenChange?.(false)}
+                  />
</file context>
Suggested change
isRemoteWorkspace={props.selectedWorkspaceDisplay.workspaceType === "remote"}
isRemoteWorkspace={props.surface?.isRemoteWorkspace ?? false}

onClose={() => props.onTerminalOpenChange?.(false)}
/>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>

{shellConfig.statusBar ? (
<StatusBar
Expand Down
131 changes: 131 additions & 0 deletions apps/app/src/react-app/domains/session/terminal/terminal-dock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/** @jsxImportSource react */
import { useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { X } from "lucide-react";

import { Button } from "@/components/ui/button";
import { isElectronRuntime } from "../../../../app/utils";

type TerminalDockProps = {
workspaceRoot: string;
isRemoteWorkspace: boolean;
onClose: () => void;
};

export function TerminalDock({ workspaceRoot, isRemoteWorkspace, onClose }: TerminalDockProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalIdRef = useRef<string | null>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const [status, setStatus] = useState("Starting terminal...");

useEffect(() => {
if (!containerRef.current) return;
if (!isElectronRuntime()) {
setStatus("Terminal is available in the desktop app.");
return;
}
if (isRemoteWorkspace) {
setStatus("Remote workspace terminals are not wired yet.");
return;
}

const bridge = window.__OPENWORK_ELECTRON__?.terminal;
if (!bridge?.create || !bridge.write || !bridge.resize || !bridge.kill || !bridge.onData || !bridge.onExit) {
setStatus("Terminal bridge is unavailable.");
return;
}
const createTerminal = bridge.create;
const writeTerminal = bridge.write;
const resizeTerminal = bridge.resize;
const killTerminal = bridge.kill;
const onTerminalData = bridge.onData;
const onTerminalExit = bridge.onExit;

let disposed = false;
const fitAddon = new FitAddon();
const terminal = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily: "'SFMono-Regular', 'Cascadia Code', 'Liberation Mono', Menlo, monospace",
fontSize: 12,
theme: {
background: "#0b0d12",
foreground: "#d7dde8",
cursor: "#ffffff",
selectionBackground: "#334155",
},
});
terminal.loadAddon(fitAddon);
terminal.open(containerRef.current);
terminal.focus();
fitAddon.fit();
terminalRef.current = terminal;
fitRef.current = fitAddon;

const removeDataListener = onTerminalData(({ terminalId, data }) => {
if (terminalIdRef.current !== terminalId) return;
terminal.write(data);
});
const removeExitListener = onTerminalExit(({ terminalId, exitCode }) => {
if (terminalIdRef.current !== terminalId) return;
setStatus(`Terminal exited${exitCode === null ? "" : ` with code ${exitCode}`}.`);
terminalIdRef.current = null;
});
const inputDisposable = terminal.onData((data) => {
const terminalId = terminalIdRef.current;
if (!terminalId) return;
void writeTerminal(terminalId, data);
});

const fitAndResize = () => {
fitAddon.fit();
const terminalId = terminalIdRef.current;
if (!terminalId) return;
void resizeTerminal(terminalId, terminal.cols, terminal.rows);
};
const resizeObserver = new ResizeObserver(fitAndResize);
resizeObserver.observe(containerRef.current);

void createTerminal({ cwd: workspaceRoot, cols: terminal.cols, rows: terminal.rows }).then(({ terminalId }) => {
if (disposed) {
void killTerminal(terminalId);
return;
}
terminalIdRef.current = terminalId;
setStatus(workspaceRoot);
fitAndResize();
}).catch((error) => {
setStatus(error instanceof Error ? error.message : "Could not start terminal.");
});

return () => {
disposed = true;
resizeObserver.disconnect();
inputDisposable.dispose();
removeDataListener();
removeExitListener();
const terminalId = terminalIdRef.current;
terminalIdRef.current = null;
if (terminalId) void killTerminal(terminalId);
terminal.dispose();
terminalRef.current = null;
fitRef.current = null;
};
}, [isRemoteWorkspace, workspaceRoot]);

return (
<section className="flex h-full min-h-0 flex-col border-t border-border bg-[#0b0d12] text-white" aria-label="Terminal">
<header className="flex h-9 shrink-0 items-center justify-between border-b border-white/10 bg-black/35 px-3 text-xs">
<div className="min-w-0 truncate text-white/75">Terminal · {status}</div>
<Button variant="ghost" size="icon-sm" className="text-white/70 hover:bg-white/10 hover:text-white" onClick={onClose}>
<X className="size-4" />
<span className="sr-only">Hide terminal</span>
</Button>
</header>
<div ref={containerRef} className="min-h-0 flex-1 px-2 py-1 [&_.xterm]:h-full" />
</section>
);
}
2 changes: 2 additions & 0 deletions apps/app/src/react-app/shell/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export type CommandPaletteProps = {
onHideAccessibleTarget?: (target: AccessibleTargetOption) => void;
/** Optional: sessions for the second mode. */
sessions: SessionOption[];
extraItems?: PaletteItem[];
};

/**
Expand Down Expand Up @@ -151,6 +152,7 @@ export function CommandPalette(props: CommandPaletteProps) {
setMode("accessible-items");
},
},
...(props.extraItems ?? []),
{
id: "open-settings",
title: t("settings.tab_general"),
Expand Down
26 changes: 25 additions & 1 deletion apps/app/src/react-app/shell/session-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ import {
} from "@/react-app/domains/workspace/remote-workspace-diagnostics";
import { useShareWorkspaceState } from "@/react-app/domains/workspace/share-workspace-state";
import { ModelPickerModal } from "@/react-app/domains/session/modals/model-picker-modal";
import { CommandPalette, type AccessibleTargetOption, type SessionOption as PaletteSessionOption } from "./command-palette";
import { CommandPalette, type PaletteItem, type SessionOption as PaletteSessionOption } from "./command-palette";
import { getDisplaySessionTitle } from "@/app/lib/session-title";
import { useBootState } from "./boot-state";
import {
Expand Down Expand Up @@ -588,6 +588,7 @@ export function SessionRoute() {
const [renameWorkspaceTitle, setRenameWorkspaceTitle] = useState("");
const [renameWorkspaceBusy, setRenameWorkspaceBusy] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [paletteAccessibleTargets, setPaletteAccessibleTargets] = useState<OpenTarget[]>([]);
// Model picker modal state (ported from settings-route; previously the
// session "Pick a model" button navigated to /settings/general, which is a
Expand Down Expand Up @@ -2518,6 +2519,7 @@ export function SessionRoute() {
// Global shortcuts:
// Cmd/Ctrl+N -> new task in selected workspace
// Cmd/Ctrl+K -> toggle command palette
// Cmd/Ctrl+J -> toggle terminal panel (matches VS Code)
const handleGlobalShortcut = useEffectEvent((event: KeyboardEvent) => {
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const mod = isMac ? event.metaKey : event.ctrlKey;
Expand All @@ -2542,6 +2544,11 @@ export function SessionRoute() {
if (key === "k") {
event.preventDefault();
setCommandPaletteOpen((value) => !value);
return;
}
if (key === "j") {
event.preventDefault();
setTerminalOpen((value) => !value);
}
});

Expand Down Expand Up @@ -2655,6 +2662,20 @@ export function SessionRoute() {
return out;
}, [sessionsByWorkspaceId, selectedWorkspaceId, workspaces]);

const terminalPaletteItems = useMemo<PaletteItem[]>(() => [
{
id: "terminal.toggle",
title: terminalOpen ? "Hide terminal" : "Show terminal",
detail: "Toggle the integrated terminal panel for this workspace",
meta: "Cmd/Ctrl+J",
searchText: "terminal shell command line console show hide toggle",
action: () => {
setCommandPaletteOpen(false);
setTerminalOpen((value) => !value);
},
},
], [terminalOpen]);

const handleReorderWorkspaces = useCallback((workspaceIds: string[]) => {
const activeWorkspaceIds = new Set(workspacesRef.current.map((workspace) => workspace.id));
const nextOrderIds: string[] = [];
Expand Down Expand Up @@ -2915,6 +2936,8 @@ export function SessionRoute() {
}}
/>
}
terminalOpen={terminalOpen}
onTerminalOpenChange={setTerminalOpen}
sidebar={{
workspaceSessionGroups,
selectedWorkspaceId,
Expand Down Expand Up @@ -3171,6 +3194,7 @@ export function SessionRoute() {
}
}}
sessions={paletteSessionOptions}
extraItems={terminalPaletteItems}
/>
<ModelPickerModal
open={modelPickerOpen}
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ extraResources:
filter:
- "*.js"
asar: true
asarUnpack:
- node_modules/.pnpm/@lydell+node-pty*/**
- node_modules/node-pty/**
npmRebuild: false
afterPack: scripts/electron-after-pack.cjs
afterSign: scripts/electron-after-sign.cjs
Expand Down
Loading
Loading