diff --git a/CLAUDE.md b/CLAUDE.md index d5a03365..4b1c98bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.5.13 + S3 P4–P8 (2026-10-09) — S3 review round 5: host folders admin-only, pane cwd/token bound to the pane's account, bounded sudo, capped member reads (`readLines`). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ 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.6.0 (2026-10-09) — S3 one Linux user per account (experimental, async member tmux/snapshot), git on a workspace's extra folders (`?source=`, #70), project files in the prompt (#54), a long paste attached as a file (#68). Older sync history: `docs/CLAUDE-SYNC-LOG.md` (archive, not loaded)._ 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. @@ -44,7 +44,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. -- **The dashboard's git writes are these twenty-two, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. +- **The dashboard's git writes are these twenty-two, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) is its own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. - **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]`, every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the run form's red per-run switch, never remembered — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. - **Antigravity hooks are observational only, and never answer a permission.** `server/lib/antigravity-hooks.js` writes only the named hook `agenthub` in `~/.gemini/config/hooks.json`, only on a click, and leaves every other name alone. It installs PreInvocation, PostToolUse and Stop and NEVER PreToolUse: agy reads a PreToolUse answer as a permission decision and DENIES the tool on `{}` (verified on 1.2.13), `allow` would override the user's own permission rules and `ask` would force a prompt — there is no neutral answer. The forwarder must keep printing `{}` and nothing else. The file is global (the Antigravity IDE loads it too), so the route accepts a hook only when its `transcriptPath` lies under `~/.gemini/antigravity-cli/`, and a hook's `tool` is an allowlist (`codex`, `antigravity`, else `claude`). AgentHub never reads the IDE's data. diff --git a/client/index.html b/client/index.html index d21ae217..34ed6047 100644 --- a/client/index.html +++ b/client/index.html @@ -30,7 +30,7 @@ AgentHub - SmartGift Solution - + - - + + - - + + @@ -70,7 +70,7 @@ "@context": "https://schema.org", "@type": "WebApplication", "name": "AgentHub", - "description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.", + "description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.", "applicationCategory": "DeveloperApplication", "operatingSystem": "Windows, macOS, Linux", "softwareVersion": "1.0.0", diff --git a/client/public/favicon.svg b/client/public/favicon.svg index 098f2401..0bb61a6e 100644 --- a/client/public/favicon.svg +++ b/client/public/favicon.svg @@ -1,24 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/client/public/og-image.png b/client/public/og-image.png new file mode 100644 index 00000000..079aaf36 Binary files /dev/null and b/client/public/og-image.png differ diff --git a/client/public/og-image.svg b/client/public/og-image.svg index 9c42a6e2..406dff95 100644 --- a/client/public/og-image.svg +++ b/client/public/og-image.svg @@ -1,99 +1,85 @@ - - - + + + - - - - - - - - - - - - - - - - + + + - - - + + + - - - + + + + + + + + + + + - - - - - - - - - - - + + + + + + Local-first · Real-time - - + AgentHub + Run, steer and track your AI coding agents, all in one browser tab. - - - - - - - - - - - - - - - + + + + Workspaces & terminals + Start, split and resume agent + sessions right in your browser + + + + + Every AI coding agent + Claude Code, Codex, Antigravity, + Cursor, Kimi, Grok and more + + + + + Git built in + Review changes, commit, merge + and push from each workspace + + + + + Chat view & replay + Read any session as a chat, + resume only when you choose + + + + + Usage, cost & budgets + Tokens, spend and plan quotas, + with alerts before you overspend + + + + + Alerts & notifications + Browser push and webhooks the + moment an agent needs you - Agent Dashboard - - Real-time monitoring platform for Claude Code agent activity - - - - - - Node.js 18+ - - React 18 - - TypeScript - - SQLite - - WebSocket - - MIT - - - github.com/SmartGift · smartgift.vn + smartgift.vn + git.smartgift.io.vn/Smartgift-AI/AgentHub diff --git a/client/src/components/AuthGate.tsx b/client/src/components/AuthGate.tsx index 6a957037..f228c296 100644 --- a/client/src/components/AuthGate.tsx +++ b/client/src/components/AuthGate.tsx @@ -10,6 +10,7 @@ * `usePermissions()` says what that user's role may do, so pages hide the rest. * Once an account is known (local mode is one), its own UI preferences are * loaded into this browser (`lib/userPrefs.ts`). + * The login page follows the OS's light/dark (`applySystemTheme`). * @author Nguyễn Ngọc Trí Vĩ */ @@ -17,11 +18,15 @@ import { createContext, useContext, useEffect, + useLayoutEffect, useState, type FormEvent, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; +import { Eye, EyeOff } from "lucide-react"; +import { BRAND_LOGO } from "../lib/brand"; +import { applySystemTheme } from "../hooks/useTheme"; import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api"; import { hydrate } from "../lib/userPrefs"; @@ -144,9 +149,18 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) { const { t } = useTranslation(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Nobody is signed in, so no account's theme applies: follow the OS, live. + useLayoutEffect(() => { + applySystemTheme(); + const mq = window.matchMedia?.("(prefers-color-scheme: dark)"); + mq?.addEventListener?.("change", applySystemTheme); + return () => mq?.removeEventListener?.("change", applySystemTheme); + }, []); + const submit = async (e: FormEvent) => { e.preventDefault(); setBusy(true); @@ -164,38 +178,83 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) { return (
-
-

{t("auth.title")}

- - - {error && ( -

- {error} -

- )} - +
+ {/* Accent glow and a grid fading out from the top, drawn from the + theme's own accent so it holds in every theme. */} +
+
+
+ SmartGift +

+ {t("auth.title")} +

+

{t("auth.subtitle")}

+
+ +
+ +
+ setPassword(e.target.value)} + /> + +
+
+ {error && ( +

+ {error} +

+ )} + {/* Inverted fill (text colour behind the surface colour): black in a + light theme, light in a dark one, like the reference design. */} + +

