Pass 2 of the web UI spacing/typography/visual-rhythm rework.
- Add layout, spacing, typography, visual, card, and component utilities
- Add form-section, form-row, form-help, text-error, alert-success
- Unify .form-group and .form-field; add .status-badge family
- Alias legacy button classes to .btn primitives
- Refactor ToolTypeListSidebar and ConfigProfileListSidebar to use .sidebar
and var(--sidebar-width) instead of hardcoded 280px
- Refactor ToolTypeEditorPanel, ConfigProfileEditorPanel,
git-mount-editor, and manifest-editor to use utility classes
Quality gates: npm run typecheck, npm run lint, npm run build pass.
Inline style blocks in target components reduced from 198 to 11.
Restructure live session nav entries into a 3-line layout:
- workspace/repository context on top
- session display name in the middle
- tool type and project on the bottom
Tool icon now has a status-dot badge, and the tooltip lists
display name, tool, project, context, and status.
The recent redraw fix keeps all xterm instances mounted (display:none)
when switching sessions. However, sendData/focus/font-size refs and the
header connection status were still stored globally, so the last-mounted
hidden session could own keyboard input, font-size buttons, and the
status dot for the active session.
- Pass sessionId to onTerminalReady from TerminalComponent.
- Store terminal callbacks and status keyed by sessionId in use-terminal-page.
- Use activeSessionId to route special-key input, font-size changes, and header status.
- Clean up per-session refs and status when sessions are closed.
- Update MobileTerminalWrapper signature for the new callback shape.
Quality gates: npm run typecheck, npm run lint, npm test (87 passed)
Render all terminal sessions and hide inactive ones with display:none so
xterm instances are no longer unmounted/remounted when switching tabs.
Remove terminal.clear() on the connected status message: the server keeps
the session buffer, and clearing was erasing visible content when the tab
regained focus or reconnected.
- Keep TerminalComponent instances alive in both Desktop and Mobile views.
- Only refit/resize on reconnect instead of clearing.
Closes terminal redraw on focus regain.
When a config profile declares both a git_mount and a mounts entry for the
same directory (e.g. ~/.pi), the generated bind-mounts would mask each other
inside the container. Instead, copy the static profile files into the
instance-scoped git-mount source directory so the container sees both the
cloned repo contents and the static files through a single bind-mount.
- Add _stack_profile_mounts_with_git_mounts helper to merge overlapping
profile mounts into git-mount sources.
- Integrate stacking into start_tool_instance after resolving both mount
types.
- Add unit tests for exact, descendant, non-overlapping, and file cases.
- Include branch in git-mount clone dir hash so different branches of the
same repo get separate directories and no longer race.
- Resolve profile working_directory before git mounts so relative targets
are not silently skipped.
- Fall back to tool_type.home_directory for non-manifest tools.
- Fix glob target calculation to avoid duplicate directory segment.
- Add exc_info logging for git mount clone failures.
Detached containers with tty: true still exited immediately because a
plain /bin/bash invocation exits with code 0 when stdin is not connected.
- Detect when the container CMD is /bin/bash or bash and exec an
interactive login shell () after dropping privileges
- Keep the generic path for non-shell commands
- Bump compiler_version to v4 to force a fresh image build
Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
The previous compiler_version v2 already produced an image tag
(3d34c636) for the su-based fix. Images built under v2 still had the
broken su entrypoint that exited immediately. Bump to v3 so the runuser
change forces a fresh image build with the corrected privilege drop.
Quality gates:
- pytest tests/unit: 219 passed
The container still exited immediately after the su-based fix. can
interfere with TTY/stdin handling for interactive shells. Switch to
, which is root-only, skips PAM, and preserves file descriptors so
bash stays interactive.
Also improve container failure diagnostics:
- now combines stdout and stderr
- This helps surface the real reason when a container exits with code 0
Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
compute_image_tag hashed only the manifest JSON, so cached images built
before the entrypoint fix were reused even though compile_entrypoint() now
produces a different entrypoint. This caused containers to keep using the
old (broken) entrypoint that exited immediately.
- Include a compiler_version token in the hash input so manifest compiler
changes invalidate previously built images
- This forces a fresh image build for new instances after any change to
compile_dockerfile, compile_entrypoint, or compile_compose
Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
The generated entrypoint used a brittle invocation that could
spawn a non-interactive bash and exit immediately, causing containers to
stop with exit code 0 right after startup.
- Switch to so the
container user gets a login shell and stdin/tty are preserved
- Update the unit test assertion for the new drop-privileges command
Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
Git clone with an explicit destination puts the working copy directly into
that directory; it does not create a repo-named subdirectory. The previous
change assumed the opposite, so workspaces ended up at
/data/working-copies/{workspace_id}/ with the repo contents mixed in,
causing a 500 when the expected repo-named subdirectory was missing.
- Build the target path as /data/working-copies/{workspace_id}/{repo_name}/
and pass it directly to GitService.clone
- Remove stale directory detection and fallback logic that is no longer
needed
- Keep diagnostic logging around git clone failures
Quality gates:
- pytest tests/unit: 219 passed
- mypy: clean on changed files
The workspace creation endpoint returns 500 but the actual error is not
visible. Add explicit error logging when GitService.clone fails and info
logging when git creates a directory name different from the one derived
from the remote URL.
Quality gates:
- pytest tests/unit: 219 passed
- mypy: clean on changed files
Working copies were stored as /data/working-copies/{repo_id}/{workspace_name}/,
so git clone was forced into a user-named directory. That meant the container
mount basename was the workspace name (e.g. main) instead of the repo name.
- Generate the workspace UUID before cloning and clone into
/data/working-copies/{workspace_id}/ so git creates {repo_name}/ naturally
- Set workspace.path to /data/working-copies/{workspace_id}/{repo_name}/
- Update _migrate_clone_into_workspace() to use the same layout
- _get_repository_mount_name() now prefers workspace.path basename and only
falls back to remote URL / repo.name for legacy repo-only instances
- Update unit tests to assert workspace path basename is used for mounts
Quality gates:
- pytest tests/unit: 219 passed
- ruff: clean on changed files
- mypy: clean on changed files
The pi-agent manifest still declared an explicit repo mount with
{{WORKSPACE_NAME}}, making the mount target dependent on tool config. The
instance service now synthesizes the repo mount, so the manifest no longer
needs the explicit mount.
- Add Alembic migration 2026_06_15_090500 to remove the source_type: repo
mount from the built-in pi-agent manifest
- Add _get_repository_mount_name() helper to derive the workspace directory
name from the repository remote URL (matching git clone behavior) and
fall back to the user-provided repository name
- Use the helper for WORKSPACE_NAME/REPO_NAME in manifest, legacy dockerfile,
and legacy compose template paths
- Update unit tests for the new migration and helper
Quality gates:
- pytest tests/unit: 218 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
WORKSPACE_NAME was computed from os.path.basename(repo_path), so when a
workspace path ended in a directory like 'main', the container mount target
became /home/user/main instead of /home/user/{repo-name}.
- Use GitRepository.name for WORKSPACE_NAME/REPO_NAME in manifest and
legacy dockerfile flows
- Add unit test verifying prepare_manifest_instance uses repo.name even
when the workspace path basename differs
Quality gates:
- pytest tests/unit: 213 passed
- ruff: clean on changed files
- mypy: clean on changed files
Older cached images still contain a literal /home/user/{{WORKSPACE_NAME}}
directory baked in by the previous Dockerfile generation. Even though new
images no longer create it, existing images leave the placeholder folder
alongside the real repo-named mount.
- Add entrypoint cleanup that removes /{{WORKSPACE_NAME}} if it
exists before creating the real workspace target and /workspace symlink
- Update unit tests to assert the stale placeholder removal
Quality gates:
- pytest tests/unit: 212 passed
- ruff: clean on changed files
- mypy: clean on changed files
When a manifest mount target uses ~/{{WORKSPACE_NAME}}, the Dockerfile was
building a literal directory named {{WORKSPACE_NAME}} into the image and
creating a broken /workspace symlink. The runtime mount then created the
correct repo-named folder alongside the placeholder folder.
- Only create static mount target directories in the Dockerfile; skip any
target containing {{WORKSPACE_NAME}}
- Only create the /workspace compatibility symlink at image-build time when
the workspace name is known; otherwise let the entrypoint create it from
the WORKSPACE_NAME environment variable
- Update unit tests to cover both build-time workspace names and runtime
placeholders
Quality gates:
- pytest tests/unit: 211 passed
- ruff: clean on changed files
- mypy: clean on changed files
- Add final get_container_status check in start_tool_instance before
writing status=running; mark as error and return logs if container stopped
- Treat restarting as error in HealthMonitor when DB status was already
running, so crash loops are surfaced instead of preserved
- Disable auto-restart (restart: unless-stopped -> restart: no) for tool
instances in manifest compiler, legacy dockerfile path, and built-in seeds
Quality gates:
- pytest tests/unit: 210 passed
- ruff: clean on changed files
- mypy: clean on changed files
The compose file was forcing the container to run as uid 1001, so the
entrypoint could not create /workspace even with sudo configured.
- Remove Dockerfile USER directive so containers start as root
- Make compile_compose use user: 0:0 when the manifest declares a user
- Make the entrypoint drop to the container user via after setup,
preserving environment variables and command arguments
- Update unit tests to assert root startup and privilege drop
Quality gates:
- pytest tests/unit: 210 passed
- ruff: clean on changed files
- mypy: clean on changed files
The previous ordering checked SUDO before checking if the process was
already running as root. When Docker starts the container with a
non-root user, SUDO may be empty, but the real fix is that the
entrypoint should try root first (e.g. when the image is started as
root) and only then fall back to sudo.
- Reorder symlink creation logic: root first, then sudo, then best-effort
- Update unit test to assert root is checked before sudo
Quality gates:
- pytest tests/unit: 208 passed
- ruff: clean on changed files
- mypy: clean on changed files
The previous commit moved the pi-agent repo mount from /workspace to
/home/user/{repo_name}. This exposed a permission bug: the Dockerfile
creates /workspace as a root-owned symlink in the image, and the
non-root entrypoint could not replace it because / is owned by root.
- Update compile_entrypoint to recreate /workspace via sudo when running
as the container user, or directly when running as root
- Add unit test covering sudo/root symlink creation
- Update OpenSpec change docs with the additional root cause
Quality gates:
- pytest tests/unit: 208 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
- Add Alembic migration to update built-in pi-agent manifest:
* repo mount target from /workspace to ~/{{WORKSPACE_NAME}}
* keep /workspace as compatibility symlink via working_dir
* update startup chown target to $HOME/$WORKSPACE_NAME
- Pass REPO_NAME and WORKSPACE_NAME to compile_compose from instance_service
- Substitute {{WORKSPACE_NAME}} in manifest mount targets and expose it as
a container env var so the entrypoint can create the /workspace symlink
- Generate entrypoint workspace symlink from runtime WORKSPACE_NAME env var
- Install npm_global packages into {home_dir}/.npm-global with PATH so the
non-root container user can update global packages
- Update manifest compiler unit tests for the new behavior
Quality gates:
- pytest tests/unit: 207 passed
- ruff: clean on changed files
- mypy: clean on changed files
- alembic heads: single head
- Change mobile terminal CSS to use touch-action: none and
overscroll-behavior: none so the custom touch handler owns swipes
- Archive completed/partial OpenSpec specs to
openspec/changes/archive/2026-06-14-completed-specs-archive/
- Regenerate project maps
Quality gates: npm run typecheck, npm run lint (apps/web)
The desktop terminal page sometimes grew an outer scrollbar because the
terminal instance/wrapper/container chain lacked height constraints.
Without min/max-height enforcement, xterm.js's internal viewport could
expand its parent flex/grid track past the available space.
- Add overflow: hidden to .terminal-page.
- Add max-height: 100% and overflow: hidden to .terminal-instance.
- Add max-height: 100% to .terminal-wrapper.
- Add min-height: 0 to .terminal-container.
- Constrain .xterm-viewport to max-height/width 100% so it fills but
never exceeds its container.
Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed).
Refs: openspec/changes/fix-terminal-container-overflow
The Pi Agent dockerfile templates created ~/.tmux.conf with a literal
\n because the RUN command used single-quoted echo. Tmux never parsed
the malformed line, so mouse mode stayed off. Without tmux mouse mode,
mouse-wheel events in xterm.js fell back to Up/Down arrow keys and
cycled shell command history instead of scrolling the terminal buffer.
- Use printf '%s\n' to write real newlines in .tmux.conf.
- Apply the same fix to the ranger rc.conf where the same bug existed.
- Update tool-images/pi-agent.dockerfile and both affected alembic
migration dockerfile strings.
Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed),
py_compile on changed migrations.
Refs: openspec/changes/fix-tmux-mouse-config
- Wire MobileListView onItemDelete/onItemDuplicate callbacks to render
action buttons in each list row.
- Pass onItemDelete in ToolWorkshopMobileView list view.
- Add CSS for mobile-list-item-action buttons.
- Fix MobileEditView sticky bottom action bar that was hidden behind
the 64px mobile navigation bar; raise to bottom: 64px and z-index 110.
Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)
Refs: openspec/changes/mobile-list-delete-button
- Update MobileEditView to render Save and optional Delete in a
sticky bottom action bar; header now shows Cancel + title only.
- Make ConfigProfilesMobileView open edit view on profile tap.
- Make ToolWorkshopMobileView open edit view on tool type tap.
- Wire delete into MobileEditView for existing profiles and tool
types.
- Stay on edit view after saving an existing item; create flow
returns to list as before.
- Update ToolWorkshopPage cancel to return to list.
- Add mobile-edit-actions and mobile-edit-delete CSS.
Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)
Refs: openspec/changes/mobile-edit-default-bottom-actions
- Rewrite ConfigProfilesMobileView to match desktop functionality:
full detail view with all fields, preview action showing resolved
profile, and edit view with project/tool selects, includes,
environment variables, runtime hints, files, mounts with nested
files, and git mounts.
- Update useConfigProfiles.handleSubmit to return boolean success.
- Update ConfigProfilesPage to pass required state and callbacks.
- Add mobile-specific CSS for config profile forms, includes,
mount/file cards, and preview panels.
- Allow MobileDetailView to render extra children.
Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)
Refs: openspec/changes/mobile-config-profiles-ui
The mobile profile form already renders its own header via
ProfileMobileView, so the desktop page title was redundant on small
viewports.
Quality gates: npm run typecheck, npm run lint
Refs: openspec/changes/mobile-tool-profile-ui
- Rework ToolWorkshopMobileView to support full desktop functionality:
definition type selection (Compose/Dockerfile/Manifest), manifest editor,
conditional port, startup command, readiness probe, required variables,
and validation feedback.
- Add ProfileMobileView and wire ProfilePage to render it on mobile.
- Update useToolWorkshop hook to return boolean success from submit.
- Add responsive CSS for mobile forms, edit views, and manifest editor.
- Update project maps.
Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed)
Refs: openspec/changes/mobile-tool-profile-ui
- Revert touch-action: none on .xterm-viewport so xterm.js can fall back
to its own viewport scrolling when the custom handler doesn't take over.
- Detect alternate screen via reference equality
(term.buffer.active === term.buffer.alternate) instead of the
string, which could report normal buffer incorrectly.
- Lower vertical-scroll activation threshold from 4px to 2px and only
prevent default once a vertical gesture is recognized.
- In normal buffer use term.scrollLines() so xterm.js handles the buffer
scroll consistently; in alternate screen continue sending SGR 1006
mouse-wheel sequences to tmux/vim.
Quality gates: typecheck, lint clean, npm test -- --run 87 passed.
- Add touch-action: none and overscroll-behavior: none to mobile terminal
page, content, wrapper, container, and xterm viewport so the browser
never treats swipes as page/address-bar scrolling or pull-to-refresh.
- Make .terminal-page.mobile fixed-position to prevent viewport layout
scroll; keep the overlay toolbar as absolute with pointer-events only
on interactive parts.
- Rework the mobile touch handler in terminal.tsx:
* Detect normal vs alternate buffer via term.buffer.active.type instead
of measuring the DOM viewport, which was unreliable in tmux/vim.
* Accumulate swipe distance and emit SGR 1006 mouse-wheel sequences in
steps, so tmux pane scrolling tracks the gesture correctly.
* Prevent default as soon as the swipe is recognized so the page does
not start a competing scroll gesture.
Quality gates: npm run typecheck, npm run lint clean, npm test -- --run 87 passed.
- The top-level GET /workspaces endpoint was returning project_name as
an empty string because the GitRepository.project relationship was not
eager-loaded.
- Select the full GitRepository entity and apply selectinload(project)
so project_name is populated for every workspace row.
Backend quality gates: python3 -m pytest 313 passed, 34 skipped.
- Move the project name below the workspace title row so it reads as a
distinct line with a project icon.
- Move the status badge into the title row next to the workspace name,
preventing it from crowding the project label.
- Add .workspace-title-row flex styles and update .workspace-project-name
to display inline-flex with a brand-colored project icon.
Quality gates: npm run typecheck, npm run lint clean,
npm test -- --run 87 passed.
- Reorder desktop sidebar so Sessions sits between spaces/tools groups:
Home, Projects, Workspaces, Sessions, Tool Workshop, Config Profiles, Settings.
- Reorder mobile bottom nav so Sessions is the center item:
Home, Spaces, Sessions, Tools, Settings.
- Workspace cards already display the owning project name; no extra change needed.
Quality gates: npm run typecheck, npm run lint clean,
npm test -- --run 87 passed.
- Combine Projects and Workspaces into a single 'Spaces' grouped mobile
nav item that opens a bottom-sheet menu.
- Add SpacesBottomSheet component with Projects/Workspaces options.
- Extend MobileListView with optional renderItem prop for rich rows.
- Redesign mobile ProjectsPage rows to show project description and
repository chips.
- Replace mobile WorkspacesPage list with compact WorkspaceCard grid,
matching desktop card content.
- Add mobile-list-* CSS and mobile-workspaces-list spacing.
Quality gates: npm run typecheck, npm run lint clean,
npm test -- --run 87 passed.
- Swap workspace card header order: project name is now the primary eyebrow,
workspace name is the bold title below it.
- Restructure workspace card into clean top/body/actions sections with more
whitespace and clearer hierarchy.
- Replace cramped meta paragraphs with an inline meta row (repo, branch,
instance count) and dedicated instance chip area.
- Use icon-only ghost buttons for sync/delete to reduce visual noise; keep
prominent Start Tool button.
- Add top divider for actions, improve hover states, and make long names
truncate gracefully.
- Update mobile workspace list subtitle to project · workspace name.
- Refresh workspaces.css with new card layout and responsive mobile rules.
Quality gates: npm run typecheck clean, npm run lint clean,
npm test -- --run 87 passed
- modify_compose_file now appends :ro when a config-profile volume entry
has readonly=true, matching the manifest compiler behavior.
- Add a guard for missing tool_type in prepare_manifest_instance.
Quality gates: python3 -m pytest (313 passed, 34 skipped)
- Switch apply_resolved_profile from per-file bind mounts to one
directory-level bind mount per ResolvedMount target.
- Stage all configured files under instance_dir/mounts/<sanitized_target>
and bind-mount that directory, so Docker no longer creates a root-owned
parent directory such as ~/.config.
- Propagate read-only mode ('ro') as the 'readonly' flag on volume entries.
- Update unit tests to expect directory-level mounts and add coverage for
readonly/writable flags.
Quality gates: python3 -m py_compile, pytest (313 passed, 34 skipped),
npm run typecheck, npm run lint.
- Remove pi_state and pi_config mounts from the pi-agent manifest.
- Add Alembic data migration to strip those mounts from existing DB rows.
- Remove opencode_home:/tmp volume and HOME=/tmp override from the opencode
built-in compose template; config/state now belongs in config profiles.
- Workspace and SSH key mounts remain unchanged.
Quality gates: python3 -m py_compile, pytest (311 passed, 34 skipped),
npm run typecheck, npm run lint
Add a reusable LoadingOverlay component that dims and disables the
container owning an in-flight action, with a spinning indicator and
label. Apply it to:
- SessionCard (when actionBusyId matches)
- InstanceList cards (per busyInstanceId with action-specific labels)
- CreateSessionForm (while submitting)
- ToolStarter (while starting)
Also add .icon-spin animation and position:relative to the relevant
containers.
Quality gates: npm run typecheck, npm run lint, npm test -- --run
(87 passed).