From 736e9bae4635b6d972844539f8e218592e4e60ca Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Thu, 16 Jul 2026 15:48:24 +0530 Subject: [PATCH] feat(terminal): distraction-free mode + "Pop out" a terminal to its own tab (v0.226.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new affordances in the individual terminal's top-right toolbar: - Focus (⤢): lifts the pane to a full-viewport overlay (fixed inset-0, above the sidebar + tab strip) so nothing but the terminal is on screen. Esc or the Exit button restores the console; the pane refits via its ResizeObserver. - Pop out (↗): opens the same pane on its own at a chrome-less route #/term/ in a new browser tab — rendered outside the Console shell (slim title bar + "← Console" link, then just the terminal), reusing and the login cookie so it's a real anchor (⌘/middle-click works). The standalone view fetches the session row too, so an ended run still shows its transcript and file-attach still works. Mirrors #/view/. Verified: authed headless-browser load of #/term/ renders the standalone chrome + terminal frame; governance conformance 68/68; web + server builds green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019zMSbnPEbqSVPGLasTKENp --- CHANGELOG.md | 12 ++++++- package-lock.json | 4 +-- package.json | 2 +- web/src/App.tsx | 85 +++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 93 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f5962a..c071d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,17 @@ new version heading in the same commit. ## [Unreleased] -## [0.225.1] — 2026-07-16 +## [0.226.0] — 2026-07-16 +### Added +- **Distraction-free terminal + "Pop out" to its own tab.** The individual terminal view gets two new + affordances in its top-right toolbar. **Focus** (⤢) lifts the pane to a full-viewport overlay (`fixed + inset-0`, above the sidebar + tab strip) so nothing but the terminal is on screen — **Esc** or the + **Exit** button returns to the console; the pane refits automatically (its `ResizeObserver`). **Pop out** + (↗) opens the same pane on its own at a new chrome-less route `#/term/` in a fresh browser tab — + rendered OUTSIDE the console shell (a slim title bar + "← Console" link, then just the terminal), reusing + the same `` and the login cookie, so it's a real anchor (⌘/middle-click works). The + standalone view fetches the session row too, so an ended run still shows its transcript and file-attach + still works. Mirrors the existing chrome-less artifact viewer (`#/view/`). ### Fixed - **Operations → Activity now works in the terminal-tabs view.** The v0.224.0 Activity shortcut set the panel state via `setInspect`, but `SessionsPage` early-returns the terminal-tabs view before the diff --git a/package-lock.json b/package-lock.json index 8584c48..0a4c070 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.225.1", + "version": "0.226.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.225.1", + "version": "0.226.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 4ed3e0d..654860d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.225.1", + "version": "0.226.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/web/src/App.tsx b/web/src/App.tsx index 6a82179..7ecebb8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ReactNode, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react' -import { Inbox as InboxIcon, TerminalSquare, Play, Plus, Check, X, Square, Rocket, Plug, Trash2, Users, User, LogOut, Copy, Zap, Brain, Building2, ChevronDown, SlidersHorizontal, Pencil, FileText, HelpCircle, CheckCircle2, XCircle, Clock, Send, LayoutGrid, List, ArrowLeft, Bot, FolderTree, Folder, File as FileIcon, FileCode, Save, ChevronRight, Sparkles, Package, Image as ImageIcon, Film, Download, Search, BookText, BookOpen, History as HistoryIcon, ScrollText, Bell, AlertTriangle, Activity, Lightbulb, Moon, Upload, FolderPlus, ListChecks, PanelLeftClose, PanelLeftOpen, RefreshCw, ThumbsUp, ThumbsDown, Target, ExternalLink, Paperclip, KeyRound, Blocks, FilePlus } from 'lucide-react' +import { Inbox as InboxIcon, TerminalSquare, Play, Plus, Check, X, Square, Rocket, Plug, Trash2, Users, User, LogOut, Copy, Zap, Brain, Building2, ChevronDown, SlidersHorizontal, Pencil, FileText, HelpCircle, CheckCircle2, XCircle, Clock, Send, LayoutGrid, List, ArrowLeft, Bot, FolderTree, Folder, File as FileIcon, FileCode, Save, ChevronRight, Sparkles, Package, Image as ImageIcon, Film, Download, Search, BookText, BookOpen, History as HistoryIcon, ScrollText, Bell, AlertTriangle, Activity, Lightbulb, Moon, Upload, FolderPlus, ListChecks, PanelLeftClose, PanelLeftOpen, RefreshCw, ThumbsUp, ThumbsDown, Target, ExternalLink, Paperclip, KeyRound, Blocks, FilePlus, Maximize2, Minimize2 } from 'lucide-react' import { Wrench, Code2, Bug, MessageSquare, Mail, Megaphone, PenTool, Database, Server, Cloud, Shield, Calendar, LineChart, BarChart3, DollarSign, ShoppingCart, Headphones, Cog, Compass, Flag, Heart, Star, Globe, GitBranch, Palette, Camera, Music, Feather, Wand2, Boxes, Terminal, Webhook, CalendarClock, Hash, Cpu, MoreHorizontal, Power, PowerOff, Pin, PinOff, type LucideIcon } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -598,10 +598,14 @@ export default function App() { return () => window.removeEventListener('hashchange', on) }, []) const viewArtifactId = hash.match(/^#\/view\/([\w-]+)/)?.[1] + // Chrome-less full-window single terminal at `#/term/` — the "Pop out" affordance opens a pane + // on its own in a new browser tab (distraction-free, no console shell). Same auth cookie, so it just works. + const termTmux = hash.match(/^#\/term\/([^?]+)/)?.[1] if (me === undefined) return
Loading…
if (me === null) return if (viewArtifactId) return + if (termTmux) return return } @@ -2007,8 +2011,22 @@ function QuickShortcuts({ session, attachable }: { session: Session; attachable: /** The live terminal pane. It asks the server for the attach URL — the shared /terminal/?arg=… * (uid-isolation off) or a per-member /terminal//?arg=… (on) — and derives the ttyd WebSocket * endpoint from it, which our first-party speaks directly (no iframe). */ -function TerminalFrame({ session, tmux, onActivity, ops }: { session?: Session; tmux: string; onActivity?: (sid: string) => void; ops?: SessionOps }) { +function TerminalFrame({ session, tmux, onActivity, ops, standalone }: { session?: Session; tmux: string; onActivity?: (sid: string) => void; ops?: SessionOps; standalone?: boolean }) { const [wsUrl, setWsUrl] = useState('') + // Distraction-free mode: lift the terminal out of the console layout to cover the whole viewport + // (fixed inset-0), hiding the sidebar + tab strip so nothing but the pane is on screen. Esc exits. + // Never engaged in the `standalone` chrome-less popout (`#/term/`) — that view is already bare. + const [zen, setZen] = useState(false) + useEffect(() => { + if (!zen) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setZen(false) } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [zen]) + // A viewed-session change tears the whole frame down (keyed on tmux), but reset defensively anyway. + useEffect(() => { setZen(false) }, [session?.id]) + // Chrome-less popout of THIS pane in a fresh browser tab — a real anchor so ⌘/middle-click work too. + const popoutHref = session?.tmux || (!session && tmux) ? '#/term/' + encodeDetail(session?.tmux || tmux) : undefined const [err, setErr] = useState('') const [transcript, setTranscript] = useState(null) // "Take over" state: claiming an unattended run doesn't relaunch it (the pane is already a live TUI) — @@ -2084,7 +2102,7 @@ function TerminalFrame({ session, tmux, onActivity, ops }: { session?: Session; if (err) return
⚠ {err}
if (!wsUrl) return
opening terminal…
return ( -
+
{showTakeover && (
@@ -2107,13 +2127,18 @@ function TerminalFrame({ session, tmux, onActivity, ops }: { session?: Session; * (file paste). Each uploads the bytes; the server saves them in the agent's folder and types the path * into the running claude. Now that the terminal is same-document (no iframe), drops/pastes over it * bubble to our window handlers directly — no cross-document interception needed. */ -function ImageDropZone({ session, attachable, children, onActivity, fontSize, setFontSize, ops, onReload }: { +function ImageDropZone({ session, attachable, children, onActivity, fontSize, setFontSize, ops, onReload, zen, onToggleZen, popoutHref }: { session?: Session; attachable?: boolean; children: ReactNode; onActivity?: () => void fontSize: number; setFontSize: (f: number | ((s: number) => number)) => void /** Lifecycle callbacks for the top-right "Operations" menu (absent → the menu is hidden). */ ops?: SessionOps /** Reload the agent process in place; resolves with the API result so we can toast it. */ onReload?: () => Promise<{ ok: boolean; error?: string }> + /** Distraction-free (full-viewport) state + toggle. Toggle absent → the button is hidden. */ + zen?: boolean + onToggleZen?: () => void + /** Href to pop this pane out into a chrome-less browser tab (`#/term/`); absent → hidden. */ + popoutHref?: string }) { const [drag, setDrag] = useState(false) const [help, setHelp] = useState(false) @@ -2225,8 +2250,27 @@ function ImageDropZone({ session, attachable, children, onActivity, fontSize, se {session?.id && }
setHelp(false)} /> - {/* top-right session toolbar: browse the agent's folder + attach a file */} + {/* top-right session toolbar: distraction-free + popout, browse the agent's folder + attach a file */}
+ {popoutHref && ( + + Pop out + + )} + {onToggleZen && ( + + )} {session?.agent && ( ` — the "Pop out" target. Rendered OUTSIDE + * the Console shell (no sidebar/nav/tab strip), so a pane gets a whole browser tab to itself. Reuses the + * same (with `standalone` so it drops its own popout/focus buttons), attaching by tmux; + * it also fetches the session row so lifecycle bits (transcript for an ended run, file attach) light up. */ +function FullTerminalView({ tmux }: { tmux: string }) { + const [session, setSession] = useState(undefined) // undefined = loading + useEffect(() => { + let live = true + api.sessions() + .then((list) => { if (!live) return; const hit = list.find((s) => s.tmux === tmux) ?? null; setSession(hit); document.title = (hit?.title || tmux) + ' · terminal' }) + .catch(() => { if (live) setSession(null) }) + return () => { live = false } + }, [tmux]) + if (session === undefined) return
opening terminal…
+ return ( +
+ ) +} + /** A small live-status dot + label for a hosted app's supervisor state. */ function AppStatusBadge({ s }: { s: AppInfo['status'] }) { const map: Record = {