{t("auth.noAccount")}

+
); diff --git a/client/src/components/ImageLightbox.tsx b/client/src/components/ImageLightbox.tsx new file mode 100644 index 00000000..14fb92d0 --- /dev/null +++ b/client/src/components/ImageLightbox.tsx @@ -0,0 +1,44 @@ +/** + * @file ImageLightbox.tsx + * @description One image shown full size over the whole page; a click or + * Escape closes it. Rendered into `document.body`, so no pane, its stacking + * layer or a header drawn over a pane can cover it — opened from inside a + * pane's Chat view, a `fixed` overlay would otherwise stay in that pane's + * layer, under anything stacked above it. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect } from "react"; +import { createPortal } from "react-dom"; + +export function ImageLightbox({ + src, + alt, + onClose, +}: { + src: string; + alt: string; + onClose: () => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + return createPortal( +
+ {alt} +
, + document.body + ); +} diff --git a/client/src/components/PathPickButton.tsx b/client/src/components/PathPickButton.tsx index eb2ad22c..9f5048a0 100644 --- a/client/src/components/PathPickButton.tsx +++ b/client/src/components/PathPickButton.tsx @@ -12,7 +12,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { useState } from "react"; +import { useState, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { FolderOpen } from "lucide-react"; import { api } from "../lib/api"; @@ -27,12 +27,15 @@ export function PathPickButton({ start, onPick, showLabel = false, + buttonRef, }: { kind?: "dir" | "file"; /** The field's current value — the picker opens there. */ start?: string; onPick: (path: string) => void; showLabel?: boolean; + /** Lets a field beside it open the same picker (a click on an empty path). */ + buttonRef?: Ref; }) { const { t } = useTranslation(["lanes"]); const [busy, setBusy] = useState(false); @@ -61,6 +64,7 @@ export function PathPickButton({ return ( <> + )} + {dropAt === i && } + {visible(lane) && !folded && ( + { + // A read-only workspace has no home to open: it only unfolds. + if (readOnly) return setExpanded(lane.id, !expanded(lane.id)); + select(lane.id); + if (!expanded(lane.id)) setExpanded(lane.id, true); + }} + onToggle={() => setExpanded(lane.id, !expanded(lane.id))} + onNewSession={() => select(lane.id)} + onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))} + onMenu={(x, y) => setMenu({ lane, x, y })} + onRenameStart={() => setRenamingId(lane.id)} + onRenameEnd={(title) => void rename(lane, title)} + /> + )} + {expanded(lane.id) && !folded && ( + onMatches(lane.id, n) : undefined} + hidden={!visible(lane)} + /> + )} +
+ ); + })} {dropAt === sorted.length && (
@@ -400,6 +465,9 @@ export function WorkspaceTree() { ); } +/** A workspace's owner group: its user id, or "none". */ +const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id)); + /** Where a dragged workspace would land: a line on its parent's top edge. */ function DropLine() { return ( diff --git a/client/src/components/__tests__/AuthGate.test.tsx b/client/src/components/__tests__/AuthGate.test.tsx index 1791e35e..9b5248fe 100644 --- a/client/src/components/__tests__/AuthGate.test.tsx +++ b/client/src/components/__tests__/AuthGate.test.tsx @@ -5,17 +5,19 @@ * and a later 401 `EAUTH` brings the login page back. `usePermissions` and * `useWorkLanes` mirror the server's roles: an admin changes everything, an * analyst reads everything but works only in their own workspaces, a - * developer only ever sees (and so changes) their own. + * developer only ever sees (and so changes) their own. The login page wears the + * OS's light/dark; a sign-in swaps in the account's saved appearance. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "../../i18n"; const me = vi.fn(); const login = vi.fn(); +const prefs = vi.fn(); const LANES = [ { id: 1, owner_id: 5 }, { id: 2, owner_id: 6 }, @@ -25,6 +27,7 @@ vi.mock("../../lib/api", () => ({ api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() }, lanes: { list: () => Promise.resolve({ lanes: LANES }) }, + me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) }, }, })); @@ -52,6 +55,7 @@ const user = { id: 1, username: "mia", role: "developer" }; beforeEach(() => { me.mockReset(); login.mockReset(); + prefs.mockReset().mockResolvedValue({ prefs: {} }); localStorage.clear(); }); @@ -107,15 +111,15 @@ describe("AuthGate", () => { render(app); const name = await screen.findByLabelText(/username/i); - const pass = screen.getByLabelText(/password/i); + const pass = screen.getByLabelText(/^password$/i); await userEvent.type(name, "mia"); await userEvent.type(pass, "nope"); - await userEvent.click(screen.getByRole("button")); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(screen.queryByText("app")).toBeNull(); await userEvent.type(pass, "member-pass-1"); - await userEvent.click(screen.getByRole("button")); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); expect(await screen.findByText("app")).toBeInTheDocument(); expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1"); @@ -126,6 +130,32 @@ describe("AuthGate", () => { }); }); +describe("AuthGate theme", () => { + it("wears the OS theme while signed out and the account's own once signed in", async () => { + const html = document.documentElement; + // The previous user's choice, left in this browser. + localStorage.setItem("theme", "dark"); + localStorage.setItem("agenthub.theme.dark", "darcula"); + const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }; + vi.stubGlobal("matchMedia", () => mq); // the OS is light + me.mockResolvedValue({ mode: "server", user: null }); + prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } }); + login.mockResolvedValue({ user }); + render(app); + + await userEvent.type(await screen.findByLabelText(/username/i), "mia"); + expect(html.dataset.mode).toBe("light"); + expect(html.hasAttribute("data-theme")).toBe(false); + + await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + await waitFor(() => expect(html.dataset.theme).toBe("orca-dark")); + expect(html.dataset.mode).toBe("dark"); + expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark"); + vi.unstubAllGlobals(); + }); +}); + describe("usePermissions / useWorkLanes", () => { const as = async (state: unknown) => { cleanup(); diff --git a/client/src/components/__tests__/Select.test.tsx b/client/src/components/__tests__/Select.test.tsx index d91c4353..6a44cc9a 100644 --- a/client/src/components/__tests__/Select.test.tsx +++ b/client/src/components/__tests__/Select.test.tsx @@ -2,7 +2,8 @@ * @file Select.test.tsx * @description The shared Select as a WAI-ARIA listbox button: expanded state, * listbox/option roles with aria-selected, aria-activedescendant tracking, - * Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger. + * Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a + * list that flips up by the room in its split pane, not the window's. * @author Nguyễn Ngọc Trí Vĩ */ @@ -90,4 +91,21 @@ describe("Select (listbox button)", () => { fireEvent.click(screen.getByText("Apple")); expect(onChange).toHaveBeenCalledWith("a"); }); + + it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => { + const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect; + render( +
+ setQ(e.target.value)} + placeholder={t("chat.searchFiles")} + data-testid="chat-add-project-search" + className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none" + /> +
+ {!items || items.length === 0 ? ( +

{items ? t("chat.none") : "…"}

+ ) : ( + items.map((p) => ( + + ) : ( + + ) + } + label={p} + title={p} + testId={`chat-add-project-${p}`} + onClick={() => onPick(p)} + /> + )) + )} +
+ + ); +} + +type Sub = "project" | "context" | "recent" | "skills" | "plugins"; + +export function ChatAddMenu({ + cwd, + laneId = null, + skills, + onUpload, + onInsert, + onAttach, +}: { + cwd: string; + /** The workspace, for its Context files; none hides them. */ + laneId?: number | null; + /** Offer Skills and Plugins: Claude only, they are Claude Code's. */ + skills: boolean; + onUpload: () => void; + /** `/` picked. */ + onInsert: (command: string) => void; + /** An existing file picked: a project path, a context file, an upload. */ + onAttach: (items: Attachment[]) => void; +}) { + const { t } = useTranslation("run"); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [sub, setSub] = useState(null); + const [context, setContext] = useState(null); + const [recent, setRecent] = useState([]); + const [plugin, setPlugin] = useState(null); + const [lists, setLists] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + if (skills && !lists) void loadLists(cwd).then(setLists); + + const away = (e: MouseEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", away); + document.addEventListener("keydown", esc); + return () => { + document.removeEventListener("mousedown", away); + document.removeEventListener("keydown", esc); + }; + }, [open, skills, lists, cwd]); + + // Read again at each opening: either list may have grown since. + useEffect(() => { + if (!open) return; + setRecent(recentUploads()); + setContext(null); + if (laneId === null) return; + let live = true; + api.lanes + .sources(laneId) + .then( + (v) => + live && + setContext( + v.sources + .filter((s) => s.kind === "file" && !s.missing) + .map((s) => ({ path: `@${s.path}`, name: s.name })) + ) + ) + .catch(() => live && setContext([])); + return () => { + live = false; + }; + }, [open, laneId]); + + const close = () => { + setOpen(false); + setSub(null); + setPlugin(null); + }; + const pick = (command: string) => { + close(); + onInsert(command); + }; + const attach = (item: Attachment) => { + close(); + onAttach([item]); + }; + const manage = (tab: "skills" | "plugins") => { + close(); + navigate(`/cc-config?tab=${tab}`); + }; + + const entries = (items: Entry[], prefix: string, from?: string) => + items.length === 0 ? ( +

{lists ? t("chat.none") : "…"}

+ ) : ( + items.map((it) => ( + } + label={it.name} + title={[it.description, from].filter(Boolean).join("\n") || undefined} + testId={`chat-add-skill-${prefix}${it.name}`} + onClick={() => pick(`/${prefix}${it.name}`)} + /> + )) + ); + const shownPlugin = lists?.plugins.find((p) => p.name === plugin); + + return ( +
+ + {open && ( +
+ } + label={t("chat.upload")} + testId="chat-add-upload" + onHover={() => setSub(null)} + onClick={() => { + close(); + onUpload(); + }} + /> + } + label={t("chat.projectFiles")} + more + active={sub === "project"} + testId="chat-add-project" + onHover={() => setSub("project")} + onClick={() => setSub(sub === "project" ? null : "project")} + /> + {laneId !== null && ( + } + label={t("chat.contextFiles")} + more + active={sub === "context"} + testId="chat-add-context" + onHover={() => setSub("context")} + onClick={() => setSub(sub === "context" ? null : "context")} + /> + )} + } + label={t("chat.recentUploads")} + more + active={sub === "recent"} + testId="chat-add-recent" + onHover={() => setSub("recent")} + onClick={() => setSub(sub === "recent" ? null : "recent")} + /> + {skills && ( + <> + } + label={t("chat.skills")} + more + active={sub === "skills"} + testId="chat-add-skills" + onHover={() => setSub("skills")} + onClick={() => setSub(sub === "skills" ? null : "skills")} + /> + } + label={t("chat.plugins")} + more + active={sub === "plugins"} + testId="chat-add-plugins" + onHover={() => setSub("plugins")} + onClick={() => setSub(sub === "plugins" ? null : "plugins")} + /> + + )} + + {sub === "project" && ( +
+ attach(projectAttachment(rel))} /> +
+ )} + + {(sub === "context" || sub === "recent") && ( +
+
+ {(sub === "context" ? context : recent)?.length ? ( + (sub === "context" ? context! : recent).map((it) => ( + } + label={it.name} + title={it.path.replace(/^@/, "")} + testId={`chat-add-${sub}-${it.name}`} + onClick={() => attach(it)} + /> + )) + ) : ( +

+ {sub === "context" && !context ? "…" : t("chat.none")} +

+ )} +
+
+ )} + + {sub === "skills" && ( +
+
{entries(lists?.skills ?? [], "")}
+
+ } + label={t("chat.manageSkills")} + onClick={() => manage("skills")} + /> +
+ )} + + {sub === "plugins" && ( +
+
+ {lists && lists.plugins.length === 0 && ( +

{t("chat.none")}

+ )} + {!lists &&

…

} + {lists?.plugins.map((p) => ( + } + label={p.name} + more + active={plugin === p.name} + testId={`chat-add-plugin-${p.name}`} + onHover={() => setPlugin(p.name)} + onClick={() => setPlugin(plugin === p.name ? null : p.name)} + /> + ))} +
+
+ } + label={t("chat.managePlugins")} + onClick={() => manage("plugins")} + /> + {shownPlugin && ( +
+
+ {entries( + shownPlugin.items, + `${shownPlugin.name}:`, + t("chat.fromPlugin", { name: shownPlugin.name }) + )} +
+
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/client/src/components/run/ChatFilePreview.tsx b/client/src/components/run/ChatFilePreview.tsx new file mode 100644 index 00000000..c7e46930 --- /dev/null +++ b/client/src/components/run/ChatFilePreview.tsx @@ -0,0 +1,258 @@ +/** + * @file ChatFilePreview.tsx + * @description A file a Chat pane's session touched, shown the way Claude + * Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the + * content) and `FileListModal` (a list on the left, the picked file's preview + * on the right — Claude's Uploads dialog). Content comes only through routes + * that already guard it: a file inside the workspace through + * `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under + * `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached + * image through `GET /api/run/drop/:name`. Anything else shows its path only. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { Copy, FileText, Loader2, X } from "lucide-react"; +import { api, gitTarget } from "../../lib/api"; +import { copyText } from "../../lib/clipboard"; +import { useDialog } from "../../hooks/useDialog"; +import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; + +/** Where a Chat pane's files can be read from. */ +export interface PreviewScope { + laneId: number | null; + /** The workspace's own folder. */ + laneCwd: string; + /** The pane's folder — the workspace's, or the session's own worktree. */ + cwd: string; + sessionId: string; +} + +const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i; +const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/; + +export const baseName = (p: string) => p.split("/").pop() || p; +/** `report.v2.xlsx` → `XLSX`; none → "". */ +export const extOf = (p: string) => { + const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p)); + return m ? m[1]!.toUpperCase() : ""; +}; +/** An attached file's name without the drop dir's random prefix. */ +export const displayName = (p: string) => + DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p); + +type Content = + | { kind: "loading" } + | { kind: "image"; src: string } + | { kind: "text"; text: string; truncated: boolean } + | { kind: "none" }; + +function useContent(path: string, scope: PreviewScope): Content { + const [content, setContent] = useState({ kind: "loading" }); + useEffect(() => { + const drop = DROP_RE.exec(path); + if (drop) { + setContent( + IMAGE_RE.test(path) + ? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` } + : { kind: "none" } + ); + return; + } + let live = true; + const show = (p: Promise) => { + setContent({ kind: "loading" }); + p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" })); + }; + const base = scope.cwd.replace(/\/+$/, ""); + if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) { + // Claude Code's own files (memory): the config pages' guarded read. + if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) { + show( + api.ccConfig + .file(path) + .then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated })) + ); + } else setContent({ kind: "none" }); + return () => { + live = false; + }; + } + const rel = path.slice(base.length + 1); + const target = + base === scope.laneCwd.replace(/\/+$/, "") + ? scope.laneId + : gitTarget(scope.laneId, scope.sessionId); + if (IMAGE_RE.test(path)) { + setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) }); + return; + } + show( + api.lanes + .gitFile(target, rel) + .then((f) => + f.available && !f.binary + ? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated } + : { kind: "none" } + ) + ); + return () => { + live = false; + }; + }, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]); + return content; +} + +function Body({ path, scope }: { path: string; scope: PreviewScope }) { + const { t } = useTranslation("run"); + const content = useContent(path, scope); + if (content.kind === "loading") { + return ( +
+ +
+ ); + } + if (content.kind === "image") { + return ( +
+ {baseName(path)} +
+ ); + } + if (content.kind === "none") { + return ( +
+ +

{t("chat.outline.noPreview")}

+

{path}

+
+ ); + } + return ( +
+ {isMarkdown(path) ? ( + + ) : ( +
+          {content.text}
+        
+ )} + {content.truncated && ( +

{t("chat.outline.truncated")}

+ )} +
+ ); +} + +/** One file: header (name · EXT, Copy path, Close) over its content. */ +export function FilePreview({ + path, + scope, + onClose, + className = "", +}: { + path: string; + scope: PreviewScope; + onClose: () => void; + className?: string; +}) { + const { t } = useTranslation("run"); + return ( +
+
+ + {displayName(path).replace(/\.[A-Za-z0-9]+$/, "")} + + {extOf(path)} +
+ + +
+
+ +
+ ); +} + +/** Claude's Uploads dialog: the files on the left, the picked one on the right. */ +export function FileListModal({ + title, + paths, + scope, + onClose, +}: { + title: string; + paths: string[]; + scope: PreviewScope; + onClose: () => void; +}) { + const [picked, setPicked] = useState(paths[0] ?? ""); + const panelRef = useRef(null); + useDialog(true, panelRef, onClose); + // Into document.body, like ImageLightbox: a pane's own stacking layer + // would otherwise draw over a `fixed` overlay opened from inside it. + return createPortal( +
+
e.stopPropagation()} + className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl" + > +
+

{title}

+
    + {paths.map((p) => ( +
  • + +
  • + ))} +
+
+ {picked && } +
+
, + document.body + ); +} diff --git a/client/src/components/run/ChatOutline.tsx b/client/src/components/run/ChatOutline.tsx new file mode 100644 index 00000000..084eec4b --- /dev/null +++ b/client/src/components/run/ChatOutline.tsx @@ -0,0 +1,302 @@ +/** + * @file ChatOutline.tsx + * @description The right-hand panel of a Chat pane shown alone, laid out like + * Claude Desktop's activity panel: the session's Progress (its + * TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs + * it wrote or edited (name + EXT tag; a click previews the file), and "Used in + * this session" — the files attached to its prompts, the files it read and the + * memory files it used (a click opens the list with a preview), its skills + * and MCP servers (a click opens the Claude config page). Read from + * `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also + * uses for the collapsed button's count. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState, type ComponentType, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { + BookOpen, + Check, + ChevronDown, + FileCode, + FileImage, + FileSearch, + FileSpreadsheet, + FileText, + History, + Paperclip, + Plug, + X, +} from "lucide-react"; +import { api, type SessionOutline } from "../../lib/api"; +import { displayName, extOf } from "./ChatFilePreview"; + +const POLL_MS = 5000; +const EMPTY: SessionOutline = { + progress: [], + outputs: [], + files: [], + uploads: [], + memory: [], + skills: [], + mcp: [], +}; + +/** The session's outline, re-read on a visibility-gated poll. */ +export function useSessionOutline(sessionId: string): SessionOutline { + const [outline, setOutline] = useState(EMPTY); + useEffect(() => { + let live = true; + const load = () => + api.sessions + .outline(sessionId) + .then((o) => live && setOutline({ ...EMPTY, ...o })) + .catch(() => {}); + void load(); + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") void load(); + }, POLL_MS); + return () => { + live = false; + window.clearInterval(timer); + }; + }, [sessionId]); + return outline; +} + +export type OutlineList = "uploads" | "files" | "memory"; + +function fileIcon(path: string) { + const ext = extOf(path).toLowerCase(); + if (["xlsx", "xls", "csv", "tsv"].includes(ext)) return FileSpreadsheet; + if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return FileImage; + if ( + /^(c|cc|cpp|cs|css|go|h|html|java|js|jsx|json|kt|mjs|cjs|php|py|rb|rs|sh|sql|swift|ts|tsx|vue|ya?ml|toml)$/.test( + ext + ) + ) + return FileCode; + return FileText; +} + +function Section({ + title, + count, + action, + grow, + children, +}: { + title: string; + count?: number; + action?: ReactNode; + /** Takes the panel's free height and scrolls inside it. */ + grow?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(true); + return ( +
+
+ +
{action}
+
+ {open && ( +
+ {children} +
+ )} +
+ ); +} + +const rowClass = + "flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-[13px] text-fg-secondary enabled:hover:bg-surface-3"; + +/** A "Used in this session" row: icon, label, the first names, +N. */ +function UsedRow({ + icon: Icon, + label, + names, + onClick, + to, +}: { + icon: ComponentType<{ className?: string }>; + label: string; + names: string[]; + onClick?: () => void; + to?: string; +}) { + const shown = names.slice(0, 2).join(", "); + const inner = ( + <> + + {label} + {shown} + {names.length > 2 && ( + +{names.length - 2} + )} + + ); + const title = `${label}: ${names.join(", ")}`; + return to ? ( + + {inner} + + ) : ( + + ); +} + +const stem = (p: string) => displayName(p).replace(/\.[A-Za-z0-9]+$/, ""); + +export function ChatOutline({ + outline: o, + onClose, + onOpenFile, + onOpenList, +}: { + outline: SessionOutline; + onClose: () => void; + onOpenFile: (path: string) => void; + onOpenList: (list: OutlineList) => void; +}) { + const { t } = useTranslation("run"); + const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length; + const close = ( + + ); + + return ( + + ); +} diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 6a96eb4a..8b4b6f07 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -14,8 +14,9 @@ * tab) attaches to that tmux session only; nothing is started or resumed. * A Claude or Codex pane also has a Chat view (`PaneChat`): the session's * conversation laid OVER the terminal, which stays mounted and attached under - * it. Chat or terminal is the viewer's own Chat UI setting (Settings → - * Appearance, `lib/chatUi.ts`, per account) — one choice for every pane. + * it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle + * left of the pane's split buttons); the Chat UI setting (Settings → + * Appearance, `lib/chatUi.ts`) is only what a session shows first. * @author Nguyễn Ngọc Trí Vĩ */ @@ -28,7 +29,7 @@ import type { Lane } from "../../lib/types"; import { useAiTools } from "../../hooks/useAiTools"; import { TerminalView } from "./TerminalView"; import { PaneChat } from "./PaneChat"; -import { useChatUi } from "../../lib/chatUi"; +import { useSessionChat } from "../../lib/chatUi"; const CHAT_TOOLS = new Set(["claude", "codex"]); @@ -50,6 +51,8 @@ export interface LaneConsolePaneProps { onRunExit?: () => void; /** The terminal takes the keyboard when it appears (default). */ autoFocus?: boolean; + /** The only pane shown: its Chat view gets the right panel. */ + solo?: boolean; } /** The tmux session a lane's tab owns (see server/lib/pty-run.js). */ @@ -68,6 +71,7 @@ export function LaneConsolePane({ onNewSession, onRunExit, autoFocus, + solo = false, }: LaneConsolePaneProps) { const { t } = useTranslation("run"); const [handle, setHandle] = useState(null); @@ -78,11 +82,11 @@ export function LaneConsolePane({ // The tool of the run on screen — a session's own, which need not be the // workspace's; the workspace's with no run. const paneTool = handle?.tool ?? currentLane?.tool ?? "claude"; - const chat = useChatUi(); // A run learns its session id after it starts (Codex): the page's poll has it. const chatSession = handle ? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId) : null; + const chat = useSessionChat(chatSession); const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool); useEffect(() => { @@ -192,10 +196,12 @@ export function LaneConsolePane({ {chatable && chat && (
)} diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index ae1aee89..84f76552 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -5,84 +5,319 @@ * above a prompt box. It lies OVER the pane's terminal, which stays mounted and * attached underneath — a message is sent through that terminal * (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so - * nothing new reaches the server. A permission prompt or a menu still needs the - * terminal; the hint under the box says so. + * nothing new reaches the server. The box carries Claude Desktop's controls: + * the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's + * context files, recent uploads —, skills, plugins' skills), and for a + * Claude pane the Model/Effort chip — a change is typed into the pane as + * `/model ` / `/effort ` — and the permission mode, which only + * Shift+Tab changes in a running session, so the server steps it and reads + * every step back off the pane (`POST /api/run/:id/permission-mode`); it sits + * in the box beside `+`. A permission prompt or a menu still needs the terminal. + * Shown alone (`outline`), it also has Claude Desktop's activity panel + * (`ChatOutline`: progress, outputs, what the session used): collapsed into a + * file button with the output count until clicked (remembered per browser); + * a file clicked there opens its preview in the panel's place + * (`FilePreview`) — so does one in a turn's "Edited N files" card outside + * the workspace's folder —, a group + * of files a list with a preview (`FileListModal`). A file in a turn's + * "Edited N files" card opens in the Workspace's Changes panel + * (`openInChanges`) when it lies in the pane's folder. A send scrolls the + * conversation to its end and keeps it there (`follow`), so the message and + * the step running after it come into sight on their own. While the session's + * main agent is working (a prompt submitted, no Stop yet) an empty box shows + * Stop in Send's place: Esc pressed in the pane (`interruptRun`). * @author Nguyễn Ngọc Trí Vĩ */ -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ArrowUp } from "lucide-react"; +import { ArrowUp, FileText, Square } from "lucide-react"; import { ConversationView } from "../conversation/ConversationView"; -import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup"; -import { submitToRun } from "../../lib/panePaste"; +import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup"; +import { ChatAddMenu } from "./ChatAddMenu"; +import { addAttachments } from "../../lib/promptAttach"; +import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline"; +import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview"; +import { Select } from "../Select"; +import { interruptRun, submitToRun } from "../../lib/panePaste"; +import { useLiveRefresh } from "../../hooks/useLiveRefresh"; +import { openInChanges } from "../../lib/openInChanges"; import { toast } from "../../lib/toast"; +import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api"; + +const MODES: [PermissionMode, string][] = [ + ["default", "Default"], + ["acceptEdits", "AcceptEdits"], + ["plan", "Plan"], + ["auto", "Auto"], + ["bypassPermissions", "Bypass"], +]; +const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m); +const OUTLINE_KEY = "agenthub.chat.outlineOpen"; + +/** The pane's permission mode as it shows it; a pick steps it there. */ +function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) { + const { t } = useTranslation(["run", "lanes"]); + const [mode, setMode] = useState(initial ?? "default"); + const [busy, setBusy] = useState(false); + const read = useCallback( + () => + api.run + .permissionMode(runId) + .then((r) => isSettable(r.mode) && setMode(r.mode)) + .catch(() => {}), + [runId] + ); + useEffect(() => { + void read(); + }, [read]); + // Bypass is in a session's cycle only when it was started with it. + const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v); + return ( +
+ + variant="ghost" + label={t("run:fields.permissionMode")} + value={mode} + disabled={busy} + onChange={(v) => { + setBusy(true); + api.run + .setPermissionMode(runId, v) + .then((r) => setMode(r.mode)) + .catch((err: unknown) => { + toast.error( + t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" }) + ); + void read(); + }) + .finally(() => setBusy(false)); + }} + options={offered.map(([value, key]) => ({ + value, + label: t(`lanes:home.permission${key}`), + hint: t(`run:fields.permission${key}`), + }))} + /> +
+ ); +} export function PaneChat({ - runId, + run, sessionId, - tool, cwd, + laneId = null, + laneCwd = "", + outline = false, }: { - runId: string; + run: Pick; sessionId: string; - tool: string; cwd: string; + /** The workspace and its folder: a file under `cwd` is previewed through it. */ + laneId?: number | null; + laneCwd?: string; + /** The pane is shown alone: room for the right panel. */ + outline?: boolean; }) { const { t } = useTranslation("run"); + const runId = run.id; + const claude = run.tool === "claude"; const [prompt, setPrompt] = useState(""); const [images, setImages] = useState([]); const [uploading, setUploading] = useState(false); + const [follow, setFollow] = useState(0); + const [model, setModel] = useState(run.model ?? ""); + const [effort, setEffort] = useState(run.effort ?? ""); + const pickFiles = useRef<(() => void) | null>(null); + // Collapsed until asked for, like Claude Desktop's activity panel. + const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1"); + const openOutline = (open: boolean) => { + localStorage.setItem(OUTLINE_KEY, open ? "1" : "0"); + setOutlineOpen(open); + setPreview(null); + }; + const [preview, setPreview] = useState(null); + const [list, setList] = useState(null); + const activity = useSessionOutline(sessionId); + const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId }; const text = promptWithImages(prompt, images); + // The main agent is "working" from a prompt's submit to its Stop hook. + const [working, setWorking] = useState(false); + const readWorking = useCallback( + () => + api.agents + .list({ session_id: sessionId, status: "working" }) + // With session_id the server answers every agent of the session, its + // status filter ignored: check the status here. + .then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working"))) + .catch(() => {}), + [sessionId] + ); + useEffect(() => { + void readWorking(); + }, [readWorking]); + useLiveRefresh(readWorking, { + throttleMs: 500, + // While Stop shows, also poll: a missed agent_updated (the hook reached + // another dashboard on the same database, or the socket dropped) would + // otherwise leave Stop up after the turn ended. + intervalMs: working ? 5000 : undefined, + match: (msg) => + msg.type === "agent_updated" && + (msg.data as { session_id?: string } | undefined)?.session_id === sessionId, + }); + + // A control is a command typed into the pane, like the user would. + const command = (line: string) => { + const ok = submitToRun(runId, line); + if (!ok) toast.error(t("chat.notSent")); + return ok; + }; + const send = () => { if (!text || uploading) return; - if (!submitToRun(runId, text)) { - toast.error(t("chat.notSent")); - return; - } + if (!command(text)) return; + setFollow((n) => n + 1); setPrompt(""); images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); setImages([]); }; return ( -
-
- -
-
-
- +
+
+ { + // The workspace's Changes panel when the file is in its folder; + // else this view's own preview, when it has room for one. + if (laneId !== null && openInChanges(laneId, cwd, path)) return; + if (outline) setPreview(path); + }} /> -
- {t("chat.hint")} + {outline && !outlineOpen && !preview && ( + )} +
+
+
+
+ +
+ pickFiles.current?.()} + onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)} + onAttach={(items) => setImages((prev) => addAttachments(prev, items))} + /> + {claude && } +
+ {claude && ( + { + if (command(`/model ${v || "default"}`)) setModel(v); + }} + tool={run.tool} + effort={effort} + onEffortChange={(e) => { + if (command(`/effort ${e || "auto"}`)) setEffort(e); + }} + /> + )} + {working && !text ? ( + + ) : ( + + )} +
+
+
+ {outline && + (preview ? ( + setPreview(null)} + // Beside the chat when there is room, over it when not. + className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0" + /> + ) : ( + outlineOpen && ( + openOutline(false)} + onOpenFile={setPreview} + onOpenList={setList} + /> + ) + ))} + {list && ( + setList(null)} + /> + )}
); } diff --git a/client/src/components/run/PaneGrid.tsx b/client/src/components/run/PaneGrid.tsx index 212cc2e6..d39d8f27 100644 --- a/client/src/components/run/PaneGrid.tsx +++ b/client/src/components/run/PaneGrid.tsx @@ -29,6 +29,7 @@ import { ChevronDown, Columns2, GripVertical, + MessageSquare, Minimize2, Rows2, SquareTerminal, @@ -55,6 +56,7 @@ import { } from "../../lib/paneLayout"; import { AiToolIcon } from "../AiToolIcon"; import { RunDot, type RunState } from "./ConsoleTabs"; +import { setSessionChat, useSessionChat } from "../../lib/chatUi"; export const PANE_DRAG_TYPE = "application/x-agenthub-pane"; /** The slot being dragged: `dataTransfer` data cannot be read during dragover. */ @@ -191,9 +193,37 @@ function PaneMenu({ const ITEM = "flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary"; -/** A pane's split-right / split-down menus: move a hidden run beside - * `slot`, or start a new terminal or AI tool there. In a pane header when the view is - * split, beside Tools in the tab bar while one pane is shown. */ +/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view + * (`lib/chatUi.ts`); a session with no id yet has no chat to show. */ +function ChatToggle({ run }: { run?: RunHandle }) { + const { t } = useTranslation("lanes"); + const chat = useSessionChat(run?.sessionId); + if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null; + const sessionId = run.sessionId; + const label = chat ? t("panes.showTerminal") : t("panes.showChat"); + return ( + + ); +} + +/** A pane's chat/terminal toggle and split-right / split-down menus: move a + * hidden run beside `slot`, or start a new terminal or AI tool there. In a + * pane header when the view is split, beside Tools in the tab bar while one + * pane is shown. */ export function SplitMenus({ slot, shown, @@ -222,6 +252,7 @@ export function SplitMenus({ const full = shown.length >= MAX_PANES; return ( <> + (x.slot ?? 0) === slot)} /> {(["right", "bottom"] as const).map((edge) => ( r && focus(slot)} diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index 27841a3d..55fec4f7 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -6,8 +6,12 @@ * box (`WorkspaceHome.tsx`): * - `PromptEditor` — the prompt textarea with `/` (commands and skills, * Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter - * newlines, and pasted or dropped files (images as thumbnails, any other - * as a name chip) attached by their saved paths (`POST /api/run/drop-file`); + * newlines, and pasted, dropped or picked files (`pickFiles`, a composer's + * + menu; images as thumbnails, opened full size in `ImageLightbox`, any + * other as a name chip) attached by their saved paths (`POST /api/run/drop-file`, + * remembered for the + menu's Recent uploads), and a Files panel row + * dropped on it (`lib/promptAttach.ts`) as an `@path` chip; + * a borderless (`bare`) one grows with its text up to 40vh; * - `CwdAutocomplete` — the folder field the Add-workspace modal uses; * - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`) * or a fixed one (an AI Proxy's models), plus Custom…; @@ -19,7 +23,17 @@ * @author Nguyễn Ngọc Trí Vĩ */ -import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from "react"; import { useTranslation } from "react-i18next"; import { Check, @@ -42,9 +56,16 @@ import { } from "../../lib/api"; import type { CwdSuggestion, CcMdItem } from "../../lib/api"; import type { LaneTool } from "../../lib/types"; -import { Select } from "../Select"; +import { Select, menuRoom } from "../Select"; import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu"; import { isSubmitEnter } from "../../lib/keys"; +import { ImageLightbox } from "../ImageLightbox"; +import { + ATTACH_MIME, + addAttachments, + droppedAttachment, + rememberUploads, +} from "../../lib/promptAttach"; export interface SlashCommand { name: string; @@ -158,13 +179,16 @@ interface PromptEditorProps { * sends them with the text through `promptWithImages`. */ images: PromptImage[]; onImagesChange: Dispatch>; - /** Borderless, for a composer box that draws its own frame. */ + /** Borderless, for a composer box that draws its own frame — and grows + * with its text, from `rows` up to 40% of the screen, then scrolls. */ bare?: boolean; /** An image upload started (true) or settled (false): the caller holds its * send button meanwhile, or the prompt would go out without the image. */ onBusyChange?: (busy: boolean) => void; /** The workspace (id, or a pane's run id) whose panes read the files. */ lane?: number | string; + /** Set to a function that opens a file picker (a composer's + menu). */ + pickFiles?: MutableRefObject<(() => void) | null>; } /** An attached file: its path on the server's machine, and a local preview @@ -177,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string { return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "); } +/** A plain-text paste longer than this is attached as `pasted-text.txt`. */ +export const PASTE_AS_FILE_CHARS = 5000; + export function PromptEditor({ value, onChange, @@ -190,9 +217,18 @@ export function PromptEditor({ bare, onBusyChange, lane, + pickFiles, }: PromptEditorProps) { const { t } = useTranslation("run"); const taRef = useRef(null); + // A composer grows with its text: reset to its rows, then take what the + // text needs; `max-h-[40vh]` caps it and it scrolls from there. + useLayoutEffect(() => { + const ta = taRef.current; + if (!bare || !ta) return; + ta.style.height = "auto"; + ta.style.height = `${ta.scrollHeight}px`; + }, [bare, value]); const [trigger, setTrigger] = useState(null); const [active, setActive] = useState(0); const [files, setFiles] = useState([]); @@ -324,7 +360,7 @@ export function PromptEditor({ const busyRef = useRef(onBusyChange); busyRef.current = onBusyChange; useEffect(() => busyRef.current?.(uploading), [uploading]); - const attachImages = (list: FileList | null | undefined) => { + const attachImages = (list: FileList | File[] | null | undefined) => { const picked = Array.from(list ?? []); if (picked.length === 0) return false; setUpload("uploading"); @@ -336,6 +372,7 @@ export function PromptEditor({ url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined, name: f.name, })); + rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url }))); onImagesChange((prev) => [...prev, ...added]); }) .catch(() => setUpload("failed")); @@ -345,12 +382,11 @@ export function PromptEditor({ if (img.url) URL.revokeObjectURL(img.url); onImagesChange((prev) => prev.filter((x) => x !== img)); }; - useEffect(() => { - if (!viewing) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null); - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [viewing]); + const closeViewer = useCallback(() => setViewing(null), []); + // The composer's own "Upload or add files" opens this picker; what it picks + // takes the same path as a paste or a drop. + const fileInput = useRef(null); + if (pickFiles) pickFiles.current = () => fileInput.current?.click(); const handleKeyDown = (e: React.KeyboardEvent) => { if (kind && count > 0) { @@ -454,6 +490,19 @@ export function PromptEditor({ )}
)} + {pickFiles && ( + { + attachImages(e.target.files); + e.target.value = ""; + }} + /> + )}