Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
24c0d62
feat(web): migrate DFSC conversation and summary presentation
7oMB2006 Oct 20, 2026
8cca833
feat(web): connect delivered files to preview
7oMB2006 Oct 20, 2026
25b2d3a
feat(web): add hosted browser and interactive terminal inspectors
7oMB2006 Oct 20, 2026
902fe66
feat(web): add compact performance row and hover disclosures
7oMB2006 Oct 20, 2026
0f52182
feat(web): group transcript processes with inline disclosures
7oMB2006 Oct 20, 2026
effb182
feat(web): add dedicated DFSC-style account settings
7oMB2006 Oct 20, 2026
c6420af
fix(web): guard browser redirects and settle terminal cleanup
7oMB2006 Oct 20, 2026
cfc7a78
fix(web): reconcile upstream icons with desktop features
7oMB2006 Oct 20, 2026
b560486
fix(web): integrate account menus with dedicated account settings
7oMB2006 Oct 20, 2026
05a9cfa
fix(web): flatten single-member process disclosures
7oMB2006 Oct 20, 2026
623a189
fix(web): reconcile upstream settings and dependency changes
7oMB2006 Oct 20, 2026
639df0c
fix(web): harden terminal native startup and POSIX cleanup
Oct 20, 2026
28f4dcc
feat(web): dock the terminal below the conversation with multiple tabs
Oct 20, 2026
34933a8
fix(web): keep the conversation the user picks while bootstrap loads
Oct 20, 2026
20f3872
test(web): assert the real preview and rail DOM, and drop the unused …
Oct 20, 2026
fe4cb5a
fix(web): harden the embedded browser host and its frames
Oct 21, 2026
bf11865
fix(web): update background lanes from agent_send lane details
Oct 21, 2026
c495e10
perf(web): keep unchanged conversation rows stable while a turn streams
Oct 21, 2026
2de1b33
feat(web): show the folder and Git branch in the terminal prompt
Oct 21, 2026
05cc1e1
feat(web): open terminals as login shells with the user's startup files
Oct 21, 2026
309daee
fix(web): coalesce terminal input typed while a request is in flight
Oct 21, 2026
b41fae2
feat(web): load the user's PowerShell profile in Windows terminals
Oct 21, 2026
e2b9d2d
Merge branch 'feat/stepcode-web-mvp-gh' into feat/stepcode-web-mvp-7o…
Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
99 changes: 96 additions & 3 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) | |

Expand All @@ -172,13 +180,89 @@ 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.
- Environment variables: `STEPCODE_WEB_PORT` (default 3077), `STEPCODE_WEB_CWD` (default workspace),
`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
Expand All @@ -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`).

Expand All @@ -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/<name>.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 |

Expand Down
54 changes: 41 additions & 13 deletions apps/web/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand All @@ -26,43 +29,69 @@ 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
* #loginGate login: Desktop sign-in, above everything while no credential exists
* #workspaceMenu, #sessionMenu, #tagPicker: rendered by their sidebar trigger, in a portal on body, only while open
*/
export function App() {
const appRef = useRef<HTMLDivElement>(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<HTMLElement>(".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<string, string> = {};
if (layout.sidebarWidth !== null) style["--sidebar-width"] = `${layout.sidebarWidth}px`;
if (layout.previewWidth !== null) style["--preview-width"] = `${layout.previewWidth}px`;
return (
<I18nProvider>
<IconSprite />
<div
ref={appRef}
className={cx(
"app",
layout.sidebarHidden && "sidebar-hidden",
layout.previewOpen && "preview-open",
"has-right-rail",
layout.rightOpen && "right-panel-open",
layout.summaryOpen && "summary-open",
layout.summaryOpen && summarySpace && "summary-docked",
layout.rightExpanded && "right-panel-expanded",
layout.resizing && "resizing",
)}
style={style as CSSProperties}
Expand All @@ -75,20 +104,19 @@ export function App() {
<SidebarResizer />
<main className="main">
<SidebarShowButton />
<PreviewShowButton />
<TaskBanner />
<CronView />
<MarketView />
<ChildAgentView />
<ConversationStream />
<StreamNav />
<div className="composer-zone">
<Composer workspaceControl={<WorkspaceControl />} />
</div>
<TerminalDock />
<ErrorToast />
<AuthBanner />
</main>
<PreviewPanel />
<RightPanel />
<RightToolRail />
</div>
<SettingsView />
<FeedbackModal />
Expand Down
Loading
Loading