diff --git a/CLAUDE.md b/CLAUDE.md index 5b0036d3..f27b2e75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.7.7 (2026-10-14) — File Explorer (workspaces + the user's home, trash), clone + per-user git identity, S3 fails closed._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.7.8 (2026-10-14) — Delete user, RSA SSH keys, File Explorer, clone + git identity, Artifacts hub._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -59,6 +59,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `/trash//`), restorable 7 days. `write-as` is root-installed: a change to it needs `sudo agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`. - **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo. - **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-` sessions. +- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `/artifacts//` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch turns installs off. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. - **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `/workspace-context//` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `/workspace-prompts/.md` at every spawn and passed as `--append-system-prompt-file=` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md). - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id ` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `/.claude/worktrees/`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. @@ -79,7 +80,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A read that scans the events table never runs on the event loop, and never runs a correlated subquery per event.** Node serves every page, hook and websocket from one thread, and better-sqlite3/node:sqlite block it for the whole query — one slow aggregate freezes the dashboard for every user. Put an aggregate that takes more than a few hundred ms at real size into `JOBS` in `server/lib/read-pool.js` (its own `query_only` connection in a worker, timeout, shared in-flight runs); write "the next event of the session" as a window function (`LEAD() OVER (PARTITION BY session_id ORDER BY id)`), not `MIN(id) … id > e1.id`. And do not drop `idx_events_session(session_id, id)` as a "prefix" of another index: only it orders a session's events by id. - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. -- **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable — it never deletes, never adopts a non-member account, and root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. +- **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. - **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. diff --git a/client/src/App.tsx b/client/src/App.tsx index 5d8ee5e1..aa67d41b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -64,6 +64,7 @@ import { GlobalTooltip } from "./components/Tooltip"; import { Toaster } from "./components/Toaster"; import { ConfirmHost } from "./components/ConfirmHost"; import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary"; +import { usePermissions } from "./components/AuthGate"; import { NotFound } from "./pages/NotFound"; import { useWebSocket } from "./hooks/useWebSocket"; import { useNotifications } from "./hooks/useNotifications"; @@ -89,6 +90,7 @@ const SnapshotView = lazy(() => ); const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage }))); const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users }))); +const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts }))); function PageFallback() { const { t } = useTranslation(); @@ -108,6 +110,11 @@ function Page({ children }: { children: ReactNode }) { ); } +/** A page only a shared server needs (the File Explorer): local mode goes home. */ +function ServerOnly({ children }: { children: ReactNode }) { + return usePermissions().local ? : <>{children}; +} + /** Keyed by the id so moving to another session starts from fresh state. */ function SessionDetailRoute() { const { id } = useParams(); @@ -212,13 +219,23 @@ export default function App() { } /> - + } /> + + + + + + } + /> diff --git a/client/src/components/Sagi/Sagi.tsx b/client/src/components/Sagi/Sagi.tsx index 021e52e4..eea91143 100644 --- a/client/src/components/Sagi/Sagi.tsx +++ b/client/src/components/Sagi/Sagi.tsx @@ -149,15 +149,21 @@ export function usePrefersReducedMotion(): boolean { * changes via ResizeObserver) so a tall panel near a screen edge slides fully * into view instead of being cropped. */ -/** `front`: above the mascot itself — the pet menu, which may overlap it. */ +/** + * `front`: above the mascot itself — the pet menu, which may overlap it. + * `beside`: at the mascot's side instead of above it — the chat window, on the + * screen side away from the edge it docks to, its bottom level with the pet's. + */ function SagiFlyout({ anchor, children, front = false, + beside = false, }: { anchor: Anchor; children: ReactNode; front?: boolean; + beside?: boolean; }) { const ref = useRef(null); const [style, setStyle] = useState({ visibility: "hidden" }); @@ -170,6 +176,18 @@ function SagiFlyout({ const vw = window.innerWidth; const vh = window.innerHeight; + if (beside) { + let left = + anchor.side === "left" + ? anchor.left + anchor.size + FLYOUT_GAP + : anchor.left - w - FLYOUT_GAP; + left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left)); + let top = anchor.top + anchor.size - h; + top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top)); + setStyle({ left, top, visibility: "visible" }); + return; + } + // Horizontal: hug the avatar's docked edge, then clamp on-screen. let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w; left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left)); @@ -182,7 +200,7 @@ function SagiFlyout({ top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top)); setStyle({ left, top, visibility: "visible" }); - }, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]); + }, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]); useLayoutEffect(() => { place(); @@ -431,37 +449,32 @@ function SagiBody() { onClearAlerts={brain.clearAlerts} onNavigate={onNavigate} onAsk={onAsk} - onOpenChat={() => { - setOpen(false); - setChatOpen(true); - }} onClose={() => setOpen(false)} /> )} - {/* Kept mounted while dragging (only hidden): unmounting would detach - its terminal and lose a half-typed message. */} + {/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows + it 1:1), and kept mounted: unmounting would detach its terminal and + lose a half-typed message. */} {chatOpen && ( -
- - setChatPrompt(null)} - onBack={() => { - setChatOpen(false); - setOpen(true); - }} - onClose={() => setChatOpen(false)} - onOpenWorkspace={(laneId, runId) => { - navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`); - setChatOpen(false); - }} - onBusy={onBusy} - /> - -
+ + setChatPrompt(null)} + onBack={() => { + setChatOpen(false); + setOpen(true); + }} + onClose={() => setChatOpen(false)} + onOpenWorkspace={(laneId, runId) => { + navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`); + setChatOpen(false); + }} + onBusy={onBusy} + /> + )} {!place.dragging && menu && ( diff --git a/client/src/components/Sagi/SagiPanel.tsx b/client/src/components/Sagi/SagiPanel.tsx index 82cbf5e5..a9679495 100644 --- a/client/src/components/Sagi/SagiPanel.tsx +++ b/client/src/components/Sagi/SagiPanel.tsx @@ -60,7 +60,6 @@ import { Radio, Heart, ChevronRight, - MessageCircle, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -89,8 +88,6 @@ interface SagiPanelProps { onNavigate: (route: string) => void; /** Returns an answer to display, or null when the query was handed off. */ onAsk: (query: string) => string | null; - /** Opens the chat window (PetChat). */ - onOpenChat: () => void; onClose: () => void; } @@ -108,7 +105,6 @@ export function SagiPanel({ onClearAlerts, onNavigate, onAsk, - onOpenChat, onClose, }: SagiPanelProps) { const { t } = useTranslation("settings"); @@ -282,13 +278,6 @@ export function SagiPanel({ {/* quick actions */}
-
- -
{ expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument(); }); - it("opens the chat from the panel and goes back to it", () => { + it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => { + localStorage.setItem("agenthub.defaultTool", "claude"); renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); - fireEvent.click(screen.getByRole("button", { name: /chat with sagi/i })); + expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument(); + const input = screen.getByLabelText(/ask sagi/i); + fireEvent.change(input, { target: { value: "write me a haiku" } }); + fireEvent.submit(input.closest("form")!); expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Back" })); expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 71ae7665..cc979d54 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -57,6 +57,7 @@ import { LayoutDashboard, FolderOpen, HardDrive, + Gem, BarChart3, Settings, Wifi, @@ -91,6 +92,8 @@ const NAV_KEYS = [ { to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, // The file manager (FilesPage): browse, upload and download workspace files. { to: "/files", icon: HardDrive, key: "nav:files" }, + // What agents produced, and (server mode) the Hub others shared (Artifacts.tsx). + { to: "/artifacts", icon: Gem, key: "nav:artifacts" }, ] as const; const STORAGE_KEY = "sidebar-collapsed"; @@ -197,7 +200,8 @@ export function Sidebar({ location.pathname.startsWith("/settings") || new URLSearchParams(location.search).has("settings"); // Checking for (and applying) an update acts on the host: an admin's. - const canUpdate = usePermissions().isAdmin; + const perms = usePermissions(); + const canUpdate = perms.isAdmin; const asideRef = useRef(null); const drawerOpen = Boolean(drawer?.open); useDialog(drawerOpen, asideRef, drawer?.onClose); @@ -472,7 +476,8 @@ export function Sidebar({