From 3026aa43db13fcf561bf65b23764308c266c2d3f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:27:37 -0500 Subject: [PATCH 1/2] fixing work tab --- .../src/main/services/pty/ptyService.ts | 54 +- .../components/lanes/LaneTerminalsPanel.tsx | 261 +----- .../components/terminals/LaunchPanel.tsx | 226 +++++ .../components/terminals/SessionCard.tsx | 163 ++++ .../terminals/SessionContextMenu.tsx | 105 +++ .../terminals/SessionInfoPopover.tsx | 237 +++++ .../components/terminals/SessionListPane.tsx | 198 ++++ .../terminals/TerminalSettingsDialog.tsx | 266 ++++++ .../components/terminals/TerminalsPage.tsx | 870 +++--------------- .../components/terminals/ToolLogos.tsx | 82 ++ .../components/terminals/WorkViewArea.tsx | 207 +++++ .../components/terminals/useSessionDelta.ts | 41 + .../components/terminals/useWorkSessions.ts | 349 +++++++ assets/claude.svg | 1 + assets/codex.svg | 2 + assets/terminal.svg | 1 + 16 files changed, 2075 insertions(+), 988 deletions(-) create mode 100644 apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionCard.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionListPane.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/ToolLogos.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/useSessionDelta.ts create mode 100644 apps/desktop/src/renderer/components/terminals/useWorkSessions.ts create mode 100644 assets/claude.svg create mode 100644 assets/codex.svg create mode 100644 assets/terminal.svg diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 3ec8146a1..6e99bbab0 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -48,6 +48,7 @@ type PtyEntry = { lastRuntimeSignalState: TerminalRuntimeState; lastRuntimeSignalPreview: string | null; disposed: boolean; + createdAt: number; }; type RuntimeStateEntry = { @@ -147,6 +148,22 @@ export function createPtyService({ }) { const ptys = new Map(); const runtimeStates = new Map(); + /** Timers for auto-closing tool-typed PTYs when the CLI tool exits back to shell prompt */ + const toolAutoCloseTimers = new Map>(); + + /** Tool types that run a CLI tool inside the shell and should auto-close when the tool exits */ + const TOOL_TYPES_WITH_AUTO_CLOSE = new Set([ + "claude", "codex", "claude-orchestrated", "codex-orchestrated", + "aider", "cursor", "continue" + ]); + + const clearToolAutoCloseTimer = (ptyId: string) => { + const timer = toolAutoCloseTimers.get(ptyId); + if (timer) { + clearTimeout(timer); + toolAutoCloseTimers.delete(ptyId); + } + }; const clearIdleTimer = (sessionId: string) => { const state = runtimeStates.get(sessionId); @@ -253,6 +270,7 @@ export function createPtyService({ if (!entry) return; if (entry.disposed) return; entry.disposed = true; + clearToolAutoCloseTimer(ptyId); try { entry.transcriptStream?.end(); @@ -492,7 +510,8 @@ export function createPtyService({ lastRuntimeSignalAt: 0, lastRuntimeSignalState: "running", lastRuntimeSignalPreview: null, - disposed: false + disposed: false, + createdAt: Date.now() }; ptys.set(ptyId, entry); @@ -505,15 +524,45 @@ export function createPtyService({ updatePreviewThrottled(entry, data); broadcastData({ ptyId, sessionId, data }); - const runtimeState = runtimeStateFromOsc133Chunk(data, runtimeStates.get(sessionId)?.state ?? "running"); + const prevState = runtimeStates.get(sessionId)?.state ?? "running"; + const runtimeState = runtimeStateFromOsc133Chunk(data, prevState); setRuntimeState(sessionId, runtimeState); if (runtimeState === "running") { scheduleIdleTransition(sessionId); + clearToolAutoCloseTimer(ptyId); } else { clearIdleTimer(sessionId); } emitRuntimeSignalThrottled(entry, runtimeState); + // Auto-close tool-typed PTYs when the CLI tool exits back to shell prompt. + // When a tool like claude/codex exits (via /exit, completion, etc.), the outer + // shell stays alive and returns to its prompt, detected as "waiting-input". + // We auto-dispose after a brief delay to let final output flush. + if ( + runtimeState === "waiting-input" && + (prevState === "running" || prevState === "idle") && + entry.toolTypeHint && + TOOL_TYPES_WITH_AUTO_CLOSE.has(entry.toolTypeHint) && + !toolAutoCloseTimers.has(ptyId) && + Date.now() - entry.createdAt > 5_000 // ignore initial shell prompt + ) { + toolAutoCloseTimers.set( + ptyId, + setTimeout(() => { + toolAutoCloseTimers.delete(ptyId); + if (entry.disposed) return; + logger.info("pty.tool_exit_auto_close", { ptyId, sessionId, toolType: entry.toolTypeHint }); + try { + entry.pty.kill(); + } catch { + // If kill fails, force close via closeEntry + closeEntry(ptyId, 0); + } + }, 1500) + ); + } + if (!entry.resumeCommand || entry.resumeCommandIsFallback) { entry.resumeScanBuffer = `${entry.resumeScanBuffer}${data}`.slice(-12_000); const detected = extractResumeCommandFromOutput(entry.resumeScanBuffer, entry.toolTypeHint); @@ -671,6 +720,7 @@ export function createPtyService({ } if (entry.disposed) return; entry.disposed = true; + clearToolAutoCloseTimer(ptyId); try { entry.transcriptStream?.end(); } catch { diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index 5132e9857..a6a6b4969 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Tabs from "@radix-ui/react-tabs"; -import * as Dialog from "@radix-ui/react-dialog"; -import { ArrowSquareOut, GridFour, List, Plus, GearSix, Trash, X } from "@phosphor-icons/react"; +import { ArrowSquareOut, GridFour, List, GearSix, X } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; import type { TerminalLaunchProfile, @@ -14,6 +13,7 @@ import { Chip } from "../ui/Chip"; import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; import { TerminalView } from "../terminals/TerminalView"; +import { TerminalSettingsDialog, readLaunchTracked, persistLaunchTracked } from "../terminals/TerminalSettingsDialog"; import { TilingLayout } from "./TilingLayout"; import { useNavigate } from "react-router-dom"; import { sessionIndicatorState } from "../../lib/terminalAttention"; @@ -21,21 +21,8 @@ import { sessionIndicatorState } from "../../lib/terminalAttention"; const tabTrigger = "flex items-center gap-2 rounded-md px-2.5 py-2 text-xs font-semibold text-muted-fg data-[state=active]:text-fg data-[state=active]:bg-accent/10 data-[state=active]:ring-1 data-[state=active]:ring-accent/50"; -const LAUNCH_TRACKED_KEY = "ade.terminals.launchTracked"; const DEFAULT_PROFILE_IDS = ["claude", "codex", "shell"] as const; -const PROFILE_COLORS = [ - null, // no color / default - "#ef4444", // red - "#f97316", // orange - "#f59e0b", // amber - "#22c55e", // green - "#06b6d4", // cyan - "#3b82f6", // blue - "#8b5cf6", // violet - "#ec4899", // pink -] as const; - function statusDot(status: string) { if (status === "running") return "border-2 border-emerald-500 border-t-transparent bg-transparent"; if (status === "failed") return "bg-red-700"; @@ -54,25 +41,6 @@ function sessionTabLabel(session: TerminalSessionSummary): string { return `${tool} · ${outcome} · ${base}`.slice(0, 180); } -function readLaunchTracked(): boolean { - try { - const raw = window.localStorage.getItem(LAUNCH_TRACKED_KEY); - if (raw === "0") return false; - if (raw === "1") return true; - } catch { - // ignore - } - return true; -} - -function persistLaunchTracked(value: boolean) { - try { - window.localStorage.setItem(LAUNCH_TRACKED_KEY, value ? "1" : "0"); - } catch { - // ignore - } -} - function toolTypeFromProfileId(profileId: string): TerminalToolType | null { const id = profileId.trim().toLowerCase(); if (id === "claude") return "claude"; @@ -88,28 +56,6 @@ function isChatToolType(toolType: TerminalToolType | null | undefined): boolean return toolType === "codex-chat" || toolType === "claude-chat"; } -function isDefaultProfile(profile: TerminalLaunchProfile): boolean { - return (DEFAULT_PROFILE_IDS as readonly string[]).includes(profile.id); -} - -function slugify(raw: string): string { - return raw - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); -} - -function uniqueProfileId(base: string, existing: Set): string { - if (!existing.has(base)) return base; - for (let i = 2; i < 50; i += 1) { - const next = `${base}-${i}`; - if (!existing.has(next)) return next; - } - return `${base}-${Date.now()}`; -} - export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string | null } = {}) { const navigate = useNavigate(); const globalLaneId = useAppStore((s) => s.selectedLaneId); @@ -128,11 +74,6 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string const [terminalProfiles, setTerminalProfiles] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [launchTracked, setLaunchTracked] = useState(readLaunchTracked()); - const [profileDraft, setProfileDraft] = useState([]); - const [newProfileName, setNewProfileName] = useState(""); - const [newProfileCommand, setNewProfileCommand] = useState(""); - const [profilesBusy, setProfilesBusy] = useState(false); - const [profilesError, setProfilesError] = useState(null); const laneSessionIdsRef = useRef>(new Set()); const focusedSessionId = overrideLaneId != null ? localFocusedSessionId : globalFocusedSessionId; @@ -226,8 +167,8 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string ? { ...entry, ptyId: null, - status: "disposed", - runtimeState: "killed", + status: "disposed" as const, + runtimeState: "killed" as const, endedAt: new Date().toISOString(), exitCode: null } @@ -329,45 +270,8 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string ); const openSettings = useCallback(() => { - setProfilesError(null); - setProfileDraft([...(terminalProfiles?.profiles ?? [])]); setSettingsOpen(true); - }, [terminalProfiles]); - - const saveProfiles = useCallback(async () => { - if (!terminalProfiles) return; - setProfilesBusy(true); - setProfilesError(null); - try { - const next = await window.ade.terminalProfiles.set({ - profiles: profileDraft, - defaultProfileId: terminalProfiles.defaultProfileId ?? "shell" - }); - setTerminalProfiles(next); - setProfileDraft(next.profiles); - setSettingsOpen(false); - } catch (err) { - setProfilesError(err instanceof Error ? err.message : String(err)); - } finally { - setProfilesBusy(false); - } - }, [terminalProfiles, profileDraft]); - - const addProfile = useCallback(() => { - const name = newProfileName.trim(); - const command = newProfileCommand.trim(); - if (!name || !command) { - setProfilesError("Name and command are required."); - return; - } - const existing = new Set(profileDraft.map((p) => p.id)); - const base = slugify(name) || "custom"; - const id = uniqueProfileId(base, existing); - setProfileDraft((prev) => [...prev, { id, name, command, tracked: true, description: null, color: null }]); - setNewProfileName(""); - setNewProfileCommand(""); - setProfilesError(null); - }, [newProfileName, newProfileCommand, profileDraft]); + }, []); return (
@@ -550,148 +454,19 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string
)} - - - - -
- Terminal Settings - - Configure launch profiles and whether new terminals collect context. - - - - -
- -
-
-
Launch mode
- -
- If disabled, terminals still run normally but do not produce transcripts or pack updates. -
-
- -
-
Terminal buttons
-
- {profileDraft.length === 0 ? ( -
No profiles loaded.
- ) : ( - profileDraft.map((p) => { - const locked = isDefaultProfile(p); - return ( -
-
-
{p.id}
- - setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, name: e.target.value } : x))) - } - placeholder="Name" - /> - - setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, command: e.target.value } : x))) - } - placeholder="Command" - /> - -
-
- Color - {PROFILE_COLORS.map((c) => ( - - ))} -
-
- ); - }) - )} -
- -
-
Add custom button
-
- setNewProfileName(e.target.value)} - placeholder="Name (e.g., Dev Server)" - /> - setNewProfileCommand(e.target.value)} - placeholder="Command (e.g., npm run dev)" - /> - -
-
- - {profilesError ? ( -
- {profilesError} -
- ) : null} - -
- - -
-
-
-
-
-
+ { + setTerminalProfiles(next); + }} + launchTracked={launchTracked} + onLaunchTrackedChange={(v) => { + setLaunchTracked(v); + persistLaunchTracked(v); + }} + /> ); } diff --git a/apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx b/apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx new file mode 100644 index 000000000..a8d0b027f --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx @@ -0,0 +1,226 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + CaretDown as ChevronDown, + ChatCircleDots as MessageSquarePlus, + GearSix, + Terminal, + Brain as BrainCircuit, +} from "@phosphor-icons/react"; +import type { TerminalLaunchProfile, TerminalProfilesSnapshot, TerminalToolType } from "../../../shared/types"; +import { ToolLogo } from "./ToolLogos"; +import { TerminalSettingsDialog, readLaunchTracked, persistLaunchTracked } from "./TerminalSettingsDialog"; +import { cn } from "../ui/cn"; +import { Button } from "../ui/Button"; + +const DEFAULT_PROFILE_IDS = ["claude", "codex", "shell"] as const; + +function toolTypeFromProfileId(profileId: string): TerminalToolType | null { + const id = profileId.trim().toLowerCase(); + if (id === "claude") return "claude"; + if (id === "codex") return "codex"; + if (id === "shell") return "shell"; + if (id === "aider") return "aider"; + if (id === "cursor") return "cursor"; + if (id === "continue") return "continue"; + return "other"; +} + +export function LaunchPanel({ + lanes, + onLaunchPty, + onLaunchChat, +}: { + lanes: { id: string; name: string }[]; + onLaunchPty: (laneId: string, profile: "claude" | "codex" | "shell") => void; + onLaunchChat: (laneId: string, provider: "claude" | "codex") => void; +}) { + const [laneId, setLaneId] = useState(lanes[0]?.id ?? ""); + const [chatOpen, setChatOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [terminalProfiles, setTerminalProfiles] = useState(null); + const [launchTracked, setLaunchTracked] = useState(readLaunchTracked()); + + useEffect(() => { + if (!laneId && lanes.length > 0) setLaneId(lanes[0]!.id); + }, [lanes, laneId]); + + useEffect(() => { + let cancelled = false; + window.ade.terminalProfiles + .get() + .then((snapshot) => { + if (cancelled) return; + setTerminalProfiles(snapshot); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const customProfiles = useMemo(() => { + if (!terminalProfiles) return []; + return terminalProfiles.profiles.filter( + (p) => !(DEFAULT_PROFILE_IDS as readonly string[]).includes(p.id), + ); + }, [terminalProfiles]); + + const launchCustomProfile = useCallback( + (profile: TerminalLaunchProfile) => { + if (!laneId) return; + const toolType = toolTypeFromProfileId(profile.id); + const command = (profile.command ?? "").trim(); + window.ade.pty + .create({ + laneId, + cols: 100, + rows: 30, + title: profile.name || "Shell", + tracked: launchTracked, + toolType, + startupCommand: command || undefined, + }) + .catch(() => {}); + }, + [laneId, launchTracked], + ); + + return ( + <> +
+ {/* Lane selector */} +
+ +
+ + +
+ +
+ + {/* Quick-launch row */} +
+ + + + + {/* Custom profile buttons */} + {customProfiles.map((p) => ( + + ))} + +
+ + {/* Chat launch */} +
+ + {chatOpen && ( +
+ + +
+ )} +
+ + {/* Tracked toggle */} + +
+
+ + { setLaunchTracked(v); persistLaunchTracked(v); }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx new file mode 100644 index 000000000..53831352d --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { Info, Play } from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { sessionIndicatorState } from "../../lib/terminalAttention"; +import { ToolLogo } from "./ToolLogos"; +import { useSessionDelta } from "./useSessionDelta"; +import { cn } from "../ui/cn"; + +/** Tool-type accent gradient for left bar — Claude=warm orange, Codex=cool silver, Shell=dark */ +function toolAccentGradient(toolType: string | null | undefined): string { + if (toolType === "claude" || toolType === "claude-chat" || toolType === "claude-orchestrated") + return "from-orange-400/70 to-orange-400/10"; + if (toolType === "codex" || toolType === "codex-chat" || toolType === "codex-orchestrated") + return "from-slate-300/60 to-slate-300/10"; + if (toolType === "shell") return "from-zinc-500/50 to-zinc-500/10"; + return "from-border/20 to-transparent"; +} + +/** Tool-type badge color */ +function toolBadgeClass(toolType: string | null | undefined): string { + if (toolType === "claude" || toolType === "claude-chat") return "bg-orange-500/15 text-orange-400"; + if (toolType === "codex" || toolType === "codex-chat") return "bg-slate-400/15 text-slate-300"; + return "bg-zinc-500/15 text-zinc-400"; +} + +function statusDot(session: TerminalSessionSummary): { cls: string; spinning: boolean; label: string } { + const ind = sessionIndicatorState({ + status: session.status, + lastOutputPreview: session.lastOutputPreview, + runtimeState: session.runtimeState, + }); + if (ind === "running-active") + return { cls: "border-2 border-emerald-500 border-t-transparent bg-transparent", spinning: true, label: "Running" }; + if (ind === "running-needs-attention") + return { cls: "border-2 border-amber-400 border-t-transparent bg-transparent", spinning: true, label: "Needs input" }; + if (ind === "failed") return { cls: "bg-red-500", spinning: false, label: "Failed" }; + if (ind === "disposed") return { cls: "bg-red-400/70", spinning: false, label: "Stopped" }; + return { cls: "bg-sky-500/70", spinning: false, label: "Completed" }; +} + +function truncateSummary(text: string | null, maxWords = 8): string { + if (!text) return ""; + const words = text.trim().split(/\s+/); + if (words.length <= maxWords) return text.trim(); + return words.slice(0, maxWords).join(" ") + "..."; +} + +export function SessionCard({ + session, + isSelected, + onSelect, + onResume, + onInfoClick, + onContextMenu, + resumingSessionId, +}: { + session: TerminalSessionSummary; + isSelected: boolean; + onSelect: (id: string) => void; + onResume: () => void; + onInfoClick: (e: React.MouseEvent) => void; + onContextMenu: (e: React.MouseEvent) => void; + resumingSessionId: string | null; +}) { + const dot = statusDot(session); + const canResume = session.status !== "running" && Boolean(session.resumeCommand); + const isEnded = session.status !== "running"; + const delta = useSessionDelta(session.id, isEnded); + const summary = truncateSummary(session.summary ?? session.goal ?? session.title); + + return ( +
+ + + {/* Hover actions */} +
+ {/* Info button */} + + + {/* Resume button */} + {canResume ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx new file mode 100644 index 000000000..e6bcf7e6e --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import type { TerminalSessionSummary } from "../../../shared/types"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +export type SessionContextMenuState = { + session: TerminalSessionSummary; + x: number; + y: number; +} | null; + +export function SessionContextMenu({ + menu, + onClose, + onCloseSession, + onEndChat, + onResume, + onCopyResumeCommand, + onGoToLane, + onCopySessionId, +}: { + menu: SessionContextMenuState; + onClose: () => void; + onCloseSession: (ptyId: string) => void; + onEndChat: (sessionId: string) => void; + onResume: (session: TerminalSessionSummary) => void; + onCopyResumeCommand: (command: string) => void; + onGoToLane: (session: TerminalSessionSummary) => void; + onCopySessionId: (id: string) => void; +}) { + if (!menu) return null; + + const { session, x, y } = menu; + const isRunning = session.status === "running"; + const isChat = isChatToolType(session.toolType); + const canResume = !isRunning && Boolean(session.resumeCommand); + + return ( + <> + {/* Backdrop */} +
{ e.preventDefault(); onClose(); }} /> + + {/* Menu */} +
e.stopPropagation()} + > + {isRunning && session.ptyId && !isChat ? ( + + ) : null} + + {isRunning && isChat ? ( + + ) : null} + + {canResume ? ( + + ) : null} + + {session.resumeCommand ? ( + + ) : null} + +
+ + + + +
+ + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx new file mode 100644 index 000000000..a4d36880b --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useRef } from "react"; +import { + Clipboard, + FileText, + Info, + Monitor, + Play, + Stop as Square, + X, +} from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { sanitizeTerminalInlineText } from "../../lib/terminalAttention"; +import { getTerminalRuntimeHealth } from "./TerminalView"; +import { SessionDeltaCard } from "./SessionDeltaCard"; +import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +function runtimeStateLabel(state: TerminalSessionSummary["runtimeState"]): string { + if (state === "waiting-input") return "waiting input"; + return state; +} + +export type InfoPopoverState = { + session: TerminalSessionSummary; + x: number; + y: number; +} | null; + +export function SessionInfoPopover({ + popover, + onClose, + onCloseSession, + onEndChat, + onResume, + onGoToLane, + closingPtyIds, + closingChatSessionId, + resumingSessionId, +}: { + popover: InfoPopoverState; + onClose: () => void; + onCloseSession: (ptyId: string) => void; + onEndChat: (sessionId: string) => void; + onResume: (session: TerminalSessionSummary) => void; + onGoToLane: (session: TerminalSessionSummary) => void; + closingPtyIds: Set; + closingChatSessionId: string | null; + resumingSessionId: string | null; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!popover) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const keyHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handler); + document.addEventListener("keydown", keyHandler); + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("keydown", keyHandler); + }; + }, [popover, onClose]); + + if (!popover) return null; + + const { session, x, y } = popover; + const isChat = isChatToolType(session.toolType); + const health = getTerminalRuntimeHealth(session.id); + + // Position: try to place to the right, but clamp to viewport + const left = Math.min(x + 8, window.innerWidth - 420); + const top = Math.min(y - 20, window.innerHeight - 500); + + return ( +
+ {/* Header */} +
+ {(session.goal ?? session.title).trim()} + +
+ +
+ {/* Metadata */} +
+
+ + Session info +
+
+ {([ + ["Title", (session.goal ?? session.title).trim()], + ["Lane", session.laneName], + ["Status", session.status], + ["Runtime", runtimeStateLabel(session.runtimeState)], + session.toolType ? ["Tool", session.toolType] : null, + session.exitCode != null ? ["Exit", `${session.exitCode}`] : null, + !session.tracked ? ["Context", "no context"] : null, + ["Started", new Date(session.startedAt).toLocaleTimeString()], + session.endedAt ? ["Ended", new Date(session.endedAt).toLocaleTimeString()] : null, + ] as ([string, string] | null)[]) + .filter((row): row is [string, string] => row != null) + .map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ + {/* Last output */} + {sanitizeTerminalInlineText(session.lastOutputPreview, 420) ? ( +
+
+ + Last output +
+
+              {sanitizeTerminalInlineText(session.lastOutputPreview, 420)}
+            
+
+ ) : null} + + {/* Summary */} + {session.summary && session.status !== "running" ? ( +
+
+ + Summary +
+

{session.summary}

+
+ ) : null} + + {/* Resume command */} + {session.status !== "running" && session.resumeCommand ? ( +
+
+ + Resume command +
+ + {session.resumeCommand} + +
+ + +
+
+ ) : null} + + {/* Session Delta */} + {session.status !== "running" ? ( + + ) : null} + + {/* Terminal health */} + {health ? ( +
+
+ + Terminal health +
+
+ fit_failures: {health.fitFailures} + zero_dim: {health.zeroDimFits} + renderer: {health.rendererFallbacks} + dropped: {health.droppedChunks} +
+
+ ) : null} + + {/* Actions */} +
+ {session.status === "running" && session.ptyId && !isChat ? ( + + ) : null} + {session.status === "running" && isChat ? ( + + ) : null} + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx new file mode 100644 index 000000000..da3e11f32 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -0,0 +1,198 @@ +import React from "react"; +import { Terminal } from "@phosphor-icons/react"; +import type { TerminalSessionSummary, TerminalSessionStatus } from "../../../shared/types"; +import { SessionCard } from "./SessionCard"; +import { LaunchPanel } from "./LaunchPanel"; +import type { SessionContextMenuState } from "./SessionContextMenu"; +import type { InfoPopoverState } from "./SessionInfoPopover"; +import { cn } from "../ui/cn"; + +export function SessionListPane({ + lanes, + filtered, + runningFiltered, + endedFiltered, + loading, + filterLaneId, + setFilterLaneId, + filterStatus, + setFilterStatus, + q, + setQ, + selectedSessionId, + onSelectSession, + onResume, + resumingSessionId, + onLaunchPty, + onLaunchChat, + onInfoClick, + onContextMenu, +}: { + lanes: { id: string; name: string }[]; + filtered: TerminalSessionSummary[]; + runningFiltered: TerminalSessionSummary[]; + endedFiltered: TerminalSessionSummary[]; + loading: boolean; + filterLaneId: string; + setFilterLaneId: (v: string) => void; + filterStatus: TerminalSessionStatus | "all"; + setFilterStatus: (v: TerminalSessionStatus | "all") => void; + q: string; + setQ: (v: string) => void; + selectedSessionId: string | null; + onSelectSession: (id: string) => void; + onResume: (session: TerminalSessionSummary) => void; + resumingSessionId: string | null; + onLaunchPty: (laneId: string, profile: "claude" | "codex" | "shell") => void; + onLaunchChat: (laneId: string, provider: "claude" | "codex") => void; + onInfoClick: (session: TerminalSessionSummary, e: React.MouseEvent) => void; + onContextMenu: (session: TerminalSessionSummary, e: React.MouseEvent) => void; +}) { + const statusOptions = [ + { value: "all" as const, label: "All" }, + { value: "running" as const, label: "Running" }, + { value: "completed" as const, label: "Ended" }, + ]; + + return ( +
+ {/* Launch panel */} + + + {/* Filters */} +
+ {/* Lane filter chips */} +
+ + {lanes.map((l) => ( + + ))} +
+ + {/* Status toggle pills */} +
+ {statusOptions.map((opt) => ( + + ))} +
+ + {/* Search bar */} + setQ(e.target.value)} + /> +
+ + {/* Session list */} +
+ {filtered.length === 0 ? ( +
+
+ +
+
No terminal sessions
+
+ Start a new session to begin working. +
+
+ ) : ( +
+ {/* Running group */} + {runningFiltered.length > 0 && ( +
+
+ + + Running · {runningFiltered.length} + +
+
+ {runningFiltered.map((s) => ( + onResume(s)} + onInfoClick={(e) => onInfoClick(s, e)} + onContextMenu={(e) => { e.preventDefault(); onContextMenu(s, e); }} + resumingSessionId={resumingSessionId} + /> + ))} +
+
+ )} + + {/* Ended group */} + {endedFiltered.length > 0 && ( +
0 ? "mt-2" : ""}> +
+ + + Ended · {endedFiltered.length} + +
+
+ {endedFiltered.map((s) => ( + onResume(s)} + onInfoClick={(e) => onInfoClick(s, e)} + onContextMenu={(e) => { e.preventDefault(); onContextMenu(s, e); }} + resumingSessionId={resumingSessionId} + /> + ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx b/apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx new file mode 100644 index 000000000..b61f5bd05 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx @@ -0,0 +1,266 @@ +import React, { useCallback, useEffect, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { Plus, Trash, X } from "@phosphor-icons/react"; +import type { TerminalLaunchProfile, TerminalProfilesSnapshot } from "../../../shared/types"; +import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; + +const LAUNCH_TRACKED_KEY = "ade.terminals.launchTracked"; +const DEFAULT_PROFILE_IDS = ["claude", "codex", "shell"] as const; + +const PROFILE_COLORS = [ + null, + "#ef4444", + "#f97316", + "#f59e0b", + "#22c55e", + "#06b6d4", + "#3b82f6", + "#8b5cf6", + "#ec4899", +] as const; + +function isDefaultProfile(profile: TerminalLaunchProfile): boolean { + return (DEFAULT_PROFILE_IDS as readonly string[]).includes(profile.id); +} + +function slugify(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +function uniqueProfileId(base: string, existing: Set): string { + if (!existing.has(base)) return base; + for (let i = 2; i < 50; i += 1) { + const next = `${base}-${i}`; + if (!existing.has(next)) return next; + } + return `${base}-${Date.now()}`; +} + +export function readLaunchTracked(): boolean { + try { + const raw = window.localStorage.getItem(LAUNCH_TRACKED_KEY); + if (raw === "0") return false; + if (raw === "1") return true; + } catch { /* ignore */ } + return true; +} + +export function persistLaunchTracked(value: boolean) { + try { + window.localStorage.setItem(LAUNCH_TRACKED_KEY, value ? "1" : "0"); + } catch { /* ignore */ } +} + +export function TerminalSettingsDialog({ + open, + onOpenChange, + terminalProfiles, + onProfilesSaved, + launchTracked, + onLaunchTrackedChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + terminalProfiles: TerminalProfilesSnapshot | null; + onProfilesSaved: (next: TerminalProfilesSnapshot) => void; + launchTracked: boolean; + onLaunchTrackedChange: (value: boolean) => void; +}) { + const [profileDraft, setProfileDraft] = useState([]); + const [newProfileName, setNewProfileName] = useState(""); + const [newProfileCommand, setNewProfileCommand] = useState(""); + const [profilesBusy, setProfilesBusy] = useState(false); + const [profilesError, setProfilesError] = useState(null); + + useEffect(() => { + if (open) { + setProfilesError(null); + setProfileDraft([...(terminalProfiles?.profiles ?? [])]); + setNewProfileName(""); + setNewProfileCommand(""); + } + }, [open, terminalProfiles]); + + const saveProfiles = useCallback(async () => { + if (!terminalProfiles) return; + setProfilesBusy(true); + setProfilesError(null); + try { + const next = await window.ade.terminalProfiles.set({ + profiles: profileDraft, + defaultProfileId: terminalProfiles.defaultProfileId ?? "shell", + }); + onProfilesSaved(next); + onOpenChange(false); + } catch (err) { + setProfilesError(err instanceof Error ? err.message : String(err)); + } finally { + setProfilesBusy(false); + } + }, [terminalProfiles, profileDraft, onProfilesSaved, onOpenChange]); + + const addProfile = useCallback(() => { + const name = newProfileName.trim(); + const command = newProfileCommand.trim(); + if (!name || !command) { + setProfilesError("Name and command are required."); + return; + } + const existing = new Set(profileDraft.map((p) => p.id)); + const base = slugify(name) || "custom"; + const id = uniqueProfileId(base, existing); + setProfileDraft((prev) => [...prev, { id, name, command, tracked: true, description: null, color: null }]); + setNewProfileName(""); + setNewProfileCommand(""); + setProfilesError(null); + }, [newProfileName, newProfileCommand, profileDraft]); + + return ( + + + + +
+ Terminal Settings + + Configure launch profiles and whether new terminals collect context. + + + + +
+ +
+
+
Launch mode
+ +
+ If disabled, terminals still run normally but do not produce transcripts or pack updates. +
+
+ +
+
Terminal buttons
+
+ {profileDraft.length === 0 ? ( +
No profiles loaded.
+ ) : ( + profileDraft.map((p) => { + const locked = isDefaultProfile(p); + return ( +
+
+
{p.id}
+ + setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, name: e.target.value } : x))) + } + placeholder="Name" + /> + + setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, command: e.target.value } : x))) + } + placeholder="Command" + /> + +
+
+ Color + {PROFILE_COLORS.map((c) => ( + + ))} +
+
+ ); + }) + )} +
+ +
+
Add custom button
+
+ setNewProfileName(e.target.value)} + placeholder="Name (e.g., Dev Server)" + /> + setNewProfileCommand(e.target.value)} + placeholder="Command (e.g., npm run dev)" + /> + +
+
+ + {profilesError ? ( +
+ {profilesError} +
+ ) : null} + +
+ + +
+
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 1ad34759f..a1733eea5 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -1,708 +1,142 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import React, { useCallback, useMemo, useState } from "react"; import { - Brain as BrainCircuit, - CaretDown as ChevronDown, - Clipboard, - FileText, - Info, - ChatCircleDots as MessageSquarePlus, - Monitor, - Play, ArrowClockwise as RefreshCw, Stop as Square, Terminal, - X, } from "@phosphor-icons/react"; -import type { TerminalSessionSummary, TerminalSessionStatus } from "../../../shared/types"; -import { useAppStore } from "../../state/appStore"; -import { Button } from "../ui/Button"; -import { Chip } from "../ui/Chip"; import { PaneTilingLayout, type PaneConfig, type PaneSplit } from "../ui/PaneTilingLayout"; -import { TerminalView, getTerminalRuntimeHealth } from "./TerminalView"; -import { sanitizeTerminalInlineText, sessionIndicatorState } from "../../lib/terminalAttention"; -import { AgentChatPane } from "../chat/AgentChatPane"; -import { cn } from "../ui/cn"; +import { Button } from "../ui/Button"; +import { useWorkSessions } from "./useWorkSessions"; +import { SessionListPane } from "./SessionListPane"; +import { WorkViewArea } from "./WorkViewArea"; +import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; +import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; +import type { TerminalSessionSummary } from "../../../shared/types"; -/* ---- Layout ---- */ +/* ---- Layout (2-pane: sessions | view) ---- */ const TERMINALS_TILING_TREE: PaneSplit = { type: "split", direction: "horizontal", children: [ { node: { type: "pane", id: "sessions" }, defaultSize: 28, minSize: 15 }, - { - node: { - type: "split", - direction: "vertical", - children: [ - { node: { type: "pane", id: "terminal" }, defaultSize: 70, minSize: 30 }, - { node: { type: "pane", id: "details" }, defaultSize: 30, minSize: 10 }, - ], - }, - defaultSize: 72, - minSize: 40, - }, + { node: { type: "pane", id: "view" }, defaultSize: 72, minSize: 40 }, ], }; -/* ---- Helpers ---- */ - -function isChatToolType(toolType: string | null | undefined): boolean { - return toolType === "codex-chat" || toolType === "claude-chat"; -} - -function inferToolFromResumeCommand(command: string): "claude" | "codex" | null { - const n = command.trim().toLowerCase(); - if (n.startsWith("claude ")) return "claude"; - if (n.startsWith("codex ")) return "codex"; - return null; -} - -function runtimeStateLabel(state: TerminalSessionSummary["runtimeState"]): string { - if (state === "waiting-input") return "waiting input"; - return state; -} - -/** Tool-type → left-border accent color class */ -function toolBorderClass(toolType: string | null | undefined): string { - if (toolType === "claude" || toolType === "claude-chat") return "border-l-violet-500"; - if (toolType === "codex" || toolType === "codex-chat") return "border-l-sky-500"; - if (toolType === "shell") return "border-l-border/40"; - return "border-l-border/20"; -} - -/** Tool-type → badge color */ -function toolBadgeClass(toolType: string | null | undefined): string { - if (toolType === "claude" || toolType === "claude-chat") return "bg-violet-500/15 text-violet-400"; - if (toolType === "codex" || toolType === "codex-chat") return "bg-sky-500/15 text-sky-400"; - return "bg-muted/50 text-muted-fg"; -} - -function statusDot(session: TerminalSessionSummary): { cls: string; spinning: boolean; label: string } { - const ind = sessionIndicatorState({ - status: session.status, - lastOutputPreview: session.lastOutputPreview, - runtimeState: session.runtimeState, - }); - if (ind === "running-active") - return { cls: "border-2 border-emerald-500 border-t-transparent bg-transparent", spinning: true, label: "Running" }; - if (ind === "running-needs-attention") - return { cls: "border-2 border-amber-400 border-t-transparent bg-transparent", spinning: true, label: "Needs input" }; - if (ind === "failed") return { cls: "bg-red-500", spinning: false, label: "Failed" }; - if (ind === "disposed") return { cls: "bg-red-400/70", spinning: false, label: "Stopped" }; - return { cls: "bg-sky-500/70", spinning: false, label: "Completed" }; -} - -/* ---- Launch panel subcomponent ---- */ - -function LaunchPanel({ - lanes, - onLaunchPty, - onLaunchChat, -}: { - lanes: { id: string; name: string }[]; - onLaunchPty: (laneId: string, profile: "claude" | "codex" | "shell") => void; - onLaunchChat: (laneId: string, provider: "claude" | "codex") => void; -}) { - const [laneId, setLaneId] = useState(lanes[0]?.id ?? ""); - const [chatOpen, setChatOpen] = useState(false); - - useEffect(() => { - if (!laneId && lanes.length > 0) setLaneId(lanes[0]!.id); - }, [lanes, laneId]); - - return ( -
- {/* Lane selector */} -
- -
- - -
-
- - {/* Quick-launch row */} -
- - - - -
- - {/* Chat launch */} -
- - {chatOpen && ( -
- - -
- )} -
-
-
- ); -} - /* ---- Main component ---- */ export function TerminalsPage() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const lanes = useAppStore((s) => s.lanes); - const selectedLaneId = useAppStore((s) => s.selectedLaneId); - const focusSession = useAppStore((s) => s.focusSession); - const selectLane = useAppStore((s) => s.selectLane); - - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(false); - const [filterLaneId, setFilterLaneId] = useState("all"); - const [filterStatus, setFilterStatus] = useState("all"); - const [q, setQ] = useState(""); - const [closingPtyIds, setClosingPtyIds] = useState>(new Set()); - const [closingChatSessionId, setClosingChatSessionId] = useState(null); - const [resumingSessionId, setResumingSessionId] = useState(null); - const [selectedSessionId, setSelectedSessionId] = useState(null); - const [launchPanelOpen, setLaunchPanelOpen] = useState(false); - const launchPanelRef = useRef(null); - - const refresh = useCallback(async () => { - setLoading(true); - try { - const rows = await window.ade.sessions.list({ limit: 500 }); - setSessions(rows); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { refresh().catch(() => {}); }, []); - - useEffect(() => { - const laneParam = (searchParams.get("laneId") ?? searchParams.get("lane") ?? "").trim(); - if (laneParam && lanes.some((l) => l.id === laneParam)) setFilterLaneId(laneParam); - const statusParam = (searchParams.get("status") ?? "").trim(); - if (["running", "completed", "failed", "disposed", "all"].includes(statusParam)) { - setFilterStatus(statusParam as TerminalSessionStatus | "all"); - } - }, [searchParams, lanes]); - - useEffect(() => { - const unsubExit = window.ade.pty.onExit(() => { refresh().catch(() => {}); }); - const t = setInterval(() => { - if (sessions.some((s) => s.status === "running")) refresh().catch(() => {}); - }, 2000); - return () => { - try { unsubExit(); } catch { /* ignore */ } - clearInterval(t); - }; - }, [sessions, refresh]); - - useEffect(() => { - const unsubscribe = window.ade.agentChat.onEvent((payload) => { - const event = payload.event; - if (event.type === "done") refresh().catch(() => {}); - if (event.type === "status" && event.turnStatus !== "started") refresh().catch(() => {}); - }); - return unsubscribe; - }, [refresh]); - - // Close launch panel on outside click - useEffect(() => { - if (!launchPanelOpen) return; - const handler = (e: MouseEvent) => { - if (launchPanelRef.current && !launchPanelRef.current.contains(e.target as Node)) { - setLaunchPanelOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [launchPanelOpen]); - - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return sessions.filter((s) => { - if (filterLaneId !== "all" && s.laneId !== filterLaneId) return false; - if (filterStatus !== "all" && s.status !== filterStatus) return false; - if (!needle) return true; - return ( - (s.goal ?? s.title).toLowerCase().includes(needle) || - s.laneName.toLowerCase().includes(needle) || - (s.toolType ?? "").toLowerCase().includes(needle) || - (s.lastOutputPreview ?? "").toLowerCase().includes(needle) || - (s.summary ?? "").toLowerCase().includes(needle) || - (s.resumeCommand ?? "").toLowerCase().includes(needle) - ); - }); - }, [sessions, filterLaneId, filterStatus, q]); - - const runningSessions = useMemo(() => sessions.filter((s) => s.status === "running" && Boolean(s.ptyId)), [sessions]); - const selectedSession = useMemo(() => selectedSessionId ? sessions.find((s) => s.id === selectedSessionId) ?? null : null, [sessions, selectedSessionId]); - const selectedIsChat = useMemo(() => isChatToolType(selectedSession?.toolType), [selectedSession]); - const selectedHealth = selectedSession ? getTerminalRuntimeHealth(selectedSession.id) : null; - - useEffect(() => { - if (!selectedSessionId && runningSessions.length > 0) setSelectedSessionId(runningSessions[0]!.id); - }, [selectedSessionId, runningSessions]); - - /* ---- Session actions ---- */ - - const markPtyClosed = (ptyId: string) => { - setSessions((prev) => - prev.map((s) => - s.ptyId === ptyId ? { ...s, ptyId: null, status: "disposed", runtimeState: "killed", endedAt: new Date().toISOString(), exitCode: null } : s - ) - ); - }; + const work = useWorkSessions(); - const closeSession = async (ptyId: string) => { - setClosingPtyIds((prev) => { const n = new Set(prev); n.add(ptyId); return n; }); - markPtyClosed(ptyId); - try { await window.ade.pty.dispose({ ptyId }); } finally { - setClosingPtyIds((prev) => { const n = new Set(prev); n.delete(ptyId); return n; }); - await refresh(); - } - }; + /* Floating overlays */ + const [contextMenu, setContextMenu] = useState(null); + const [infoPopover, setInfoPopover] = useState(null); - const closeRunning = async () => { - const ids = runningSessions.map((s) => s.ptyId).filter((id): id is string => Boolean(id)); - await Promise.allSettled(ids.map((id) => closeSession(id))); - }; - - const resumeSession = useCallback(async (session: TerminalSessionSummary) => { - if (isChatToolType(session.toolType)) { - if (resumingSessionId) return; - setResumingSessionId(session.id); - try { - await window.ade.agentChat.resume({ sessionId: session.id }); - selectLane(session.laneId); - focusSession(session.id); - setSelectedSessionId(session.id); - await refresh(); - } finally { setResumingSessionId(null); } - return; - } - const command = (session.resumeCommand ?? "").trim(); - if (!command || resumingSessionId) return; - setResumingSessionId(session.id); - try { - const toolType = session.toolType ?? inferToolFromResumeCommand(command) ?? null; - const started = await window.ade.pty.create({ laneId: session.laneId, cols: 100, rows: 30, title: session.goal?.trim() || session.title || "Terminal", tracked: session.tracked, toolType, startupCommand: command }); - selectLane(session.laneId); - focusSession(started.sessionId); - setSelectedSessionId(started.sessionId); - navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(started.sessionId)}`); - } finally { setResumingSessionId(null); } - }, [focusSession, navigate, refresh, resumingSessionId, selectLane]); - - const closeChatSession = useCallback(async (sessionId: string) => { - setClosingChatSessionId(sessionId); - try { await window.ade.agentChat.dispose({ sessionId }); await refresh(); } - finally { setClosingChatSessionId((c) => c === sessionId ? null : c); } - }, [refresh]); - - /* ---- Launch new sessions ---- */ - - const handleLaunchPty = useCallback(async (laneId: string, profile: "claude" | "codex" | "shell") => { - const toolTypeMap = { claude: "claude" as const, codex: "codex" as const, shell: "shell" as const }; - const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" }; - const commandMap = { claude: "claude", codex: "codex", shell: "" }; - const result = await window.ade.pty.create({ - laneId, - cols: 100, - rows: 30, - title: titleMap[profile], - tracked: true, - toolType: toolTypeMap[profile], - startupCommand: commandMap[profile] || undefined, - }); - selectLane(laneId); - focusSession(result.sessionId); - setSelectedSessionId(result.sessionId); - await refresh(); - navigate(`/lanes?laneId=${encodeURIComponent(laneId)}&sessionId=${encodeURIComponent(result.sessionId)}`); - }, [selectLane, focusSession, refresh, navigate]); - - const handleLaunchChat = useCallback(async (laneId: string, provider: "claude" | "codex") => { - const defaultModel = provider === "codex" ? "gpt-5.3-codex" : "sonnet"; - const session = await window.ade.agentChat.create({ laneId, provider, model: defaultModel }); - selectLane(laneId); - focusSession(session.id); - setSelectedSessionId(session.id); - await refresh(); - navigate(`/lanes?laneId=${encodeURIComponent(laneId)}&sessionId=${encodeURIComponent(session.id)}`); - }, [selectLane, focusSession, refresh, navigate]); - - /* ---- Session grouping ---- */ - - const runningFiltered = useMemo(() => filtered.filter((s) => s.status === "running"), [filtered]); - const endedFiltered = useMemo(() => filtered.filter((s) => s.status !== "running"), [filtered]); - - /* ---- Pane configs ---- */ - - const paneConfigs: Record = useMemo(() => ({ - sessions: { - title: "Sessions", - icon: Terminal, - meta: loading ? "loading" : `${filtered.length}`, - children: ( -
- {/* Launch panel */} - ({ id: l.id, name: l.name }))} - onLaunchPty={(laneId, profile) => { handleLaunchPty(laneId, profile).catch(() => {}); }} - onLaunchChat={(laneId, provider) => { handleLaunchChat(laneId, provider).catch(() => {}); }} - /> - - {/* Filters */} -
-
- - -
- setQ(e.target.value)} - /> -
- - {/* Session list */} -
- {filtered.length === 0 ? ( -
-
- -
-
No terminal sessions
-
- Start a new session to begin working. -
- -
- ) : ( -
- {/* Running group */} - {runningFiltered.length > 0 && ( -
-
- - - Running · {runningFiltered.length} - -
- {runningFiltered.map((s) => resumeSession(s).catch(() => {})} resumingSessionId={resumingSessionId} />)} -
- )} - - {/* Ended group */} - {endedFiltered.length > 0 && ( -
-
- - - Ended · {endedFiltered.length} - -
- {endedFiltered.map((s) => resumeSession(s).catch(() => {})} resumingSessionId={resumingSessionId} />)} -
- )} -
- )} -
-
- ), + const handleSelectSession = useCallback( + (id: string) => { + work.setSelectedSessionId(id); + work.openSessionTab(id); }, + [work], + ); - terminal: { - title: "Terminal", - icon: Monitor, - bodyClassName: "overflow-hidden", - meta: selectedSession - ? selectedIsChat ? "chat" : selectedSession.status === "running" ? "live" : selectedSession.status - : undefined, - children: ( -
- {selectedSession && selectedIsChat ? ( - - ) : runningSessions.length > 0 ? ( -
- {runningSessions.map((session) => - session.ptyId ? ( - - ) : null - )} - {selectedSession?.status === "running" && selectedSession.ptyId ? null : ( -
-
- Select a running session to interact. -
-
- )} -
- ) : ( -
-
- -
-
- {selectedSession ? "Session not running" : "No session selected"} -
-
- {selectedSession - ? "This session has ended. Select a running session to view its terminal." - : "Launch or select a session from the list."} -
-
- )} -
- ), + const handleInfoClick = useCallback( + (session: TerminalSessionSummary, e: React.MouseEvent) => { + setInfoPopover({ session, x: e.clientX, y: e.clientY }); }, + [], + ); - details: { - title: "Details", - icon: Info, - meta: selectedSession ? selectedSession.status : undefined, - children: ( -
- {selectedSession ? ( -
- {/* Metadata */} -
-
- - Session info -
-
- {[ - ["Title", (selectedSession.goal ?? selectedSession.title).trim()], - ["Lane", selectedSession.laneName], - ["Status", selectedSession.status], - ["Runtime", runtimeStateLabel(selectedSession.runtimeState)], - selectedSession.toolType ? ["Tool", selectedSession.toolType] : null, - selectedSession.exitCode != null ? ["Exit", `${selectedSession.exitCode}`] : null, - !selectedSession.tracked ? ["Context", "no context"] : null, - ["Started", new Date(selectedSession.startedAt).toLocaleTimeString()], - selectedSession.endedAt ? ["Ended", new Date(selectedSession.endedAt).toLocaleTimeString()] : null, - ].filter((row): row is [string, string] => row != null).map(([label, value]) => ( -
- {label} - {value} -
- ))} -
-
- - {/* Last output */} - {sanitizeTerminalInlineText(selectedSession.lastOutputPreview, 420) ? ( -
-
- - Last output -
-
-                    {sanitizeTerminalInlineText(selectedSession.lastOutputPreview, 420)}
-                  
-
- ) : null} + const handleContextMenu = useCallback( + (session: TerminalSessionSummary, e: React.MouseEvent) => { + setContextMenu({ session, x: e.clientX, y: e.clientY }); + }, + [], + ); - {/* Summary */} - {selectedSession.summary && selectedSession.status !== "running" ? ( -
-
- - Summary -
-

{selectedSession.summary}

-
- ) : null} + const handleGoToLane = useCallback( + (session: TerminalSessionSummary) => { + work.selectLane(session.laneId); + work.focusSession(session.id); + work.navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(session.id)}`); + }, + [work], + ); - {/* Resume command */} - {selectedSession.status !== "running" && selectedSession.resumeCommand ? ( -
-
- - Resume command -
- - {selectedSession.resumeCommand} - -
- ) : null} + /* ---- Pane configs ---- */ - {/* Terminal health */} - {selectedHealth ? ( -
-
- - Terminal health -
-
- fit_failures: {selectedHealth.fitFailures} - zero_dim: {selectedHealth.zeroDimFits} - renderer: {selectedHealth.rendererFallbacks} - dropped: {selectedHealth.droppedChunks} -
-
- ) : null} + const paneConfigs: Record = useMemo( + () => ({ + sessions: { + title: "Sessions", + icon: Terminal, + meta: work.loading ? "loading" : `${work.filtered.length}`, + children: ( + ({ id: l.id, name: l.name }))} + filtered={work.filtered} + runningFiltered={work.runningFiltered} + endedFiltered={work.endedFiltered} + loading={work.loading} + filterLaneId={work.filterLaneId} + setFilterLaneId={work.setFilterLaneId} + filterStatus={work.filterStatus} + setFilterStatus={work.setFilterStatus} + q={work.q} + setQ={work.setQ} + selectedSessionId={work.selectedSessionId} + onSelectSession={handleSelectSession} + onResume={(s) => work.resumeSession(s).catch(() => {})} + resumingSessionId={work.resumingSessionId} + onLaunchPty={(laneId, profile) => work.handleLaunchPty(laneId, profile).catch(() => {})} + onLaunchChat={(laneId, provider) => work.handleLaunchChat(laneId, provider).catch(() => {})} + onInfoClick={handleInfoClick} + onContextMenu={handleContextMenu} + /> + ), + }, - {/* Actions */} -
- {selectedSession.status === "running" && selectedSession.ptyId ? ( - - ) : null} - {selectedSession.status === "running" && selectedIsChat ? ( - - ) : null} - {selectedSession.status !== "running" && selectedSession.resumeCommand ? ( - <> - - - - ) : null} - -
-
- ) : ( -
-
- -
-
No session selected
-
- Click a session from the list to view details. -
-
- )} -
- ), - }, - }), [ - filtered, runningFiltered, endedFiltered, loading, filterLaneId, filterStatus, q, lanes, - selectedSessionId, selectedSession, selectedIsChat, selectedHealth, - closingPtyIds, closingChatSessionId, resumingSessionId, - selectLane, focusSession, navigate, closeSession, closeChatSession, resumeSession, - handleLaunchPty, handleLaunchChat, - ]); + view: { + title: "View", + icon: Terminal, + bodyClassName: "overflow-hidden", + children: ( + { + work.setActiveTabId(id); + work.setSelectedSessionId(id); + }} + onCloseTab={work.closeTab} + closingPtyIds={work.closingPtyIds} + onCloseSession={(id) => { + const session = work.sessions.find((s) => s.id === id); + if (session?.ptyId) work.closeSession(session.ptyId).catch(() => {}); + }} + /> + ), + }, + }), + [work, handleSelectSession, handleInfoClick, handleContextMenu], + ); return (
{/* Header */} -
+
Work - {runningSessions.length > 0 ? ( + {work.runningSessions.length > 0 ? ( - {runningSessions.length} running + {work.runningSessions.length} running ) : null}
@@ -711,8 +145,8 @@ export function TerminalsPage() { variant="outline" size="sm" className="h-7" - disabled={runningSessions.length === 0} - onClick={() => closeRunning().catch(() => {})} + disabled={work.runningSessions.length === 0} + onClick={() => work.closeAllRunning().catch(() => {})} > Close all @@ -721,7 +155,7 @@ export function TerminalsPage() { variant="ghost" size="sm" className="h-7 w-7 p-0" - onClick={() => refresh().catch(() => {})} + onClick={() => work.refresh().catch(() => {})} title="Refresh" > @@ -731,85 +165,35 @@ export function TerminalsPage() {
-
- ); -} -/* ---- Session row ---- */ - -function SessionRow({ - session, - isSelected, - onSelect, - onResume, - resumingSessionId, -}: { - session: TerminalSessionSummary; - isSelected: boolean; - onSelect: (id: string) => void; - onResume: () => void; - resumingSessionId: string | null; -}) { - const dot = statusDot(session); - const canResume = session.status !== "running" && Boolean(session.resumeCommand); + {/* Floating overlays */} + setContextMenu(null)} + onCloseSession={(ptyId) => work.closeSession(ptyId).catch(() => {})} + onEndChat={(id) => work.closeChatSession(id).catch(() => {})} + onResume={(s) => work.resumeSession(s).catch(() => {})} + onCopyResumeCommand={(cmd) => navigator.clipboard.writeText(cmd).catch(() => {})} + onGoToLane={handleGoToLane} + onCopySessionId={(id) => navigator.clipboard.writeText(id).catch(() => {})} + /> - return ( -
- - {/* Resume on hover */} - {canResume ? ( - - ) : null} + setInfoPopover(null)} + onCloseSession={(ptyId) => work.closeSession(ptyId).catch(() => {})} + onEndChat={(id) => work.closeChatSession(id).catch(() => {})} + onResume={(s) => work.resumeSession(s).catch(() => {})} + onGoToLane={handleGoToLane} + closingPtyIds={work.closingPtyIds} + closingChatSessionId={work.closingChatSessionId} + resumingSessionId={work.resumingSessionId} + />
); } diff --git a/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx b/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx new file mode 100644 index 000000000..eaaff8610 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import type { TerminalToolType } from "../../../shared/types"; +import { cn } from "../ui/cn"; + +type LogoProps = { size?: number; className?: string }; + +export const ClaudeLogo: React.FC = ({ size = 16, className }) => ( + + + + + + + + + + + +); + +export const CodexLogo: React.FC = ({ size = 16, className }) => ( + + + +); + +export const ShellLogo: React.FC = ({ size = 16, className }) => ( + + + + +); + +const LOGO_MAP: Partial>> = { + claude: ClaudeLogo, + "claude-chat": ClaudeLogo, + "claude-orchestrated": ClaudeLogo, + codex: CodexLogo, + "codex-chat": CodexLogo, + "codex-orchestrated": CodexLogo, + shell: ShellLogo, +}; + +export function ToolLogo({ + toolType, + size = 16, + className, +}: { + toolType: TerminalToolType | null | undefined; + size?: number; + className?: string; +}) { + const Logo = toolType ? LOGO_MAP[toolType] : undefined; + if (Logo) return ; + return ; +} diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx new file mode 100644 index 000000000..ce665cc52 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -0,0 +1,207 @@ +import React, { useMemo } from "react"; +import { GridFour, List, Monitor, X } from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { TerminalView } from "./TerminalView"; +import { ToolLogo } from "./ToolLogos"; +import { TilingLayout } from "../lanes/TilingLayout"; +import { AgentChatPane } from "../chat/AgentChatPane"; +import { cn } from "../ui/cn"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +function statusDotClass(session: TerminalSessionSummary): string { + if (session.status === "running") return "bg-emerald-500"; + if (session.status === "failed") return "bg-red-500"; + if (session.status === "disposed") return "bg-red-400/70"; + return "bg-sky-500/70"; +} + +function truncateTabLabel(text: string, max = 20): string { + if (text.length <= max) return text; + return text.slice(0, max - 1) + "..."; +} + +export function WorkViewArea({ + sessions, + runningSessions, + openTabIds, + activeTabId, + viewMode, + setViewMode, + onSelectTab, + onCloseTab, + closingPtyIds, + onCloseSession, +}: { + sessions: TerminalSessionSummary[]; + runningSessions: TerminalSessionSummary[]; + openTabIds: string[]; + activeTabId: string | null; + viewMode: "tabs" | "grid"; + setViewMode: (mode: "tabs" | "grid") => void; + onSelectTab: (sessionId: string) => void; + onCloseTab: (sessionId: string) => void; + closingPtyIds: Set; + onCloseSession: (id: string) => void; +}) { + const sessionsById = useMemo(() => { + const map = new Map(); + for (const s of sessions) map.set(s.id, s); + return map; + }, [sessions]); + + const tabSessions = useMemo( + () => openTabIds.map((id) => sessionsById.get(id)).filter((s): s is TerminalSessionSummary => s != null), + [openTabIds, sessionsById], + ); + + const activeSession = activeTabId ? sessionsById.get(activeTabId) ?? null : null; + const activeIsChat = isChatToolType(activeSession?.toolType); + + if (viewMode === "grid") { + return ( +
+ {/* Mode toggle header */} +
+ + {runningSessions.length} running +
+
+ +
+
+ ); + } + + // Tab mode + return ( +
+ {/* Tab bar */} +
+ +
+
+ {tabSessions.map((s) => ( + + ))} +
+
+ + {/* Content area */} +
+ {activeSession && activeIsChat ? ( + + ) : runningSessions.length > 0 ? ( +
+ {runningSessions.map((session) => + session.ptyId ? ( + + ) : null, + )} + {activeSession?.status === "running" && activeSession.ptyId ? null : ( +
+
+ Select a running session to interact. +
+
+ )} +
+ ) : tabSessions.length === 0 ? ( +
+
+ +
+
No session selected
+
+ Click a session from the list to open it here. +
+
+ ) : ( +
+
+ +
+
Session not running
+
+ This session has ended. Select a running session to view its terminal. +
+
+ )} +
+
+ ); +} + +function ViewModeToggle({ + viewMode, + setViewMode, +}: { + viewMode: "tabs" | "grid"; + setViewMode: (mode: "tabs" | "grid") => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/useSessionDelta.ts b/apps/desktop/src/renderer/components/terminals/useSessionDelta.ts new file mode 100644 index 000000000..7b8aa6e99 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/useSessionDelta.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import type { SessionDeltaSummary } from "../../../shared/types"; + +const deltaCache = new Map(); + +export function useSessionDelta(sessionId: string | null, enabled: boolean) { + const [delta, setDelta] = useState( + sessionId ? deltaCache.get(sessionId) ?? null : null, + ); + + useEffect(() => { + if (!sessionId || !enabled) { + setDelta(null); + return; + } + + const cached = deltaCache.get(sessionId); + if (cached) { + setDelta(cached); + return; + } + + let cancelled = false; + window.ade.sessions + .getDelta(sessionId) + .then((result) => { + if (cancelled) return; + deltaCache.set(sessionId, result); + setDelta(result); + }) + .catch(() => { + // ignore - delta not available + }); + + return () => { + cancelled = true; + }; + }, [sessionId, enabled]); + + return delta; +} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts new file mode 100644 index 000000000..41c580625 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -0,0 +1,349 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import type { TerminalSessionSummary, TerminalSessionStatus } from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +function inferToolFromResumeCommand(command: string): "claude" | "codex" | null { + const n = command.trim().toLowerCase(); + if (n.startsWith("claude ")) return "claude"; + if (n.startsWith("codex ")) return "codex"; + return null; +} + +export function useWorkSessions() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const lanes = useAppStore((s) => s.lanes); + const selectedLaneId = useAppStore((s) => s.selectedLaneId); + const focusSession = useAppStore((s) => s.focusSession); + const selectLane = useAppStore((s) => s.selectLane); + + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [filterLaneId, setFilterLaneId] = useState("all"); + const [filterStatus, setFilterStatus] = useState("all"); + const [q, setQ] = useState(""); + const [closingPtyIds, setClosingPtyIds] = useState>(new Set()); + const [closingChatSessionId, setClosingChatSessionId] = useState(null); + const [resumingSessionId, setResumingSessionId] = useState(null); + const [selectedSessionId, setSelectedSessionId] = useState(null); + + /* ---- Tabs state ---- */ + const [openTabIds, setOpenTabIds] = useState([]); + const [activeTabId, setActiveTabId] = useState(null); + const [viewMode, setViewMode] = useState<"tabs" | "grid">("tabs"); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const rows = await window.ade.sessions.list({ limit: 500 }); + setSessions(rows); + } finally { + setLoading(false); + } + }, []); + + // Initial fetch + useEffect(() => { + refresh().catch(() => {}); + }, []); + + // URL params + useEffect(() => { + const laneParam = (searchParams.get("laneId") ?? searchParams.get("lane") ?? "").trim(); + if (laneParam && lanes.some((l) => l.id === laneParam)) setFilterLaneId(laneParam); + const statusParam = (searchParams.get("status") ?? "").trim(); + if (["running", "completed", "failed", "disposed", "all"].includes(statusParam)) { + setFilterStatus(statusParam as TerminalSessionStatus | "all"); + } + }, [searchParams, lanes]); + + // Event subscriptions + useEffect(() => { + const unsubExit = window.ade.pty.onExit(() => { + refresh().catch(() => {}); + }); + const t = setInterval(() => { + if (sessions.some((s) => s.status === "running")) refresh().catch(() => {}); + }, 2000); + return () => { + try { unsubExit(); } catch { /* ignore */ } + clearInterval(t); + }; + }, [sessions, refresh]); + + useEffect(() => { + const unsubscribe = window.ade.agentChat.onEvent((payload) => { + const event = payload.event; + if (event.type === "done") refresh().catch(() => {}); + if (event.type === "status" && event.turnStatus !== "started") refresh().catch(() => {}); + }); + return unsubscribe; + }, [refresh]); + + // Enhanced filtering with prefix search + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + return sessions.filter((s) => { + if (filterLaneId !== "all" && s.laneId !== filterLaneId) return false; + if (filterStatus !== "all" && s.status !== filterStatus) return false; + if (!needle) return true; + + // Prefix search: lane:, type:, tracked: + if (needle.startsWith("lane:")) { + const val = needle.slice(5).trim(); + return s.laneName.toLowerCase().includes(val); + } + if (needle.startsWith("type:")) { + const val = needle.slice(5).trim(); + return (s.toolType ?? "").toLowerCase().includes(val); + } + if (needle.startsWith("tracked:")) { + const val = needle.slice(8).trim(); + if (val === "yes" || val === "true") return s.tracked; + if (val === "no" || val === "false") return !s.tracked; + return true; + } + + return ( + (s.goal ?? s.title).toLowerCase().includes(needle) || + s.laneName.toLowerCase().includes(needle) || + (s.toolType ?? "").toLowerCase().includes(needle) || + (s.lastOutputPreview ?? "").toLowerCase().includes(needle) || + (s.summary ?? "").toLowerCase().includes(needle) || + (s.resumeCommand ?? "").toLowerCase().includes(needle) + ); + }); + }, [sessions, filterLaneId, filterStatus, q]); + + const runningSessions = useMemo( + () => sessions.filter((s) => s.status === "running" && Boolean(s.ptyId)), + [sessions], + ); + + const selectedSession = useMemo( + () => (selectedSessionId ? sessions.find((s) => s.id === selectedSessionId) ?? null : null), + [sessions, selectedSessionId], + ); + + const runningFiltered = useMemo(() => filtered.filter((s) => s.status === "running"), [filtered]); + const endedFiltered = useMemo(() => filtered.filter((s) => s.status !== "running"), [filtered]); + + // Auto-select first running session + useEffect(() => { + if (!selectedSessionId && runningSessions.length > 0) setSelectedSessionId(runningSessions[0]!.id); + }, [selectedSessionId, runningSessions]); + + /* ---- Tab management ---- */ + const openSessionTab = useCallback( + (sessionId: string) => { + setOpenTabIds((prev) => (prev.includes(sessionId) ? prev : [...prev, sessionId])); + setActiveTabId(sessionId); + setSelectedSessionId(sessionId); + }, + [], + ); + + const closeTab = useCallback( + (sessionId: string) => { + setOpenTabIds((prev) => { + const next = prev.filter((id) => id !== sessionId); + if (activeTabId === sessionId) { + const idx = prev.indexOf(sessionId); + const newActive = next[Math.min(idx, next.length - 1)] ?? null; + setActiveTabId(newActive); + setSelectedSessionId(newActive); + } + return next; + }); + }, + [activeTabId], + ); + + /* ---- Session actions ---- */ + + const markPtyClosed = (ptyId: string) => { + setSessions((prev) => + prev.map((s) => + s.ptyId === ptyId + ? { ...s, ptyId: null, status: "disposed" as const, runtimeState: "killed" as const, endedAt: new Date().toISOString(), exitCode: null } + : s, + ), + ); + }; + + const closeSession = useCallback( + async (ptyId: string) => { + setClosingPtyIds((prev) => { + const n = new Set(prev); + n.add(ptyId); + return n; + }); + markPtyClosed(ptyId); + try { + await window.ade.pty.dispose({ ptyId }); + } finally { + setClosingPtyIds((prev) => { + const n = new Set(prev); + n.delete(ptyId); + return n; + }); + await refresh(); + } + }, + [refresh], + ); + + const closeAllRunning = useCallback(async () => { + const ids = runningSessions.map((s) => s.ptyId).filter((id): id is string => Boolean(id)); + await Promise.allSettled(ids.map((id) => closeSession(id))); + }, [runningSessions, closeSession]); + + const resumeSession = useCallback( + async (session: TerminalSessionSummary) => { + if (isChatToolType(session.toolType)) { + if (resumingSessionId) return; + setResumingSessionId(session.id); + try { + await window.ade.agentChat.resume({ sessionId: session.id }); + selectLane(session.laneId); + focusSession(session.id); + setSelectedSessionId(session.id); + await refresh(); + } finally { + setResumingSessionId(null); + } + return; + } + const command = (session.resumeCommand ?? "").trim(); + if (!command || resumingSessionId) return; + setResumingSessionId(session.id); + try { + const toolType = session.toolType ?? inferToolFromResumeCommand(command) ?? null; + const started = await window.ade.pty.create({ + laneId: session.laneId, + cols: 100, + rows: 30, + title: session.goal?.trim() || session.title || "Terminal", + tracked: session.tracked, + toolType, + startupCommand: command, + }); + selectLane(session.laneId); + focusSession(started.sessionId); + setSelectedSessionId(started.sessionId); + navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(started.sessionId)}`); + } finally { + setResumingSessionId(null); + } + }, + [focusSession, navigate, refresh, resumingSessionId, selectLane], + ); + + const closeChatSession = useCallback( + async (sessionId: string) => { + setClosingChatSessionId(sessionId); + try { + await window.ade.agentChat.dispose({ sessionId }); + await refresh(); + } finally { + setClosingChatSessionId((c) => (c === sessionId ? null : c)); + } + }, + [refresh], + ); + + /* ---- Launch new sessions ---- */ + + const handleLaunchPty = useCallback( + async (laneId: string, profile: "claude" | "codex" | "shell") => { + const toolTypeMap = { claude: "claude" as const, codex: "codex" as const, shell: "shell" as const }; + const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" }; + const commandMap = { claude: "claude", codex: "codex", shell: "" }; + const result = await window.ade.pty.create({ + laneId, + cols: 100, + rows: 30, + title: titleMap[profile], + tracked: true, + toolType: toolTypeMap[profile], + startupCommand: commandMap[profile] || undefined, + }); + selectLane(laneId); + focusSession(result.sessionId); + setSelectedSessionId(result.sessionId); + openSessionTab(result.sessionId); + await refresh(); + }, + [selectLane, focusSession, refresh, openSessionTab], + ); + + const handleLaunchChat = useCallback( + async (laneId: string, provider: "claude" | "codex") => { + const defaultModel = provider === "codex" ? "gpt-5.3-codex" : "sonnet"; + const session = await window.ade.agentChat.create({ laneId, provider, model: defaultModel }); + selectLane(laneId); + focusSession(session.id); + setSelectedSessionId(session.id); + openSessionTab(session.id); + await refresh(); + }, + [selectLane, focusSession, refresh, openSessionTab], + ); + + return { + // Data + sessions, + lanes, + filtered, + runningFiltered, + endedFiltered, + runningSessions, + selectedSession, + loading, + + // Filters + filterLaneId, + setFilterLaneId, + filterStatus, + setFilterStatus, + q, + setQ, + + // Selection + selectedSessionId, + setSelectedSessionId, + + // Tabs + openTabIds, + activeTabId, + viewMode, + setViewMode, + openSessionTab, + closeTab, + setActiveTabId, + + // In-flight state + closingPtyIds, + closingChatSessionId, + resumingSessionId, + + // Actions + refresh, + closeSession, + closeAllRunning, + resumeSession, + closeChatSession, + handleLaunchPty, + handleLaunchChat, + + // Navigation helpers + navigate, + selectLane, + focusSession, + }; +} diff --git a/assets/claude.svg b/assets/claude.svg new file mode 100644 index 000000000..9ae00d8b2 --- /dev/null +++ b/assets/claude.svg @@ -0,0 +1 @@ + diff --git a/assets/codex.svg b/assets/codex.svg new file mode 100644 index 000000000..3b4eff961 --- /dev/null +++ b/assets/codex.svg @@ -0,0 +1,2 @@ + +OpenAI icon \ No newline at end of file diff --git a/assets/terminal.svg b/assets/terminal.svg new file mode 100644 index 000000000..51a4cc444 --- /dev/null +++ b/assets/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file From da0981ddb77fcc3393835e903b238fc139833060 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:27:37 -0500 Subject: [PATCH 2/2] fixing work tab --- .../src/main/services/pty/ptyService.ts | 54 +- .../components/lanes/LaneTerminalsPanel.tsx | 261 +----- .../components/terminals/LaunchPanel.tsx | 226 +++++ .../components/terminals/SessionCard.tsx | 163 ++++ .../terminals/SessionContextMenu.tsx | 105 +++ .../terminals/SessionInfoPopover.tsx | 237 +++++ .../components/terminals/SessionListPane.tsx | 198 ++++ .../terminals/TerminalSettingsDialog.tsx | 266 ++++++ .../components/terminals/TerminalsPage.tsx | 870 +++--------------- .../components/terminals/ToolLogos.tsx | 82 ++ .../components/terminals/WorkViewArea.tsx | 207 +++++ .../components/terminals/useSessionDelta.ts | 41 + .../components/terminals/useWorkSessions.ts | 349 +++++++ assets/claude.svg | 1 + assets/codex.svg | 2 + assets/terminal.svg | 1 + 16 files changed, 2075 insertions(+), 988 deletions(-) create mode 100644 apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionCard.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/SessionListPane.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/ToolLogos.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/useSessionDelta.ts create mode 100644 apps/desktop/src/renderer/components/terminals/useWorkSessions.ts create mode 100644 assets/claude.svg create mode 100644 assets/codex.svg create mode 100644 assets/terminal.svg diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 3ec8146a1..6e99bbab0 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -48,6 +48,7 @@ type PtyEntry = { lastRuntimeSignalState: TerminalRuntimeState; lastRuntimeSignalPreview: string | null; disposed: boolean; + createdAt: number; }; type RuntimeStateEntry = { @@ -147,6 +148,22 @@ export function createPtyService({ }) { const ptys = new Map(); const runtimeStates = new Map(); + /** Timers for auto-closing tool-typed PTYs when the CLI tool exits back to shell prompt */ + const toolAutoCloseTimers = new Map>(); + + /** Tool types that run a CLI tool inside the shell and should auto-close when the tool exits */ + const TOOL_TYPES_WITH_AUTO_CLOSE = new Set([ + "claude", "codex", "claude-orchestrated", "codex-orchestrated", + "aider", "cursor", "continue" + ]); + + const clearToolAutoCloseTimer = (ptyId: string) => { + const timer = toolAutoCloseTimers.get(ptyId); + if (timer) { + clearTimeout(timer); + toolAutoCloseTimers.delete(ptyId); + } + }; const clearIdleTimer = (sessionId: string) => { const state = runtimeStates.get(sessionId); @@ -253,6 +270,7 @@ export function createPtyService({ if (!entry) return; if (entry.disposed) return; entry.disposed = true; + clearToolAutoCloseTimer(ptyId); try { entry.transcriptStream?.end(); @@ -492,7 +510,8 @@ export function createPtyService({ lastRuntimeSignalAt: 0, lastRuntimeSignalState: "running", lastRuntimeSignalPreview: null, - disposed: false + disposed: false, + createdAt: Date.now() }; ptys.set(ptyId, entry); @@ -505,15 +524,45 @@ export function createPtyService({ updatePreviewThrottled(entry, data); broadcastData({ ptyId, sessionId, data }); - const runtimeState = runtimeStateFromOsc133Chunk(data, runtimeStates.get(sessionId)?.state ?? "running"); + const prevState = runtimeStates.get(sessionId)?.state ?? "running"; + const runtimeState = runtimeStateFromOsc133Chunk(data, prevState); setRuntimeState(sessionId, runtimeState); if (runtimeState === "running") { scheduleIdleTransition(sessionId); + clearToolAutoCloseTimer(ptyId); } else { clearIdleTimer(sessionId); } emitRuntimeSignalThrottled(entry, runtimeState); + // Auto-close tool-typed PTYs when the CLI tool exits back to shell prompt. + // When a tool like claude/codex exits (via /exit, completion, etc.), the outer + // shell stays alive and returns to its prompt, detected as "waiting-input". + // We auto-dispose after a brief delay to let final output flush. + if ( + runtimeState === "waiting-input" && + (prevState === "running" || prevState === "idle") && + entry.toolTypeHint && + TOOL_TYPES_WITH_AUTO_CLOSE.has(entry.toolTypeHint) && + !toolAutoCloseTimers.has(ptyId) && + Date.now() - entry.createdAt > 5_000 // ignore initial shell prompt + ) { + toolAutoCloseTimers.set( + ptyId, + setTimeout(() => { + toolAutoCloseTimers.delete(ptyId); + if (entry.disposed) return; + logger.info("pty.tool_exit_auto_close", { ptyId, sessionId, toolType: entry.toolTypeHint }); + try { + entry.pty.kill(); + } catch { + // If kill fails, force close via closeEntry + closeEntry(ptyId, 0); + } + }, 1500) + ); + } + if (!entry.resumeCommand || entry.resumeCommandIsFallback) { entry.resumeScanBuffer = `${entry.resumeScanBuffer}${data}`.slice(-12_000); const detected = extractResumeCommandFromOutput(entry.resumeScanBuffer, entry.toolTypeHint); @@ -671,6 +720,7 @@ export function createPtyService({ } if (entry.disposed) return; entry.disposed = true; + clearToolAutoCloseTimer(ptyId); try { entry.transcriptStream?.end(); } catch { diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index 5132e9857..a6a6b4969 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Tabs from "@radix-ui/react-tabs"; -import * as Dialog from "@radix-ui/react-dialog"; -import { ArrowSquareOut, GridFour, List, Plus, GearSix, Trash, X } from "@phosphor-icons/react"; +import { ArrowSquareOut, GridFour, List, GearSix, X } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; import type { TerminalLaunchProfile, @@ -14,6 +13,7 @@ import { Chip } from "../ui/Chip"; import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; import { TerminalView } from "../terminals/TerminalView"; +import { TerminalSettingsDialog, readLaunchTracked, persistLaunchTracked } from "../terminals/TerminalSettingsDialog"; import { TilingLayout } from "./TilingLayout"; import { useNavigate } from "react-router-dom"; import { sessionIndicatorState } from "../../lib/terminalAttention"; @@ -21,21 +21,8 @@ import { sessionIndicatorState } from "../../lib/terminalAttention"; const tabTrigger = "flex items-center gap-2 rounded-md px-2.5 py-2 text-xs font-semibold text-muted-fg data-[state=active]:text-fg data-[state=active]:bg-accent/10 data-[state=active]:ring-1 data-[state=active]:ring-accent/50"; -const LAUNCH_TRACKED_KEY = "ade.terminals.launchTracked"; const DEFAULT_PROFILE_IDS = ["claude", "codex", "shell"] as const; -const PROFILE_COLORS = [ - null, // no color / default - "#ef4444", // red - "#f97316", // orange - "#f59e0b", // amber - "#22c55e", // green - "#06b6d4", // cyan - "#3b82f6", // blue - "#8b5cf6", // violet - "#ec4899", // pink -] as const; - function statusDot(status: string) { if (status === "running") return "border-2 border-emerald-500 border-t-transparent bg-transparent"; if (status === "failed") return "bg-red-700"; @@ -54,25 +41,6 @@ function sessionTabLabel(session: TerminalSessionSummary): string { return `${tool} · ${outcome} · ${base}`.slice(0, 180); } -function readLaunchTracked(): boolean { - try { - const raw = window.localStorage.getItem(LAUNCH_TRACKED_KEY); - if (raw === "0") return false; - if (raw === "1") return true; - } catch { - // ignore - } - return true; -} - -function persistLaunchTracked(value: boolean) { - try { - window.localStorage.setItem(LAUNCH_TRACKED_KEY, value ? "1" : "0"); - } catch { - // ignore - } -} - function toolTypeFromProfileId(profileId: string): TerminalToolType | null { const id = profileId.trim().toLowerCase(); if (id === "claude") return "claude"; @@ -88,28 +56,6 @@ function isChatToolType(toolType: TerminalToolType | null | undefined): boolean return toolType === "codex-chat" || toolType === "claude-chat"; } -function isDefaultProfile(profile: TerminalLaunchProfile): boolean { - return (DEFAULT_PROFILE_IDS as readonly string[]).includes(profile.id); -} - -function slugify(raw: string): string { - return raw - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); -} - -function uniqueProfileId(base: string, existing: Set): string { - if (!existing.has(base)) return base; - for (let i = 2; i < 50; i += 1) { - const next = `${base}-${i}`; - if (!existing.has(next)) return next; - } - return `${base}-${Date.now()}`; -} - export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string | null } = {}) { const navigate = useNavigate(); const globalLaneId = useAppStore((s) => s.selectedLaneId); @@ -128,11 +74,6 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string const [terminalProfiles, setTerminalProfiles] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [launchTracked, setLaunchTracked] = useState(readLaunchTracked()); - const [profileDraft, setProfileDraft] = useState([]); - const [newProfileName, setNewProfileName] = useState(""); - const [newProfileCommand, setNewProfileCommand] = useState(""); - const [profilesBusy, setProfilesBusy] = useState(false); - const [profilesError, setProfilesError] = useState(null); const laneSessionIdsRef = useRef>(new Set()); const focusedSessionId = overrideLaneId != null ? localFocusedSessionId : globalFocusedSessionId; @@ -226,8 +167,8 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string ? { ...entry, ptyId: null, - status: "disposed", - runtimeState: "killed", + status: "disposed" as const, + runtimeState: "killed" as const, endedAt: new Date().toISOString(), exitCode: null } @@ -329,45 +270,8 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string ); const openSettings = useCallback(() => { - setProfilesError(null); - setProfileDraft([...(terminalProfiles?.profiles ?? [])]); setSettingsOpen(true); - }, [terminalProfiles]); - - const saveProfiles = useCallback(async () => { - if (!terminalProfiles) return; - setProfilesBusy(true); - setProfilesError(null); - try { - const next = await window.ade.terminalProfiles.set({ - profiles: profileDraft, - defaultProfileId: terminalProfiles.defaultProfileId ?? "shell" - }); - setTerminalProfiles(next); - setProfileDraft(next.profiles); - setSettingsOpen(false); - } catch (err) { - setProfilesError(err instanceof Error ? err.message : String(err)); - } finally { - setProfilesBusy(false); - } - }, [terminalProfiles, profileDraft]); - - const addProfile = useCallback(() => { - const name = newProfileName.trim(); - const command = newProfileCommand.trim(); - if (!name || !command) { - setProfilesError("Name and command are required."); - return; - } - const existing = new Set(profileDraft.map((p) => p.id)); - const base = slugify(name) || "custom"; - const id = uniqueProfileId(base, existing); - setProfileDraft((prev) => [...prev, { id, name, command, tracked: true, description: null, color: null }]); - setNewProfileName(""); - setNewProfileCommand(""); - setProfilesError(null); - }, [newProfileName, newProfileCommand, profileDraft]); + }, []); return (
@@ -550,148 +454,19 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string
)} - - - - -
- Terminal Settings - - Configure launch profiles and whether new terminals collect context. - - - - -
- -
-
-
Launch mode
- -
- If disabled, terminals still run normally but do not produce transcripts or pack updates. -
-
- -
-
Terminal buttons
-
- {profileDraft.length === 0 ? ( -
No profiles loaded.
- ) : ( - profileDraft.map((p) => { - const locked = isDefaultProfile(p); - return ( -
-
-
{p.id}
- - setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, name: e.target.value } : x))) - } - placeholder="Name" - /> - - setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, command: e.target.value } : x))) - } - placeholder="Command" - /> - -
-
- Color - {PROFILE_COLORS.map((c) => ( - - ))} -
-
- ); - }) - )} -
- -
-
Add custom button
-
- setNewProfileName(e.target.value)} - placeholder="Name (e.g., Dev Server)" - /> - setNewProfileCommand(e.target.value)} - placeholder="Command (e.g., npm run dev)" - /> - -
-
- - {profilesError ? ( -
- {profilesError} -
- ) : null} - -
- - -
-
-
-
-
-
+ { + setTerminalProfiles(next); + }} + launchTracked={launchTracked} + onLaunchTrackedChange={(v) => { + setLaunchTracked(v); + persistLaunchTracked(v); + }} + />
); } diff --git a/apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx b/apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx new file mode 100644 index 000000000..a8d0b027f --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LaunchPanel.tsx @@ -0,0 +1,226 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + CaretDown as ChevronDown, + ChatCircleDots as MessageSquarePlus, + GearSix, + Terminal, + Brain as BrainCircuit, +} from "@phosphor-icons/react"; +import type { TerminalLaunchProfile, TerminalProfilesSnapshot, TerminalToolType } from "../../../shared/types"; +import { ToolLogo } from "./ToolLogos"; +import { TerminalSettingsDialog, readLaunchTracked, persistLaunchTracked } from "./TerminalSettingsDialog"; +import { cn } from "../ui/cn"; +import { Button } from "../ui/Button"; + +const DEFAULT_PROFILE_IDS = ["claude", "codex", "shell"] as const; + +function toolTypeFromProfileId(profileId: string): TerminalToolType | null { + const id = profileId.trim().toLowerCase(); + if (id === "claude") return "claude"; + if (id === "codex") return "codex"; + if (id === "shell") return "shell"; + if (id === "aider") return "aider"; + if (id === "cursor") return "cursor"; + if (id === "continue") return "continue"; + return "other"; +} + +export function LaunchPanel({ + lanes, + onLaunchPty, + onLaunchChat, +}: { + lanes: { id: string; name: string }[]; + onLaunchPty: (laneId: string, profile: "claude" | "codex" | "shell") => void; + onLaunchChat: (laneId: string, provider: "claude" | "codex") => void; +}) { + const [laneId, setLaneId] = useState(lanes[0]?.id ?? ""); + const [chatOpen, setChatOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [terminalProfiles, setTerminalProfiles] = useState(null); + const [launchTracked, setLaunchTracked] = useState(readLaunchTracked()); + + useEffect(() => { + if (!laneId && lanes.length > 0) setLaneId(lanes[0]!.id); + }, [lanes, laneId]); + + useEffect(() => { + let cancelled = false; + window.ade.terminalProfiles + .get() + .then((snapshot) => { + if (cancelled) return; + setTerminalProfiles(snapshot); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const customProfiles = useMemo(() => { + if (!terminalProfiles) return []; + return terminalProfiles.profiles.filter( + (p) => !(DEFAULT_PROFILE_IDS as readonly string[]).includes(p.id), + ); + }, [terminalProfiles]); + + const launchCustomProfile = useCallback( + (profile: TerminalLaunchProfile) => { + if (!laneId) return; + const toolType = toolTypeFromProfileId(profile.id); + const command = (profile.command ?? "").trim(); + window.ade.pty + .create({ + laneId, + cols: 100, + rows: 30, + title: profile.name || "Shell", + tracked: launchTracked, + toolType, + startupCommand: command || undefined, + }) + .catch(() => {}); + }, + [laneId, launchTracked], + ); + + return ( + <> +
+ {/* Lane selector */} +
+ +
+ + +
+ +
+ + {/* Quick-launch row */} +
+ + + + + {/* Custom profile buttons */} + {customProfiles.map((p) => ( + + ))} + +
+ + {/* Chat launch */} +
+ + {chatOpen && ( +
+ + +
+ )} +
+ + {/* Tracked toggle */} + +
+
+ + { setLaunchTracked(v); persistLaunchTracked(v); }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx new file mode 100644 index 000000000..53831352d --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import { Info, Play } from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { sessionIndicatorState } from "../../lib/terminalAttention"; +import { ToolLogo } from "./ToolLogos"; +import { useSessionDelta } from "./useSessionDelta"; +import { cn } from "../ui/cn"; + +/** Tool-type accent gradient for left bar — Claude=warm orange, Codex=cool silver, Shell=dark */ +function toolAccentGradient(toolType: string | null | undefined): string { + if (toolType === "claude" || toolType === "claude-chat" || toolType === "claude-orchestrated") + return "from-orange-400/70 to-orange-400/10"; + if (toolType === "codex" || toolType === "codex-chat" || toolType === "codex-orchestrated") + return "from-slate-300/60 to-slate-300/10"; + if (toolType === "shell") return "from-zinc-500/50 to-zinc-500/10"; + return "from-border/20 to-transparent"; +} + +/** Tool-type badge color */ +function toolBadgeClass(toolType: string | null | undefined): string { + if (toolType === "claude" || toolType === "claude-chat") return "bg-orange-500/15 text-orange-400"; + if (toolType === "codex" || toolType === "codex-chat") return "bg-slate-400/15 text-slate-300"; + return "bg-zinc-500/15 text-zinc-400"; +} + +function statusDot(session: TerminalSessionSummary): { cls: string; spinning: boolean; label: string } { + const ind = sessionIndicatorState({ + status: session.status, + lastOutputPreview: session.lastOutputPreview, + runtimeState: session.runtimeState, + }); + if (ind === "running-active") + return { cls: "border-2 border-emerald-500 border-t-transparent bg-transparent", spinning: true, label: "Running" }; + if (ind === "running-needs-attention") + return { cls: "border-2 border-amber-400 border-t-transparent bg-transparent", spinning: true, label: "Needs input" }; + if (ind === "failed") return { cls: "bg-red-500", spinning: false, label: "Failed" }; + if (ind === "disposed") return { cls: "bg-red-400/70", spinning: false, label: "Stopped" }; + return { cls: "bg-sky-500/70", spinning: false, label: "Completed" }; +} + +function truncateSummary(text: string | null, maxWords = 8): string { + if (!text) return ""; + const words = text.trim().split(/\s+/); + if (words.length <= maxWords) return text.trim(); + return words.slice(0, maxWords).join(" ") + "..."; +} + +export function SessionCard({ + session, + isSelected, + onSelect, + onResume, + onInfoClick, + onContextMenu, + resumingSessionId, +}: { + session: TerminalSessionSummary; + isSelected: boolean; + onSelect: (id: string) => void; + onResume: () => void; + onInfoClick: (e: React.MouseEvent) => void; + onContextMenu: (e: React.MouseEvent) => void; + resumingSessionId: string | null; +}) { + const dot = statusDot(session); + const canResume = session.status !== "running" && Boolean(session.resumeCommand); + const isEnded = session.status !== "running"; + const delta = useSessionDelta(session.id, isEnded); + const summary = truncateSummary(session.summary ?? session.goal ?? session.title); + + return ( +
+ + + {/* Hover actions */} +
+ {/* Info button */} + + + {/* Resume button */} + {canResume ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx new file mode 100644 index 000000000..e6bcf7e6e --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionContextMenu.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import type { TerminalSessionSummary } from "../../../shared/types"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +export type SessionContextMenuState = { + session: TerminalSessionSummary; + x: number; + y: number; +} | null; + +export function SessionContextMenu({ + menu, + onClose, + onCloseSession, + onEndChat, + onResume, + onCopyResumeCommand, + onGoToLane, + onCopySessionId, +}: { + menu: SessionContextMenuState; + onClose: () => void; + onCloseSession: (ptyId: string) => void; + onEndChat: (sessionId: string) => void; + onResume: (session: TerminalSessionSummary) => void; + onCopyResumeCommand: (command: string) => void; + onGoToLane: (session: TerminalSessionSummary) => void; + onCopySessionId: (id: string) => void; +}) { + if (!menu) return null; + + const { session, x, y } = menu; + const isRunning = session.status === "running"; + const isChat = isChatToolType(session.toolType); + const canResume = !isRunning && Boolean(session.resumeCommand); + + return ( + <> + {/* Backdrop */} +
{ e.preventDefault(); onClose(); }} /> + + {/* Menu */} +
e.stopPropagation()} + > + {isRunning && session.ptyId && !isChat ? ( + + ) : null} + + {isRunning && isChat ? ( + + ) : null} + + {canResume ? ( + + ) : null} + + {session.resumeCommand ? ( + + ) : null} + +
+ + + + +
+ + ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx new file mode 100644 index 000000000..a4d36880b --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionInfoPopover.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useRef } from "react"; +import { + Clipboard, + FileText, + Info, + Monitor, + Play, + Stop as Square, + X, +} from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { sanitizeTerminalInlineText } from "../../lib/terminalAttention"; +import { getTerminalRuntimeHealth } from "./TerminalView"; +import { SessionDeltaCard } from "./SessionDeltaCard"; +import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +function runtimeStateLabel(state: TerminalSessionSummary["runtimeState"]): string { + if (state === "waiting-input") return "waiting input"; + return state; +} + +export type InfoPopoverState = { + session: TerminalSessionSummary; + x: number; + y: number; +} | null; + +export function SessionInfoPopover({ + popover, + onClose, + onCloseSession, + onEndChat, + onResume, + onGoToLane, + closingPtyIds, + closingChatSessionId, + resumingSessionId, +}: { + popover: InfoPopoverState; + onClose: () => void; + onCloseSession: (ptyId: string) => void; + onEndChat: (sessionId: string) => void; + onResume: (session: TerminalSessionSummary) => void; + onGoToLane: (session: TerminalSessionSummary) => void; + closingPtyIds: Set; + closingChatSessionId: string | null; + resumingSessionId: string | null; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!popover) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const keyHandler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handler); + document.addEventListener("keydown", keyHandler); + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("keydown", keyHandler); + }; + }, [popover, onClose]); + + if (!popover) return null; + + const { session, x, y } = popover; + const isChat = isChatToolType(session.toolType); + const health = getTerminalRuntimeHealth(session.id); + + // Position: try to place to the right, but clamp to viewport + const left = Math.min(x + 8, window.innerWidth - 420); + const top = Math.min(y - 20, window.innerHeight - 500); + + return ( +
+ {/* Header */} +
+ {(session.goal ?? session.title).trim()} + +
+ +
+ {/* Metadata */} +
+
+ + Session info +
+
+ {([ + ["Title", (session.goal ?? session.title).trim()], + ["Lane", session.laneName], + ["Status", session.status], + ["Runtime", runtimeStateLabel(session.runtimeState)], + session.toolType ? ["Tool", session.toolType] : null, + session.exitCode != null ? ["Exit", `${session.exitCode}`] : null, + !session.tracked ? ["Context", "no context"] : null, + ["Started", new Date(session.startedAt).toLocaleTimeString()], + session.endedAt ? ["Ended", new Date(session.endedAt).toLocaleTimeString()] : null, + ] as ([string, string] | null)[]) + .filter((row): row is [string, string] => row != null) + .map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ + {/* Last output */} + {sanitizeTerminalInlineText(session.lastOutputPreview, 420) ? ( +
+
+ + Last output +
+
+              {sanitizeTerminalInlineText(session.lastOutputPreview, 420)}
+            
+
+ ) : null} + + {/* Summary */} + {session.summary && session.status !== "running" ? ( +
+
+ + Summary +
+

{session.summary}

+
+ ) : null} + + {/* Resume command */} + {session.status !== "running" && session.resumeCommand ? ( +
+
+ + Resume command +
+ + {session.resumeCommand} + +
+ + +
+
+ ) : null} + + {/* Session Delta */} + {session.status !== "running" ? ( + + ) : null} + + {/* Terminal health */} + {health ? ( +
+
+ + Terminal health +
+
+ fit_failures: {health.fitFailures} + zero_dim: {health.zeroDimFits} + renderer: {health.rendererFallbacks} + dropped: {health.droppedChunks} +
+
+ ) : null} + + {/* Actions */} +
+ {session.status === "running" && session.ptyId && !isChat ? ( + + ) : null} + {session.status === "running" && isChat ? ( + + ) : null} + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx new file mode 100644 index 000000000..da3e11f32 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -0,0 +1,198 @@ +import React from "react"; +import { Terminal } from "@phosphor-icons/react"; +import type { TerminalSessionSummary, TerminalSessionStatus } from "../../../shared/types"; +import { SessionCard } from "./SessionCard"; +import { LaunchPanel } from "./LaunchPanel"; +import type { SessionContextMenuState } from "./SessionContextMenu"; +import type { InfoPopoverState } from "./SessionInfoPopover"; +import { cn } from "../ui/cn"; + +export function SessionListPane({ + lanes, + filtered, + runningFiltered, + endedFiltered, + loading, + filterLaneId, + setFilterLaneId, + filterStatus, + setFilterStatus, + q, + setQ, + selectedSessionId, + onSelectSession, + onResume, + resumingSessionId, + onLaunchPty, + onLaunchChat, + onInfoClick, + onContextMenu, +}: { + lanes: { id: string; name: string }[]; + filtered: TerminalSessionSummary[]; + runningFiltered: TerminalSessionSummary[]; + endedFiltered: TerminalSessionSummary[]; + loading: boolean; + filterLaneId: string; + setFilterLaneId: (v: string) => void; + filterStatus: TerminalSessionStatus | "all"; + setFilterStatus: (v: TerminalSessionStatus | "all") => void; + q: string; + setQ: (v: string) => void; + selectedSessionId: string | null; + onSelectSession: (id: string) => void; + onResume: (session: TerminalSessionSummary) => void; + resumingSessionId: string | null; + onLaunchPty: (laneId: string, profile: "claude" | "codex" | "shell") => void; + onLaunchChat: (laneId: string, provider: "claude" | "codex") => void; + onInfoClick: (session: TerminalSessionSummary, e: React.MouseEvent) => void; + onContextMenu: (session: TerminalSessionSummary, e: React.MouseEvent) => void; +}) { + const statusOptions = [ + { value: "all" as const, label: "All" }, + { value: "running" as const, label: "Running" }, + { value: "completed" as const, label: "Ended" }, + ]; + + return ( +
+ {/* Launch panel */} + + + {/* Filters */} +
+ {/* Lane filter chips */} +
+ + {lanes.map((l) => ( + + ))} +
+ + {/* Status toggle pills */} +
+ {statusOptions.map((opt) => ( + + ))} +
+ + {/* Search bar */} + setQ(e.target.value)} + /> +
+ + {/* Session list */} +
+ {filtered.length === 0 ? ( +
+
+ +
+
No terminal sessions
+
+ Start a new session to begin working. +
+
+ ) : ( +
+ {/* Running group */} + {runningFiltered.length > 0 && ( +
+
+ + + Running · {runningFiltered.length} + +
+
+ {runningFiltered.map((s) => ( + onResume(s)} + onInfoClick={(e) => onInfoClick(s, e)} + onContextMenu={(e) => { e.preventDefault(); onContextMenu(s, e); }} + resumingSessionId={resumingSessionId} + /> + ))} +
+
+ )} + + {/* Ended group */} + {endedFiltered.length > 0 && ( +
0 ? "mt-2" : ""}> +
+ + + Ended · {endedFiltered.length} + +
+
+ {endedFiltered.map((s) => ( + onResume(s)} + onInfoClick={(e) => onInfoClick(s, e)} + onContextMenu={(e) => { e.preventDefault(); onContextMenu(s, e); }} + resumingSessionId={resumingSessionId} + /> + ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx b/apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx new file mode 100644 index 000000000..b61f5bd05 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/TerminalSettingsDialog.tsx @@ -0,0 +1,266 @@ +import React, { useCallback, useEffect, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { Plus, Trash, X } from "@phosphor-icons/react"; +import type { TerminalLaunchProfile, TerminalProfilesSnapshot } from "../../../shared/types"; +import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; + +const LAUNCH_TRACKED_KEY = "ade.terminals.launchTracked"; +const DEFAULT_PROFILE_IDS = ["claude", "codex", "shell"] as const; + +const PROFILE_COLORS = [ + null, + "#ef4444", + "#f97316", + "#f59e0b", + "#22c55e", + "#06b6d4", + "#3b82f6", + "#8b5cf6", + "#ec4899", +] as const; + +function isDefaultProfile(profile: TerminalLaunchProfile): boolean { + return (DEFAULT_PROFILE_IDS as readonly string[]).includes(profile.id); +} + +function slugify(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); +} + +function uniqueProfileId(base: string, existing: Set): string { + if (!existing.has(base)) return base; + for (let i = 2; i < 50; i += 1) { + const next = `${base}-${i}`; + if (!existing.has(next)) return next; + } + return `${base}-${Date.now()}`; +} + +export function readLaunchTracked(): boolean { + try { + const raw = window.localStorage.getItem(LAUNCH_TRACKED_KEY); + if (raw === "0") return false; + if (raw === "1") return true; + } catch { /* ignore */ } + return true; +} + +export function persistLaunchTracked(value: boolean) { + try { + window.localStorage.setItem(LAUNCH_TRACKED_KEY, value ? "1" : "0"); + } catch { /* ignore */ } +} + +export function TerminalSettingsDialog({ + open, + onOpenChange, + terminalProfiles, + onProfilesSaved, + launchTracked, + onLaunchTrackedChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + terminalProfiles: TerminalProfilesSnapshot | null; + onProfilesSaved: (next: TerminalProfilesSnapshot) => void; + launchTracked: boolean; + onLaunchTrackedChange: (value: boolean) => void; +}) { + const [profileDraft, setProfileDraft] = useState([]); + const [newProfileName, setNewProfileName] = useState(""); + const [newProfileCommand, setNewProfileCommand] = useState(""); + const [profilesBusy, setProfilesBusy] = useState(false); + const [profilesError, setProfilesError] = useState(null); + + useEffect(() => { + if (open) { + setProfilesError(null); + setProfileDraft([...(terminalProfiles?.profiles ?? [])]); + setNewProfileName(""); + setNewProfileCommand(""); + } + }, [open, terminalProfiles]); + + const saveProfiles = useCallback(async () => { + if (!terminalProfiles) return; + setProfilesBusy(true); + setProfilesError(null); + try { + const next = await window.ade.terminalProfiles.set({ + profiles: profileDraft, + defaultProfileId: terminalProfiles.defaultProfileId ?? "shell", + }); + onProfilesSaved(next); + onOpenChange(false); + } catch (err) { + setProfilesError(err instanceof Error ? err.message : String(err)); + } finally { + setProfilesBusy(false); + } + }, [terminalProfiles, profileDraft, onProfilesSaved, onOpenChange]); + + const addProfile = useCallback(() => { + const name = newProfileName.trim(); + const command = newProfileCommand.trim(); + if (!name || !command) { + setProfilesError("Name and command are required."); + return; + } + const existing = new Set(profileDraft.map((p) => p.id)); + const base = slugify(name) || "custom"; + const id = uniqueProfileId(base, existing); + setProfileDraft((prev) => [...prev, { id, name, command, tracked: true, description: null, color: null }]); + setNewProfileName(""); + setNewProfileCommand(""); + setProfilesError(null); + }, [newProfileName, newProfileCommand, profileDraft]); + + return ( + + + + +
+ Terminal Settings + + Configure launch profiles and whether new terminals collect context. + + + + +
+ +
+
+
Launch mode
+ +
+ If disabled, terminals still run normally but do not produce transcripts or pack updates. +
+
+ +
+
Terminal buttons
+
+ {profileDraft.length === 0 ? ( +
No profiles loaded.
+ ) : ( + profileDraft.map((p) => { + const locked = isDefaultProfile(p); + return ( +
+
+
{p.id}
+ + setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, name: e.target.value } : x))) + } + placeholder="Name" + /> + + setProfileDraft((prev) => prev.map((x) => (x.id === p.id ? { ...x, command: e.target.value } : x))) + } + placeholder="Command" + /> + +
+
+ Color + {PROFILE_COLORS.map((c) => ( + + ))} +
+
+ ); + }) + )} +
+ +
+
Add custom button
+
+ setNewProfileName(e.target.value)} + placeholder="Name (e.g., Dev Server)" + /> + setNewProfileCommand(e.target.value)} + placeholder="Command (e.g., npm run dev)" + /> + +
+
+ + {profilesError ? ( +
+ {profilesError} +
+ ) : null} + +
+ + +
+
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 1ad34759f..a1733eea5 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -1,708 +1,142 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import React, { useCallback, useMemo, useState } from "react"; import { - Brain as BrainCircuit, - CaretDown as ChevronDown, - Clipboard, - FileText, - Info, - ChatCircleDots as MessageSquarePlus, - Monitor, - Play, ArrowClockwise as RefreshCw, Stop as Square, Terminal, - X, } from "@phosphor-icons/react"; -import type { TerminalSessionSummary, TerminalSessionStatus } from "../../../shared/types"; -import { useAppStore } from "../../state/appStore"; -import { Button } from "../ui/Button"; -import { Chip } from "../ui/Chip"; import { PaneTilingLayout, type PaneConfig, type PaneSplit } from "../ui/PaneTilingLayout"; -import { TerminalView, getTerminalRuntimeHealth } from "./TerminalView"; -import { sanitizeTerminalInlineText, sessionIndicatorState } from "../../lib/terminalAttention"; -import { AgentChatPane } from "../chat/AgentChatPane"; -import { cn } from "../ui/cn"; +import { Button } from "../ui/Button"; +import { useWorkSessions } from "./useWorkSessions"; +import { SessionListPane } from "./SessionListPane"; +import { WorkViewArea } from "./WorkViewArea"; +import { SessionContextMenu, type SessionContextMenuState } from "./SessionContextMenu"; +import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; +import type { TerminalSessionSummary } from "../../../shared/types"; -/* ---- Layout ---- */ +/* ---- Layout (2-pane: sessions | view) ---- */ const TERMINALS_TILING_TREE: PaneSplit = { type: "split", direction: "horizontal", children: [ { node: { type: "pane", id: "sessions" }, defaultSize: 28, minSize: 15 }, - { - node: { - type: "split", - direction: "vertical", - children: [ - { node: { type: "pane", id: "terminal" }, defaultSize: 70, minSize: 30 }, - { node: { type: "pane", id: "details" }, defaultSize: 30, minSize: 10 }, - ], - }, - defaultSize: 72, - minSize: 40, - }, + { node: { type: "pane", id: "view" }, defaultSize: 72, minSize: 40 }, ], }; -/* ---- Helpers ---- */ - -function isChatToolType(toolType: string | null | undefined): boolean { - return toolType === "codex-chat" || toolType === "claude-chat"; -} - -function inferToolFromResumeCommand(command: string): "claude" | "codex" | null { - const n = command.trim().toLowerCase(); - if (n.startsWith("claude ")) return "claude"; - if (n.startsWith("codex ")) return "codex"; - return null; -} - -function runtimeStateLabel(state: TerminalSessionSummary["runtimeState"]): string { - if (state === "waiting-input") return "waiting input"; - return state; -} - -/** Tool-type → left-border accent color class */ -function toolBorderClass(toolType: string | null | undefined): string { - if (toolType === "claude" || toolType === "claude-chat") return "border-l-violet-500"; - if (toolType === "codex" || toolType === "codex-chat") return "border-l-sky-500"; - if (toolType === "shell") return "border-l-border/40"; - return "border-l-border/20"; -} - -/** Tool-type → badge color */ -function toolBadgeClass(toolType: string | null | undefined): string { - if (toolType === "claude" || toolType === "claude-chat") return "bg-violet-500/15 text-violet-400"; - if (toolType === "codex" || toolType === "codex-chat") return "bg-sky-500/15 text-sky-400"; - return "bg-muted/50 text-muted-fg"; -} - -function statusDot(session: TerminalSessionSummary): { cls: string; spinning: boolean; label: string } { - const ind = sessionIndicatorState({ - status: session.status, - lastOutputPreview: session.lastOutputPreview, - runtimeState: session.runtimeState, - }); - if (ind === "running-active") - return { cls: "border-2 border-emerald-500 border-t-transparent bg-transparent", spinning: true, label: "Running" }; - if (ind === "running-needs-attention") - return { cls: "border-2 border-amber-400 border-t-transparent bg-transparent", spinning: true, label: "Needs input" }; - if (ind === "failed") return { cls: "bg-red-500", spinning: false, label: "Failed" }; - if (ind === "disposed") return { cls: "bg-red-400/70", spinning: false, label: "Stopped" }; - return { cls: "bg-sky-500/70", spinning: false, label: "Completed" }; -} - -/* ---- Launch panel subcomponent ---- */ - -function LaunchPanel({ - lanes, - onLaunchPty, - onLaunchChat, -}: { - lanes: { id: string; name: string }[]; - onLaunchPty: (laneId: string, profile: "claude" | "codex" | "shell") => void; - onLaunchChat: (laneId: string, provider: "claude" | "codex") => void; -}) { - const [laneId, setLaneId] = useState(lanes[0]?.id ?? ""); - const [chatOpen, setChatOpen] = useState(false); - - useEffect(() => { - if (!laneId && lanes.length > 0) setLaneId(lanes[0]!.id); - }, [lanes, laneId]); - - return ( -
- {/* Lane selector */} -
- -
- - -
-
- - {/* Quick-launch row */} -
- - - - -
- - {/* Chat launch */} -
- - {chatOpen && ( -
- - -
- )} -
-
-
- ); -} - /* ---- Main component ---- */ export function TerminalsPage() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const lanes = useAppStore((s) => s.lanes); - const selectedLaneId = useAppStore((s) => s.selectedLaneId); - const focusSession = useAppStore((s) => s.focusSession); - const selectLane = useAppStore((s) => s.selectLane); - - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(false); - const [filterLaneId, setFilterLaneId] = useState("all"); - const [filterStatus, setFilterStatus] = useState("all"); - const [q, setQ] = useState(""); - const [closingPtyIds, setClosingPtyIds] = useState>(new Set()); - const [closingChatSessionId, setClosingChatSessionId] = useState(null); - const [resumingSessionId, setResumingSessionId] = useState(null); - const [selectedSessionId, setSelectedSessionId] = useState(null); - const [launchPanelOpen, setLaunchPanelOpen] = useState(false); - const launchPanelRef = useRef(null); - - const refresh = useCallback(async () => { - setLoading(true); - try { - const rows = await window.ade.sessions.list({ limit: 500 }); - setSessions(rows); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { refresh().catch(() => {}); }, []); - - useEffect(() => { - const laneParam = (searchParams.get("laneId") ?? searchParams.get("lane") ?? "").trim(); - if (laneParam && lanes.some((l) => l.id === laneParam)) setFilterLaneId(laneParam); - const statusParam = (searchParams.get("status") ?? "").trim(); - if (["running", "completed", "failed", "disposed", "all"].includes(statusParam)) { - setFilterStatus(statusParam as TerminalSessionStatus | "all"); - } - }, [searchParams, lanes]); - - useEffect(() => { - const unsubExit = window.ade.pty.onExit(() => { refresh().catch(() => {}); }); - const t = setInterval(() => { - if (sessions.some((s) => s.status === "running")) refresh().catch(() => {}); - }, 2000); - return () => { - try { unsubExit(); } catch { /* ignore */ } - clearInterval(t); - }; - }, [sessions, refresh]); - - useEffect(() => { - const unsubscribe = window.ade.agentChat.onEvent((payload) => { - const event = payload.event; - if (event.type === "done") refresh().catch(() => {}); - if (event.type === "status" && event.turnStatus !== "started") refresh().catch(() => {}); - }); - return unsubscribe; - }, [refresh]); - - // Close launch panel on outside click - useEffect(() => { - if (!launchPanelOpen) return; - const handler = (e: MouseEvent) => { - if (launchPanelRef.current && !launchPanelRef.current.contains(e.target as Node)) { - setLaunchPanelOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [launchPanelOpen]); - - const filtered = useMemo(() => { - const needle = q.trim().toLowerCase(); - return sessions.filter((s) => { - if (filterLaneId !== "all" && s.laneId !== filterLaneId) return false; - if (filterStatus !== "all" && s.status !== filterStatus) return false; - if (!needle) return true; - return ( - (s.goal ?? s.title).toLowerCase().includes(needle) || - s.laneName.toLowerCase().includes(needle) || - (s.toolType ?? "").toLowerCase().includes(needle) || - (s.lastOutputPreview ?? "").toLowerCase().includes(needle) || - (s.summary ?? "").toLowerCase().includes(needle) || - (s.resumeCommand ?? "").toLowerCase().includes(needle) - ); - }); - }, [sessions, filterLaneId, filterStatus, q]); - - const runningSessions = useMemo(() => sessions.filter((s) => s.status === "running" && Boolean(s.ptyId)), [sessions]); - const selectedSession = useMemo(() => selectedSessionId ? sessions.find((s) => s.id === selectedSessionId) ?? null : null, [sessions, selectedSessionId]); - const selectedIsChat = useMemo(() => isChatToolType(selectedSession?.toolType), [selectedSession]); - const selectedHealth = selectedSession ? getTerminalRuntimeHealth(selectedSession.id) : null; - - useEffect(() => { - if (!selectedSessionId && runningSessions.length > 0) setSelectedSessionId(runningSessions[0]!.id); - }, [selectedSessionId, runningSessions]); - - /* ---- Session actions ---- */ - - const markPtyClosed = (ptyId: string) => { - setSessions((prev) => - prev.map((s) => - s.ptyId === ptyId ? { ...s, ptyId: null, status: "disposed", runtimeState: "killed", endedAt: new Date().toISOString(), exitCode: null } : s - ) - ); - }; + const work = useWorkSessions(); - const closeSession = async (ptyId: string) => { - setClosingPtyIds((prev) => { const n = new Set(prev); n.add(ptyId); return n; }); - markPtyClosed(ptyId); - try { await window.ade.pty.dispose({ ptyId }); } finally { - setClosingPtyIds((prev) => { const n = new Set(prev); n.delete(ptyId); return n; }); - await refresh(); - } - }; + /* Floating overlays */ + const [contextMenu, setContextMenu] = useState(null); + const [infoPopover, setInfoPopover] = useState(null); - const closeRunning = async () => { - const ids = runningSessions.map((s) => s.ptyId).filter((id): id is string => Boolean(id)); - await Promise.allSettled(ids.map((id) => closeSession(id))); - }; - - const resumeSession = useCallback(async (session: TerminalSessionSummary) => { - if (isChatToolType(session.toolType)) { - if (resumingSessionId) return; - setResumingSessionId(session.id); - try { - await window.ade.agentChat.resume({ sessionId: session.id }); - selectLane(session.laneId); - focusSession(session.id); - setSelectedSessionId(session.id); - await refresh(); - } finally { setResumingSessionId(null); } - return; - } - const command = (session.resumeCommand ?? "").trim(); - if (!command || resumingSessionId) return; - setResumingSessionId(session.id); - try { - const toolType = session.toolType ?? inferToolFromResumeCommand(command) ?? null; - const started = await window.ade.pty.create({ laneId: session.laneId, cols: 100, rows: 30, title: session.goal?.trim() || session.title || "Terminal", tracked: session.tracked, toolType, startupCommand: command }); - selectLane(session.laneId); - focusSession(started.sessionId); - setSelectedSessionId(started.sessionId); - navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(started.sessionId)}`); - } finally { setResumingSessionId(null); } - }, [focusSession, navigate, refresh, resumingSessionId, selectLane]); - - const closeChatSession = useCallback(async (sessionId: string) => { - setClosingChatSessionId(sessionId); - try { await window.ade.agentChat.dispose({ sessionId }); await refresh(); } - finally { setClosingChatSessionId((c) => c === sessionId ? null : c); } - }, [refresh]); - - /* ---- Launch new sessions ---- */ - - const handleLaunchPty = useCallback(async (laneId: string, profile: "claude" | "codex" | "shell") => { - const toolTypeMap = { claude: "claude" as const, codex: "codex" as const, shell: "shell" as const }; - const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" }; - const commandMap = { claude: "claude", codex: "codex", shell: "" }; - const result = await window.ade.pty.create({ - laneId, - cols: 100, - rows: 30, - title: titleMap[profile], - tracked: true, - toolType: toolTypeMap[profile], - startupCommand: commandMap[profile] || undefined, - }); - selectLane(laneId); - focusSession(result.sessionId); - setSelectedSessionId(result.sessionId); - await refresh(); - navigate(`/lanes?laneId=${encodeURIComponent(laneId)}&sessionId=${encodeURIComponent(result.sessionId)}`); - }, [selectLane, focusSession, refresh, navigate]); - - const handleLaunchChat = useCallback(async (laneId: string, provider: "claude" | "codex") => { - const defaultModel = provider === "codex" ? "gpt-5.3-codex" : "sonnet"; - const session = await window.ade.agentChat.create({ laneId, provider, model: defaultModel }); - selectLane(laneId); - focusSession(session.id); - setSelectedSessionId(session.id); - await refresh(); - navigate(`/lanes?laneId=${encodeURIComponent(laneId)}&sessionId=${encodeURIComponent(session.id)}`); - }, [selectLane, focusSession, refresh, navigate]); - - /* ---- Session grouping ---- */ - - const runningFiltered = useMemo(() => filtered.filter((s) => s.status === "running"), [filtered]); - const endedFiltered = useMemo(() => filtered.filter((s) => s.status !== "running"), [filtered]); - - /* ---- Pane configs ---- */ - - const paneConfigs: Record = useMemo(() => ({ - sessions: { - title: "Sessions", - icon: Terminal, - meta: loading ? "loading" : `${filtered.length}`, - children: ( -
- {/* Launch panel */} - ({ id: l.id, name: l.name }))} - onLaunchPty={(laneId, profile) => { handleLaunchPty(laneId, profile).catch(() => {}); }} - onLaunchChat={(laneId, provider) => { handleLaunchChat(laneId, provider).catch(() => {}); }} - /> - - {/* Filters */} -
-
- - -
- setQ(e.target.value)} - /> -
- - {/* Session list */} -
- {filtered.length === 0 ? ( -
-
- -
-
No terminal sessions
-
- Start a new session to begin working. -
- -
- ) : ( -
- {/* Running group */} - {runningFiltered.length > 0 && ( -
-
- - - Running · {runningFiltered.length} - -
- {runningFiltered.map((s) => resumeSession(s).catch(() => {})} resumingSessionId={resumingSessionId} />)} -
- )} - - {/* Ended group */} - {endedFiltered.length > 0 && ( -
-
- - - Ended · {endedFiltered.length} - -
- {endedFiltered.map((s) => resumeSession(s).catch(() => {})} resumingSessionId={resumingSessionId} />)} -
- )} -
- )} -
-
- ), + const handleSelectSession = useCallback( + (id: string) => { + work.setSelectedSessionId(id); + work.openSessionTab(id); }, + [work], + ); - terminal: { - title: "Terminal", - icon: Monitor, - bodyClassName: "overflow-hidden", - meta: selectedSession - ? selectedIsChat ? "chat" : selectedSession.status === "running" ? "live" : selectedSession.status - : undefined, - children: ( -
- {selectedSession && selectedIsChat ? ( - - ) : runningSessions.length > 0 ? ( -
- {runningSessions.map((session) => - session.ptyId ? ( - - ) : null - )} - {selectedSession?.status === "running" && selectedSession.ptyId ? null : ( -
-
- Select a running session to interact. -
-
- )} -
- ) : ( -
-
- -
-
- {selectedSession ? "Session not running" : "No session selected"} -
-
- {selectedSession - ? "This session has ended. Select a running session to view its terminal." - : "Launch or select a session from the list."} -
-
- )} -
- ), + const handleInfoClick = useCallback( + (session: TerminalSessionSummary, e: React.MouseEvent) => { + setInfoPopover({ session, x: e.clientX, y: e.clientY }); }, + [], + ); - details: { - title: "Details", - icon: Info, - meta: selectedSession ? selectedSession.status : undefined, - children: ( -
- {selectedSession ? ( -
- {/* Metadata */} -
-
- - Session info -
-
- {[ - ["Title", (selectedSession.goal ?? selectedSession.title).trim()], - ["Lane", selectedSession.laneName], - ["Status", selectedSession.status], - ["Runtime", runtimeStateLabel(selectedSession.runtimeState)], - selectedSession.toolType ? ["Tool", selectedSession.toolType] : null, - selectedSession.exitCode != null ? ["Exit", `${selectedSession.exitCode}`] : null, - !selectedSession.tracked ? ["Context", "no context"] : null, - ["Started", new Date(selectedSession.startedAt).toLocaleTimeString()], - selectedSession.endedAt ? ["Ended", new Date(selectedSession.endedAt).toLocaleTimeString()] : null, - ].filter((row): row is [string, string] => row != null).map(([label, value]) => ( -
- {label} - {value} -
- ))} -
-
- - {/* Last output */} - {sanitizeTerminalInlineText(selectedSession.lastOutputPreview, 420) ? ( -
-
- - Last output -
-
-                    {sanitizeTerminalInlineText(selectedSession.lastOutputPreview, 420)}
-                  
-
- ) : null} + const handleContextMenu = useCallback( + (session: TerminalSessionSummary, e: React.MouseEvent) => { + setContextMenu({ session, x: e.clientX, y: e.clientY }); + }, + [], + ); - {/* Summary */} - {selectedSession.summary && selectedSession.status !== "running" ? ( -
-
- - Summary -
-

{selectedSession.summary}

-
- ) : null} + const handleGoToLane = useCallback( + (session: TerminalSessionSummary) => { + work.selectLane(session.laneId); + work.focusSession(session.id); + work.navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(session.id)}`); + }, + [work], + ); - {/* Resume command */} - {selectedSession.status !== "running" && selectedSession.resumeCommand ? ( -
-
- - Resume command -
- - {selectedSession.resumeCommand} - -
- ) : null} + /* ---- Pane configs ---- */ - {/* Terminal health */} - {selectedHealth ? ( -
-
- - Terminal health -
-
- fit_failures: {selectedHealth.fitFailures} - zero_dim: {selectedHealth.zeroDimFits} - renderer: {selectedHealth.rendererFallbacks} - dropped: {selectedHealth.droppedChunks} -
-
- ) : null} + const paneConfigs: Record = useMemo( + () => ({ + sessions: { + title: "Sessions", + icon: Terminal, + meta: work.loading ? "loading" : `${work.filtered.length}`, + children: ( + ({ id: l.id, name: l.name }))} + filtered={work.filtered} + runningFiltered={work.runningFiltered} + endedFiltered={work.endedFiltered} + loading={work.loading} + filterLaneId={work.filterLaneId} + setFilterLaneId={work.setFilterLaneId} + filterStatus={work.filterStatus} + setFilterStatus={work.setFilterStatus} + q={work.q} + setQ={work.setQ} + selectedSessionId={work.selectedSessionId} + onSelectSession={handleSelectSession} + onResume={(s) => work.resumeSession(s).catch(() => {})} + resumingSessionId={work.resumingSessionId} + onLaunchPty={(laneId, profile) => work.handleLaunchPty(laneId, profile).catch(() => {})} + onLaunchChat={(laneId, provider) => work.handleLaunchChat(laneId, provider).catch(() => {})} + onInfoClick={handleInfoClick} + onContextMenu={handleContextMenu} + /> + ), + }, - {/* Actions */} -
- {selectedSession.status === "running" && selectedSession.ptyId ? ( - - ) : null} - {selectedSession.status === "running" && selectedIsChat ? ( - - ) : null} - {selectedSession.status !== "running" && selectedSession.resumeCommand ? ( - <> - - - - ) : null} - -
-
- ) : ( -
-
- -
-
No session selected
-
- Click a session from the list to view details. -
-
- )} -
- ), - }, - }), [ - filtered, runningFiltered, endedFiltered, loading, filterLaneId, filterStatus, q, lanes, - selectedSessionId, selectedSession, selectedIsChat, selectedHealth, - closingPtyIds, closingChatSessionId, resumingSessionId, - selectLane, focusSession, navigate, closeSession, closeChatSession, resumeSession, - handleLaunchPty, handleLaunchChat, - ]); + view: { + title: "View", + icon: Terminal, + bodyClassName: "overflow-hidden", + children: ( + { + work.setActiveTabId(id); + work.setSelectedSessionId(id); + }} + onCloseTab={work.closeTab} + closingPtyIds={work.closingPtyIds} + onCloseSession={(id) => { + const session = work.sessions.find((s) => s.id === id); + if (session?.ptyId) work.closeSession(session.ptyId).catch(() => {}); + }} + /> + ), + }, + }), + [work, handleSelectSession, handleInfoClick, handleContextMenu], + ); return (
{/* Header */} -
+
Work - {runningSessions.length > 0 ? ( + {work.runningSessions.length > 0 ? ( - {runningSessions.length} running + {work.runningSessions.length} running ) : null}
@@ -711,8 +145,8 @@ export function TerminalsPage() { variant="outline" size="sm" className="h-7" - disabled={runningSessions.length === 0} - onClick={() => closeRunning().catch(() => {})} + disabled={work.runningSessions.length === 0} + onClick={() => work.closeAllRunning().catch(() => {})} > Close all @@ -721,7 +155,7 @@ export function TerminalsPage() { variant="ghost" size="sm" className="h-7 w-7 p-0" - onClick={() => refresh().catch(() => {})} + onClick={() => work.refresh().catch(() => {})} title="Refresh" > @@ -731,85 +165,35 @@ export function TerminalsPage() {
-
- ); -} -/* ---- Session row ---- */ - -function SessionRow({ - session, - isSelected, - onSelect, - onResume, - resumingSessionId, -}: { - session: TerminalSessionSummary; - isSelected: boolean; - onSelect: (id: string) => void; - onResume: () => void; - resumingSessionId: string | null; -}) { - const dot = statusDot(session); - const canResume = session.status !== "running" && Boolean(session.resumeCommand); + {/* Floating overlays */} + setContextMenu(null)} + onCloseSession={(ptyId) => work.closeSession(ptyId).catch(() => {})} + onEndChat={(id) => work.closeChatSession(id).catch(() => {})} + onResume={(s) => work.resumeSession(s).catch(() => {})} + onCopyResumeCommand={(cmd) => navigator.clipboard.writeText(cmd).catch(() => {})} + onGoToLane={handleGoToLane} + onCopySessionId={(id) => navigator.clipboard.writeText(id).catch(() => {})} + /> - return ( -
- - {/* Resume on hover */} - {canResume ? ( - - ) : null} + setInfoPopover(null)} + onCloseSession={(ptyId) => work.closeSession(ptyId).catch(() => {})} + onEndChat={(id) => work.closeChatSession(id).catch(() => {})} + onResume={(s) => work.resumeSession(s).catch(() => {})} + onGoToLane={handleGoToLane} + closingPtyIds={work.closingPtyIds} + closingChatSessionId={work.closingChatSessionId} + resumingSessionId={work.resumingSessionId} + />
); } diff --git a/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx b/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx new file mode 100644 index 000000000..eaaff8610 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/ToolLogos.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import type { TerminalToolType } from "../../../shared/types"; +import { cn } from "../ui/cn"; + +type LogoProps = { size?: number; className?: string }; + +export const ClaudeLogo: React.FC = ({ size = 16, className }) => ( + + + + + + + + + + + +); + +export const CodexLogo: React.FC = ({ size = 16, className }) => ( + + + +); + +export const ShellLogo: React.FC = ({ size = 16, className }) => ( + + + + +); + +const LOGO_MAP: Partial>> = { + claude: ClaudeLogo, + "claude-chat": ClaudeLogo, + "claude-orchestrated": ClaudeLogo, + codex: CodexLogo, + "codex-chat": CodexLogo, + "codex-orchestrated": CodexLogo, + shell: ShellLogo, +}; + +export function ToolLogo({ + toolType, + size = 16, + className, +}: { + toolType: TerminalToolType | null | undefined; + size?: number; + className?: string; +}) { + const Logo = toolType ? LOGO_MAP[toolType] : undefined; + if (Logo) return ; + return ; +} diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx new file mode 100644 index 000000000..ce665cc52 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -0,0 +1,207 @@ +import React, { useMemo } from "react"; +import { GridFour, List, Monitor, X } from "@phosphor-icons/react"; +import type { TerminalSessionSummary } from "../../../shared/types"; +import { TerminalView } from "./TerminalView"; +import { ToolLogo } from "./ToolLogos"; +import { TilingLayout } from "../lanes/TilingLayout"; +import { AgentChatPane } from "../chat/AgentChatPane"; +import { cn } from "../ui/cn"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +function statusDotClass(session: TerminalSessionSummary): string { + if (session.status === "running") return "bg-emerald-500"; + if (session.status === "failed") return "bg-red-500"; + if (session.status === "disposed") return "bg-red-400/70"; + return "bg-sky-500/70"; +} + +function truncateTabLabel(text: string, max = 20): string { + if (text.length <= max) return text; + return text.slice(0, max - 1) + "..."; +} + +export function WorkViewArea({ + sessions, + runningSessions, + openTabIds, + activeTabId, + viewMode, + setViewMode, + onSelectTab, + onCloseTab, + closingPtyIds, + onCloseSession, +}: { + sessions: TerminalSessionSummary[]; + runningSessions: TerminalSessionSummary[]; + openTabIds: string[]; + activeTabId: string | null; + viewMode: "tabs" | "grid"; + setViewMode: (mode: "tabs" | "grid") => void; + onSelectTab: (sessionId: string) => void; + onCloseTab: (sessionId: string) => void; + closingPtyIds: Set; + onCloseSession: (id: string) => void; +}) { + const sessionsById = useMemo(() => { + const map = new Map(); + for (const s of sessions) map.set(s.id, s); + return map; + }, [sessions]); + + const tabSessions = useMemo( + () => openTabIds.map((id) => sessionsById.get(id)).filter((s): s is TerminalSessionSummary => s != null), + [openTabIds, sessionsById], + ); + + const activeSession = activeTabId ? sessionsById.get(activeTabId) ?? null : null; + const activeIsChat = isChatToolType(activeSession?.toolType); + + if (viewMode === "grid") { + return ( +
+ {/* Mode toggle header */} +
+ + {runningSessions.length} running +
+
+ +
+
+ ); + } + + // Tab mode + return ( +
+ {/* Tab bar */} +
+ +
+
+ {tabSessions.map((s) => ( + + ))} +
+
+ + {/* Content area */} +
+ {activeSession && activeIsChat ? ( + + ) : runningSessions.length > 0 ? ( +
+ {runningSessions.map((session) => + session.ptyId ? ( + + ) : null, + )} + {activeSession?.status === "running" && activeSession.ptyId ? null : ( +
+
+ Select a running session to interact. +
+
+ )} +
+ ) : tabSessions.length === 0 ? ( +
+
+ +
+
No session selected
+
+ Click a session from the list to open it here. +
+
+ ) : ( +
+
+ +
+
Session not running
+
+ This session has ended. Select a running session to view its terminal. +
+
+ )} +
+
+ ); +} + +function ViewModeToggle({ + viewMode, + setViewMode, +}: { + viewMode: "tabs" | "grid"; + setViewMode: (mode: "tabs" | "grid") => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/components/terminals/useSessionDelta.ts b/apps/desktop/src/renderer/components/terminals/useSessionDelta.ts new file mode 100644 index 000000000..7b8aa6e99 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/useSessionDelta.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import type { SessionDeltaSummary } from "../../../shared/types"; + +const deltaCache = new Map(); + +export function useSessionDelta(sessionId: string | null, enabled: boolean) { + const [delta, setDelta] = useState( + sessionId ? deltaCache.get(sessionId) ?? null : null, + ); + + useEffect(() => { + if (!sessionId || !enabled) { + setDelta(null); + return; + } + + const cached = deltaCache.get(sessionId); + if (cached) { + setDelta(cached); + return; + } + + let cancelled = false; + window.ade.sessions + .getDelta(sessionId) + .then((result) => { + if (cancelled) return; + deltaCache.set(sessionId, result); + setDelta(result); + }) + .catch(() => { + // ignore - delta not available + }); + + return () => { + cancelled = true; + }; + }, [sessionId, enabled]); + + return delta; +} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts new file mode 100644 index 000000000..41c580625 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -0,0 +1,349 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import type { TerminalSessionSummary, TerminalSessionStatus } from "../../../shared/types"; +import { useAppStore } from "../../state/appStore"; + +function isChatToolType(toolType: string | null | undefined): boolean { + return toolType === "codex-chat" || toolType === "claude-chat"; +} + +function inferToolFromResumeCommand(command: string): "claude" | "codex" | null { + const n = command.trim().toLowerCase(); + if (n.startsWith("claude ")) return "claude"; + if (n.startsWith("codex ")) return "codex"; + return null; +} + +export function useWorkSessions() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const lanes = useAppStore((s) => s.lanes); + const selectedLaneId = useAppStore((s) => s.selectedLaneId); + const focusSession = useAppStore((s) => s.focusSession); + const selectLane = useAppStore((s) => s.selectLane); + + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [filterLaneId, setFilterLaneId] = useState("all"); + const [filterStatus, setFilterStatus] = useState("all"); + const [q, setQ] = useState(""); + const [closingPtyIds, setClosingPtyIds] = useState>(new Set()); + const [closingChatSessionId, setClosingChatSessionId] = useState(null); + const [resumingSessionId, setResumingSessionId] = useState(null); + const [selectedSessionId, setSelectedSessionId] = useState(null); + + /* ---- Tabs state ---- */ + const [openTabIds, setOpenTabIds] = useState([]); + const [activeTabId, setActiveTabId] = useState(null); + const [viewMode, setViewMode] = useState<"tabs" | "grid">("tabs"); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const rows = await window.ade.sessions.list({ limit: 500 }); + setSessions(rows); + } finally { + setLoading(false); + } + }, []); + + // Initial fetch + useEffect(() => { + refresh().catch(() => {}); + }, []); + + // URL params + useEffect(() => { + const laneParam = (searchParams.get("laneId") ?? searchParams.get("lane") ?? "").trim(); + if (laneParam && lanes.some((l) => l.id === laneParam)) setFilterLaneId(laneParam); + const statusParam = (searchParams.get("status") ?? "").trim(); + if (["running", "completed", "failed", "disposed", "all"].includes(statusParam)) { + setFilterStatus(statusParam as TerminalSessionStatus | "all"); + } + }, [searchParams, lanes]); + + // Event subscriptions + useEffect(() => { + const unsubExit = window.ade.pty.onExit(() => { + refresh().catch(() => {}); + }); + const t = setInterval(() => { + if (sessions.some((s) => s.status === "running")) refresh().catch(() => {}); + }, 2000); + return () => { + try { unsubExit(); } catch { /* ignore */ } + clearInterval(t); + }; + }, [sessions, refresh]); + + useEffect(() => { + const unsubscribe = window.ade.agentChat.onEvent((payload) => { + const event = payload.event; + if (event.type === "done") refresh().catch(() => {}); + if (event.type === "status" && event.turnStatus !== "started") refresh().catch(() => {}); + }); + return unsubscribe; + }, [refresh]); + + // Enhanced filtering with prefix search + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + return sessions.filter((s) => { + if (filterLaneId !== "all" && s.laneId !== filterLaneId) return false; + if (filterStatus !== "all" && s.status !== filterStatus) return false; + if (!needle) return true; + + // Prefix search: lane:, type:, tracked: + if (needle.startsWith("lane:")) { + const val = needle.slice(5).trim(); + return s.laneName.toLowerCase().includes(val); + } + if (needle.startsWith("type:")) { + const val = needle.slice(5).trim(); + return (s.toolType ?? "").toLowerCase().includes(val); + } + if (needle.startsWith("tracked:")) { + const val = needle.slice(8).trim(); + if (val === "yes" || val === "true") return s.tracked; + if (val === "no" || val === "false") return !s.tracked; + return true; + } + + return ( + (s.goal ?? s.title).toLowerCase().includes(needle) || + s.laneName.toLowerCase().includes(needle) || + (s.toolType ?? "").toLowerCase().includes(needle) || + (s.lastOutputPreview ?? "").toLowerCase().includes(needle) || + (s.summary ?? "").toLowerCase().includes(needle) || + (s.resumeCommand ?? "").toLowerCase().includes(needle) + ); + }); + }, [sessions, filterLaneId, filterStatus, q]); + + const runningSessions = useMemo( + () => sessions.filter((s) => s.status === "running" && Boolean(s.ptyId)), + [sessions], + ); + + const selectedSession = useMemo( + () => (selectedSessionId ? sessions.find((s) => s.id === selectedSessionId) ?? null : null), + [sessions, selectedSessionId], + ); + + const runningFiltered = useMemo(() => filtered.filter((s) => s.status === "running"), [filtered]); + const endedFiltered = useMemo(() => filtered.filter((s) => s.status !== "running"), [filtered]); + + // Auto-select first running session + useEffect(() => { + if (!selectedSessionId && runningSessions.length > 0) setSelectedSessionId(runningSessions[0]!.id); + }, [selectedSessionId, runningSessions]); + + /* ---- Tab management ---- */ + const openSessionTab = useCallback( + (sessionId: string) => { + setOpenTabIds((prev) => (prev.includes(sessionId) ? prev : [...prev, sessionId])); + setActiveTabId(sessionId); + setSelectedSessionId(sessionId); + }, + [], + ); + + const closeTab = useCallback( + (sessionId: string) => { + setOpenTabIds((prev) => { + const next = prev.filter((id) => id !== sessionId); + if (activeTabId === sessionId) { + const idx = prev.indexOf(sessionId); + const newActive = next[Math.min(idx, next.length - 1)] ?? null; + setActiveTabId(newActive); + setSelectedSessionId(newActive); + } + return next; + }); + }, + [activeTabId], + ); + + /* ---- Session actions ---- */ + + const markPtyClosed = (ptyId: string) => { + setSessions((prev) => + prev.map((s) => + s.ptyId === ptyId + ? { ...s, ptyId: null, status: "disposed" as const, runtimeState: "killed" as const, endedAt: new Date().toISOString(), exitCode: null } + : s, + ), + ); + }; + + const closeSession = useCallback( + async (ptyId: string) => { + setClosingPtyIds((prev) => { + const n = new Set(prev); + n.add(ptyId); + return n; + }); + markPtyClosed(ptyId); + try { + await window.ade.pty.dispose({ ptyId }); + } finally { + setClosingPtyIds((prev) => { + const n = new Set(prev); + n.delete(ptyId); + return n; + }); + await refresh(); + } + }, + [refresh], + ); + + const closeAllRunning = useCallback(async () => { + const ids = runningSessions.map((s) => s.ptyId).filter((id): id is string => Boolean(id)); + await Promise.allSettled(ids.map((id) => closeSession(id))); + }, [runningSessions, closeSession]); + + const resumeSession = useCallback( + async (session: TerminalSessionSummary) => { + if (isChatToolType(session.toolType)) { + if (resumingSessionId) return; + setResumingSessionId(session.id); + try { + await window.ade.agentChat.resume({ sessionId: session.id }); + selectLane(session.laneId); + focusSession(session.id); + setSelectedSessionId(session.id); + await refresh(); + } finally { + setResumingSessionId(null); + } + return; + } + const command = (session.resumeCommand ?? "").trim(); + if (!command || resumingSessionId) return; + setResumingSessionId(session.id); + try { + const toolType = session.toolType ?? inferToolFromResumeCommand(command) ?? null; + const started = await window.ade.pty.create({ + laneId: session.laneId, + cols: 100, + rows: 30, + title: session.goal?.trim() || session.title || "Terminal", + tracked: session.tracked, + toolType, + startupCommand: command, + }); + selectLane(session.laneId); + focusSession(started.sessionId); + setSelectedSessionId(started.sessionId); + navigate(`/lanes?laneId=${encodeURIComponent(session.laneId)}&sessionId=${encodeURIComponent(started.sessionId)}`); + } finally { + setResumingSessionId(null); + } + }, + [focusSession, navigate, refresh, resumingSessionId, selectLane], + ); + + const closeChatSession = useCallback( + async (sessionId: string) => { + setClosingChatSessionId(sessionId); + try { + await window.ade.agentChat.dispose({ sessionId }); + await refresh(); + } finally { + setClosingChatSessionId((c) => (c === sessionId ? null : c)); + } + }, + [refresh], + ); + + /* ---- Launch new sessions ---- */ + + const handleLaunchPty = useCallback( + async (laneId: string, profile: "claude" | "codex" | "shell") => { + const toolTypeMap = { claude: "claude" as const, codex: "codex" as const, shell: "shell" as const }; + const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" }; + const commandMap = { claude: "claude", codex: "codex", shell: "" }; + const result = await window.ade.pty.create({ + laneId, + cols: 100, + rows: 30, + title: titleMap[profile], + tracked: true, + toolType: toolTypeMap[profile], + startupCommand: commandMap[profile] || undefined, + }); + selectLane(laneId); + focusSession(result.sessionId); + setSelectedSessionId(result.sessionId); + openSessionTab(result.sessionId); + await refresh(); + }, + [selectLane, focusSession, refresh, openSessionTab], + ); + + const handleLaunchChat = useCallback( + async (laneId: string, provider: "claude" | "codex") => { + const defaultModel = provider === "codex" ? "gpt-5.3-codex" : "sonnet"; + const session = await window.ade.agentChat.create({ laneId, provider, model: defaultModel }); + selectLane(laneId); + focusSession(session.id); + setSelectedSessionId(session.id); + openSessionTab(session.id); + await refresh(); + }, + [selectLane, focusSession, refresh, openSessionTab], + ); + + return { + // Data + sessions, + lanes, + filtered, + runningFiltered, + endedFiltered, + runningSessions, + selectedSession, + loading, + + // Filters + filterLaneId, + setFilterLaneId, + filterStatus, + setFilterStatus, + q, + setQ, + + // Selection + selectedSessionId, + setSelectedSessionId, + + // Tabs + openTabIds, + activeTabId, + viewMode, + setViewMode, + openSessionTab, + closeTab, + setActiveTabId, + + // In-flight state + closingPtyIds, + closingChatSessionId, + resumingSessionId, + + // Actions + refresh, + closeSession, + closeAllRunning, + resumeSession, + closeChatSession, + handleLaunchPty, + handleLaunchChat, + + // Navigation helpers + navigate, + selectLane, + focusSession, + }; +} diff --git a/assets/claude.svg b/assets/claude.svg new file mode 100644 index 000000000..9ae00d8b2 --- /dev/null +++ b/assets/claude.svg @@ -0,0 +1 @@ + diff --git a/assets/codex.svg b/assets/codex.svg new file mode 100644 index 000000000..3b4eff961 --- /dev/null +++ b/assets/codex.svg @@ -0,0 +1,2 @@ + +OpenAI icon \ No newline at end of file diff --git a/assets/terminal.svg b/assets/terminal.svg new file mode 100644 index 000000000..51a4cc444 --- /dev/null +++ b/assets/terminal.svg @@ -0,0 +1 @@ + \ No newline at end of file