diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 52a3579f..1f22c1e8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,3 +9,30 @@ The root MIT license does not replace these materials' separate licenses or copyright notices. + +## Desktop for Step Code + +The Web transcript's Simple process grouping and disclosure presentation are +adapted from Desktop for Step Code. + +MIT License + +Copyright (c) 2026 Desktop for Step Code contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 4539f23e..3770e82d 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -25,9 +25,9 @@ A change to the client is a change to the desktop app. Check both hosts when it | Markdown, highlighting, i18n | In-house (`lib/markdown.ts`, `features/files/highlight.ts`, `i18n/`) | No libraries | | Types | TypeScript strict, `erasableSyntaxOnly`, `verbatimModuleSyntax` | Type-checked by `tsgo` (both tsconfigs) | | Lint / format | Biome (root `biome.json`) | Tabs, indent width 3, line width 120, double quotes, semicolons | -| Tests | `node:test` + `tsx` (unit), headless Chrome over CDP (e2e) | No Jest, Vitest, Playwright | +| Tests | `node:test` + `tsx` (unit), headless Chrome over CDP (e2e) | No Jest, Vitest or Playwright Test runner; the browser adapter itself uses playwright-core | -The runtime dependencies are `react`, `react-dom` and the workspace `@step-harness/coding-agent`. Adding any dependency +The runtime dependencies are `react`, `react-dom`, `playwright-core`, `node-pty`, `@xterm/xterm`, `@xterm/addon-fit` and the workspace `@step-harness/coding-agent`. Adding any dependency needs the user's approval first; pin it to an exact version and install with `pnpm install --ignore-scripts`. ## Directory layout @@ -47,6 +47,14 @@ apps/web/ workspaces.ts workspace registry (newest first) files.ts workspace file tree / read / search auth.ts, login.ts cookie secret, launch-token exchange, credential status, desktop sign-in + browser.ts isolated Chromium ownership, leased controller, bounded frames and input + browser-routes.ts, browser-policy.ts authenticated browser HTTP/SSE adapter and input/URL policy + browser-proxy.ts private authenticated network boundary for redirect-safe port blocking + terminal.ts bounded host-owned terminal sessions and process cleanup + terminal-process.ts isolated node-pty helper; native failures must not crash the Host + terminal-cleanup.ts owned process discovery: POSIX session/group/tty, Windows identity-validated descendants + terminal-native.ts node-pty native build probe and macOS spawn-helper exec-bit repair + terminal-routes.ts, terminal-policy.ts authenticated HTTP/SSE and shell/environment/input policy directory-picker.ts server-side folder chooser settings.ts settings back end: MCP servers, plugins/marketplaces, skills attachments.ts, feedback.ts, banner.ts, bootstrap.ts, writer-lease.ts @@ -152,7 +160,7 @@ keep only the classes passed in. Read the component's doc comment before using i | `Kbd` | Keyboard shortcut hints | | | `DiffStat` | `+12 -3` line counts (`additions`, `deletions`) | | | `MarkdownView` | Rendering `lib/markdown` output (`html`), with code-block copy buttons (`onCopyCode`) | Only HTML from `renderMarkdown`; never other HTML | -| `ResizeHandle` | Draggable / keyboard splitter between panels | Holds no size: `getSize`, `onResize` (live), `onCommit` (persist), `onReset` | +| `ResizeHandle` | Draggable / keyboard splitter between panels | Holds no size: `getSize`, `onResize` (live), `onCommit` (persist), `onReset`; `orientation="horizontal"` for stacked panels | | `useDismiss` | Outside-press and Escape dismissal for a mounted overlay | Include the trigger in `insideRefs` | | `useMergedRef` | One ref callback feeding two refs (own + forwarded) | | @@ -172,6 +180,80 @@ When to add or change one: and add a unit test. - Anything a reloaded page must show again belongs in the session snapshot (e.g. `pendingApprovals`), not only in an SSE event. +- Thinking elapsed time is host-observed presentation metadata (`src/thinking-timing.ts`), persisted through + the session manager's `stepcode-web.thinking-timing` custom entries outside model context. Snapshots and agent + envelopes project validated `thinkingTimings` by original content index. Never infer durations for unobserved + history or start clocks from browser receipt times. +- The main transcript uses fixed DFSC Simple process groups, not a complexity + setting. Group consecutive thinking and ordinary tools only; singleton groups + show the member's own disclosure without a group heading or indentation. + Retain keyed member components and a manually opened singleton when siblings + arrive. Keep failed tools, approvals, retries and dedicated lifecycle calls + separate. Preserve original + timeline indices for turn footers, file deliveries and search reveal. Group + completion counts tool results, never canonical Tasks or child-agent success. +- Subagent rows project structured per-task results and use the existing read-only child transcript API. + Planned arguments mean queued, not running; missing legacy statuses stay unknown. Background custom messages + expose only validated agentId/event/status fields through `src/subagent-notification.ts`. Do not parse + notification prose, infer per-task success from the parent tool, or add child execution controls in the inspector. +- `features/inspector` owns the shared right rail, Summary and Context views. + Summary uses DFSC's independent content-height floating board with a close-only + header, fixed compact width and an optional reading-space reservation; do not + wrap it in the full-height inspector chrome used by the other panels. + Summary's DFSC colors are scoped to that surface. Keep grid disclosures, + fixed-slot completion motion and reduced-motion support; never replay a + live completion animation for initially loaded completed history. + Its MCP switch-shaped indicators are decorative, not configuration controls. + Latest-turn task/subagent activity can reveal Summary once per selection/turn; + respect manual dismissal and never displace an already active inspector. + Its task progress uses canonical completed/total tasks, not tool-call counts. + Agent rows below the task list reuse validated timeline statuses and existing read-only + inspector actions; count records, never infer distinct live agents or success. + The header repeats canonical task completion and agent-record counts; its + activity dot uses explicit running states and preserves unknown history. + File and Subagent entry points select the shared panel through `state/right-panel.ts`. + The read-only `/sessions/{id}/inspector` route projects canonical `step-tasks` + snapshots and SDK statistics; task-tool completion is not task completion. + MCP status is process-scoped, unlike the session's skill count. Do not label + recorded messages as assembled model context or turn patches as Git net changes. + Conversation navigation is excluded. Terminal uses its separate Web adapter. + `features/terminal` renders xterm in `#terminalDock`, a resizable bottom dock + under the conversation toggled from the rail (`data-terminal-toggle`) or + Ctrl+`; it is independent of the exclusive inspector. Keys typed in the shell + stay there (Escape, Ctrl chords). Only opening an empty dock or explicit tab + creation starts a shell; ending the last tab hides the dock. Hiding/switching tabs + disconnects streams without killing processes; closing a tab awaits owned + process-tree cleanup. Refresh replays bounded sequenced output, never creates + replacement shells. Shell exit retains helper ownership until Host cleanup; + Windows descendant discovery checks creation times, not just reusable PIDs. + Startup cancellation waits for a shell identity or startup failure before + terminating the helper. Retention gaps must be visible; do not claim full-screen + restoration from a truncated ANSI tail. Keep input/IPC/SSE queues bounded, + disable OSC 52 and automatic links, and do not inherit Host API keys or + startup-injection environment variables. Never load sibling-project binaries + as runtime dependencies or silently install/compile native modules; the only + repair is restoring the exec bit of node-pty's published macOS spawn-helper. + POSIX cleanup kills the shell's session, process group and controlling-tty + members, never the Host or helper. + `features/browser` uses the authenticated host-owned Chromium adapter in + `src/browser.ts` / `browser-routes.ts`, not a Desktop bridge or iframe. + Hiding releases its stream/controller but keeps tabs. Refresh rotates a + leased controller token; late cleanup must not release its replacement. + Keep frame/input queues bounded and block host/client ports for subresources + and WebSockets too. The browser-owned authenticated loopback proxy checks + connection destinations across redirects and tunnels; keep loopback bypass + disabled, strip proxy credentials upstream, and await proxy shutdown. + All client snapshot sources share one monotonic revision gate per connection. + Never attach a personal profile, expose arbitrary CDP, + download a browser automatically or disable its sandbox. +- File delivery recognition is a frontend projection, not a persisted or + filesystem-verified registry. Settled turns collect local Markdown links + and supported structured successful write/edit outputs; inline basenames + require an unambiguous observed output. References use the existing + workspace file API and containment checks, never browser `file:` navigation. + Delivery buttons toggle only their own visible Files preview, retaining + tabs and trigger focus; other files or panels switch to the requested file. + Markdown opens rendered; preview links resolve relative to that document. - Workspace navigation metadata is separate from sessions: `web-workspaces.json` stores registered `workspaces` and explicit `removedWorkspaces`. Removed entries are returned with `hidden: true`; clients must not recreate their project groups from session cwd values. Re-adding restores the group; removal never deletes chats or files. @@ -179,6 +261,8 @@ When to add or change one: `STEPCODE_WEB_PUBLIC_DIR` (client directory), `STEPCODE_WEB_AUTH_SECRET`, `STEPCODE_WEB_OPEN` (below), `STEPCODE_WEB_CLIENT_PORT` (the launch URL points at this port instead of the host's; `web:watch` sets it to the Vite dev server, default 5173). + `STEPCODE_WEB_BROWSER_PATH` optionally names an installed Chrome/Edge/Chromium executable. + The browser adapter detects common locations otherwise and reports missing engines without installing one. Document new ones here and in `apps/desktop/README.md` if the desktop passes them through. - Opening a browser on start is **opt-in**, because Desktop shows the client in its own window and the tests must stay headless. `STEPCODE_WEB_OPEN=1|true|on` makes the host open the launch URL right after printing the banner; unset or @@ -193,6 +277,13 @@ When to add or change one: `step mcp`, `/plugin` and the TUI resource selector use — and never project scope. Only user-scope state moves; a change reaches a session when that session starts (MCP servers connect at `session_start`, skills load with its resources), so the client says so instead of reloading sessions. +- Account settings are separate from General and reuse `features/login/actions.ts` + and the existing Host sign-in routes. Keep credential presence separate from + validity; an unavailable check is not a rejected credential. Optional UID is + only a bounded stored OAuth identity matching the kernel's valid account, + never a profile name, key or token. Environment credentials remain read-only. + Cancel page-owned browser authorization on exit and ignore late callbacks. + Keep cookie-only `/auth/logout` unchanged; no balance/allowance placeholders. - Do not depend on `process.cwd()` being the workspace; the desktop starts the server elsewhere (macOS privacy blocks a cwd inside `~/Documents`). @@ -208,6 +299,8 @@ Run from the repo root. | `pnpm --filter @step-harness/web typecheck` | Host and client type check | | `pnpm --filter @step-harness/web test` | Host unit tests | | `TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test test/.test.ts` | One unit test file (from `apps/web/`); without the tsconfig, workspace packages resolve to `dist/` and fail | +| `STEPCODE_WEB_BROWSER_TEST=1 TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test test/browser.test.ts` | Opt-in installed-Chromium adapter acceptance against a local fixture only; no account, provider or engine download | +| `STEPCODE_WEB_TERMINAL_TEST=1 TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test test/terminal.test.ts` | Opt-in real local-shell acceptance in temporary folders; no account/provider calls | | `pnpm --filter @step-harness/web test:e2e` | Client build + e2e in headless Chrome (`CHROME_PATH` to override; skipped without Chrome) | | `pnpm run check` | Required before handing back any code change: Biome, guards, type checks, web unit tests, web runtime, browser smoke, desktop type check | diff --git a/apps/web/client/src/App.tsx b/apps/web/client/src/App.tsx index d65b9f24..ede43b3f 100644 --- a/apps/web/client/src/App.tsx +++ b/apps/web/client/src/App.tsx @@ -1,7 +1,8 @@ -import type { CSSProperties } from "react"; +import { type CSSProperties, useLayoutEffect, useRef, useState } from "react"; import { Composer, ImagePreview, SelectionDialog } from "./features/composer"; -import { ChildAgentView, ConversationStream, FeedbackModal, StreamNav, TaskBanner } from "./features/conversation"; -import { FileTreePanel, PreviewPanel, PreviewShowButton } from "./features/files"; +import { ConversationStream, FeedbackModal, StreamNav } from "./features/conversation"; +import { FileTreePanel } from "./features/files"; +import { RightPanel, RightToolRail } from "./features/inspector"; import { LoginGate } from "./features/login"; import { CronView, MarketView } from "./features/market"; import { SettingsView } from "./features/settings"; @@ -15,9 +16,11 @@ import { UserArea, } from "./features/shell"; import { SearchModal, SidebarNav, SidebarResizer } from "./features/sidebar"; +import { TerminalDock } from "./features/terminal"; import { RemoveWorkspaceModal, WorkspaceBrowserModal, WorkspaceControl } from "./features/workspace"; import { I18nProvider } from "./i18n"; import { cx } from "./lib/format"; +import { activeRightPanel } from "./state/right-panel"; import { shallowEqual, useAppStore } from "./state/store"; import { IconSprite } from "./ui"; @@ -26,15 +29,17 @@ import { IconSprite } from "./ui"; * original DOM position: * * svg.icon-sprite - * div.app[.sidebar-hidden][.preview-open][.resizing] (--sidebar-width, --preview-width) + * div.app[.sidebar-hidden][.right-panel-open][.resizing] (--sidebar-width, --preview-width) * aside.sidebar sidebar nav | files tree | shell user area (account, remote control, settings gear) * #sidebarResizer sidebar - * main.main shell show button | files show button | conversation banner | market cron/market views - * | conversation #childAgentView (read-only child transcript over stream and composer) | stream + * main.main shell show button | files show button | market cron/market views + * | conversation stream * | conversation stream-nav (locator rail over the stream's right edge) * | .composer-zone > composer form (workspace pill above the card) + * | #terminalDock (terminal: resizable bottom dock with workspace tabs) * | shell toast + auth banner - * aside#previewPanel files + * aside#rightPanel floating Summary, or context/files/changes/child inspector / browser + * nav.right-tool-rail * #settingsView settings: full-window settings page (menu + page) over the workspace * #feedbackModal, #searchModal, #workspaceBrowserModal, #removeWorkspaceModal, #selectionDialog, * #imagePreview (composer: enlarged image attachment), #logoutDialog (shell), #loggedOutOverlay @@ -42,16 +47,35 @@ import { IconSprite } from "./ui"; * #workspaceMenu, #sessionMenu, #tagPicker: rendered by their sidebar trigger, in a portal on body, only while open */ export function App() { + const appRef = useRef(null); + const [summarySpace, setSummarySpace] = useState(false); const layout = useAppStore( (state) => ({ sidebarHidden: state.ui.sidebarHidden, sidebarWidth: state.ui.sidebarWidth, previewWidth: state.ui.previewWidth, resizing: state.ui.resizing, - previewOpen: state.preview.open, + rightOpen: Boolean(activeRightPanel(state)), + summaryOpen: activeRightPanel(state) === "summary", + rightExpanded: state.ui.rightPanelExpanded, }), shallowEqual, ); + useLayoutEffect(() => { + const app = appRef.current; + if (!app) return; + const sidebar = app.querySelector(".sidebar"); + const measure = () => { + const sidebarWidth = layout.sidebarHidden ? 0 : (sidebar?.getBoundingClientRect().width ?? 0); + const railWidth = Number.parseFloat(getComputedStyle(app).getPropertyValue("--rail-width")) || 44; + setSummarySpace(app.clientWidth - sidebarWidth - railWidth >= 1120); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(app); + if (sidebar) observer.observe(sidebar); + return () => observer.disconnect(); + }, [layout.sidebarHidden]); const style: Record = {}; if (layout.sidebarWidth !== null) style["--sidebar-width"] = `${layout.sidebarWidth}px`; if (layout.previewWidth !== null) style["--preview-width"] = `${layout.previewWidth}px`; @@ -59,10 +83,15 @@ export function App() {
- - -
} />
+
- + +
diff --git a/apps/web/client/src/features/browser/BrowserPanel.tsx b/apps/web/client/src/features/browser/BrowserPanel.tsx new file mode 100644 index 00000000..31b1dbb4 --- /dev/null +++ b/apps/web/client/src/features/browser/BrowserPanel.tsx @@ -0,0 +1,394 @@ +import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import { errorMessage } from "../../lib/api"; +import { writeClipboard } from "../../lib/clipboard"; +import { isMacPlatform } from "../../lib/format"; +import type { EmbeddedBrowserFrame, EmbeddedBrowserSnapshot } from "../../state/types"; +import { Button, Icon, IconButton, type IconName, Tooltip } from "../../ui"; +import { BrowserConnection, browserKeyRoute } from "./actions"; + +const EMPTY: EmbeddedBrowserSnapshot = { revision: 0, activeId: null, tabs: [] }; + +export function BrowserPanel({ open }: { open: boolean }) { + const t = useT(); + const [snapshot, setSnapshot] = useState(EMPTY); + const [error, setError] = useState(""); + const [ready, setReady] = useState(false); + const [retry, setRetry] = useState(0); + const [frame, setFrame] = useState(); + const [address, setAddress] = useState(""); + const connection = useRef(null); + const surface = useRef(null); + const input = useRef(null); + const addressInput = useRef(null); + const composing = useRef(false); + const clicks = useRef({ at: 0, x: 0, y: 0, button: 0, count: 1 }); + const active = snapshot.tabs.find((tab) => tab.id === snapshot.activeId); + const activeId = active?.id; + useEffect(() => { + setAddress(active?.url === "about:blank" ? "" : (active?.url ?? "")); + }, [active?.url]); + // biome-ignore lint/correctness/useExhaustiveDependencies: retry deliberately recreates the transport. + useEffect(() => { + if (!open) return; + let disposed = false; + let decoding = false; + let latest: EmbeddedBrowserFrame | undefined; + const display = async (value: EmbeddedBrowserFrame) => { + latest = value; + if (decoding) return; + decoding = true; + try { + while (latest && !disposed) { + const next = latest; + latest = undefined; + const image = new Image(); + image.src = `data:image/jpeg;base64,${next.data}`; + await image.decode(); + if (!disposed) setFrame(next); + } + } catch (failure) { + if (!disposed) setError(errorMessage(failure)); + } finally { + decoding = false; + } + }; + const client = new BrowserConnection( + (value) => { + if (!disposed) setSnapshot(value); + }, + (value) => { + void display(value); + }, + (failure, disconnected) => { + if (!disposed) { + setError(errorMessage(failure)); + if (disconnected) setReady(false); + } + }, + ); + connection.current = client; + setReady(false); + setError(""); + void client + .connect() + .then(() => { + if (!disposed) setReady(true); + }) + .catch((failure: unknown) => { + if (!disposed) setError(errorMessage(failure)); + client.close(); + }); + return () => { + disposed = true; + client.close(); + connection.current = null; + }; + }, [open, retry]); + useEffect(() => { + const element = surface.current; + if (!open || !ready || !element) return; + let timer: ReturnType; + const observer = new ResizeObserver(() => { + clearTimeout(timer); + timer = setTimeout(() => { + const rect = element.getBoundingClientRect(); + if (rect.width && rect.height) + connection.current?.send("/viewport", { + width: Math.min(1600, Math.max(160, Math.round(rect.width))), + height: Math.min(1200, Math.max(120, Math.round(rect.height))), + }); + }, 100); + }); + observer.observe(element); + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [open, ready]); + const action = (name: string, value?: unknown) => { + if (activeId) connection.current?.send(`/tabs/${activeId}/action`, { action: name, value }); + }; + const sendInput = (event: unknown, move = false) => { + if (!activeId || !ready) return; + const path = `/tabs/${activeId}/input`; + if (move) connection.current?.move(path, event); + else connection.current?.send(path, event); + }; + const copy = () => { + if (!activeId) return; + void connection.current + ?.selection(activeId) + .then(writeClipboard) + .catch((failure: unknown) => setError(errorMessage(failure))); + }; + const point = (x: number, y: number) => { + const rect = surface.current?.getBoundingClientRect(); + if (!rect || !frame) return { x: 0, y: 0 }; + return { + x: Math.max(0, Math.min(frame.width - 1, ((x - rect.left) / rect.width) * frame.width)), + y: Math.max(0, Math.min(frame.height - 1, ((y - rect.top) / rect.height) * frame.height)), + }; + }; + const pointer = (event: PointerEvent, phase: "down" | "move" | "up") => { + if (!frame || frame.tabId !== activeId || !ready) return; + if (phase === "down") { + const previous = clicks.current; + clicks.current = { + at: Date.now(), + x: event.clientX, + y: event.clientY, + button: event.button, + count: + Date.now() - previous.at < 500 && + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) < 4 && + event.button === previous.button + ? (previous.count % 3) + 1 + : 1, + }; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + if (input.current) { + const rect = event.currentTarget.getBoundingClientRect(); + input.current.style.left = `${Math.max(0, event.clientX - rect.left)}px`; + input.current.style.top = `${Math.max(0, event.clientY - rect.top)}px`; + input.current.focus({ preventScroll: true }); + } + } + sendInput( + { + kind: "pointer", + phase, + ...point(event.clientX, event.clientY), + button: event.button === 2 ? "right" : event.button === 1 ? "middle" : "left", + clicks: clicks.current.count, + }, + phase === "move", + ); + }; + const key = (event: KeyboardEvent) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || composing.current || event.key === "Process") return; + const route = browserKeyRoute( + { + key: event.key, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + altGraph: event.getModifierState("AltGraph"), + }, + isMacPlatform(), + ); + if (route.kind === "native") return; + event.preventDefault(); + if (route.kind === "copy") copy(); + else if (route.kind === "key") sendInput(route); + }; + const control = (icon: IconName, label: string, onClick: () => void, disabled = false) => ( + + + + ); + return ( +