Compare commits

...

19 Commits

Author SHA1 Message Date
nntrivi2001 c28395402b chore: v2.8.40 — a Codex account switch reaches running sessions 2026-10-29 15:59:19 +07:00
nntrivi2001 7798530528 fix(profiles): a Codex switch reaches running and new sessions
Codex >= 0.164 sends every request through a shared app-server daemon that
outlives its TUIs and keeps the login it started with, so writing a new
auth.json changed nothing: open sessions and even new ones stayed on the
old (out-of-quota) account. After a switch, restart that CODEX_HOME's
daemon when one runs; open TUIs reconnect to it on their own (verified on
0.164.0 with two keys). The response says so (following), and the toast
tells the user running sessions follow.
2026-10-29 15:54:20 +07:00
nntrivi2001 25f63d9ad3 fix(terminal): rebuild the WebGL glyph atlas when the page comes back
A browser can drop the atlas's GPU memory without a context-loss event
(sleep/resume, a long-backgrounded tab, display/zoom change on macOS),
leaving every cell painted with a clipped or foreign glyph.
2026-10-29 15:44:16 +07:00
nntrivi2001 d060d288fb fix(sessions): Top tools counts each tool call once
The session analysis page's Top tools list counted every event carrying a
tool_name, so each call was counted twice (its PreToolUse and its
PostToolUse) while the Tool calls total beside it counted once — a session
with 840 calls showed Bash at 1,533. The list now counts the same way as
the total: PreToolUse, or PostToolUse in a session with none (imported).
2026-10-29 14:53:45 +07:00
nntrivi2001 6099841cef fix(sessions): archiving a live session stops its pane
Archive on a session a live pane held only hid its row, and the list
deliberately does not bring such a tab back, so the pane kept running
with nothing on screen showing it — a member archived five sessions
believing them closed and kept ~2.7 GB of claude/omp/postgres-mcp alive
for up to two days. Archive on a live session is now a Stop (endRuns,
the tab X's confirm, DELETE /api/run/:id?archive=1) in the sidebar row,
the workspace home's Recents and their right-click menu.
2026-10-29 14:26:25 +07:00
nntrivi2001 d70a7c9403 chore: v2.8.39 — no one can take a colleague's display name 2026-10-29 13:47:31 +07:00
nntrivi2001 63eaa739d4 fix(accounts): a display name may not read as another account's
A member kept renaming themselves to a colleague's display name, plus
"(you)", so the CPU/RAM card and every owner list showed two of that person.
A display name is now refused (409 ENAMETAKEN) when, with marks, case,
spaces, punctuation, bracketed parts and look-alike Cyrillic/Greek letters
ignored, it equals another account's display name or username — in
Settings → Account and on the Users page alike. Keeping the name one already
has stays allowed, so an older clash does not block an admin's other edits.
2026-10-29 13:47:20 +07:00
nntrivi2001 9f8dc9082e chore: v2.8.38 — every user row shows its avatar 2026-10-29 13:40:39 +07:00
nntrivi2001 f1a90e120e fix(users): every user row shows its avatar, not only its initials
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own
picture with no user id, and the host stats never carried avatarAt, so every
card fell back to letters. The groups now carry avatarAt and the card passes
userId. The user editor's header, the pool board's member chips and the pool
member picker drew hand-made initials; they use UserAvatar too.
2026-10-29 13:40:28 +07:00
nntrivi2001 db5a7776cc chore: v2.8.37 — the Artifacts installs list is never cut off 2026-10-29 11:25:10 +07:00
nntrivi2001 6c2c094f64 fix(artifacts): the installs list floats over the page, never cut off
It was an absolute box inside the card list, whose overflow-hidden clipped it
at the last card, and it opened to the right off a button near the edge. It
is now a portal at the button: below it, above when there is no room, kept
inside the window, following scroll and resize.
2026-10-29 11:25:10 +07:00
nntrivi2001 609a35f251 chore: v2.8.36 — an HTML page opens in a modal over the screen (Changes Preview, Chat) 2026-10-29 11:20:54 +07:00
nntrivi2001 97c39d5e15 feat(files): an HTML page opens in a modal over the screen
- Changes panel: Preview beside Whole file opens the page in a modal
  (HtmlViewerModal, shared with the File Explorer) instead of inside the
  panel; Changes and Whole file stay as they were.
- Chat: an HTML file's card, Outputs row or a path in a reply (in the
  workspace or not) opens FilePreview as a modal — Preview | Code, versions,
  Save to Artifacts kept — rather than the side panel or the Changes panel.
- Esc closes both modals: they stop every key from reaching the view below,
  which also kept useDialog's document listener from seeing it.
2026-10-29 11:20:54 +07:00
nntrivi2001 4a82790734 chore: v2.8.35 — an HTML file's Preview sits beside Whole file 2026-10-29 10:47:39 +07:00
nntrivi2001 80000793f4 feat(files): an HTML file's Preview sits beside Whole file
The Changes panel's view toggle becomes Changes | Whole file | Preview for an
HTML file; the Preview/Raw button on the file's name row stays for markdown.
2026-10-29 10:47:39 +07:00
nntrivi2001 39bea78a9c chore: v2.8.34 — HTML page on Preview in the Changes panel (also from Changes) 2026-10-29 10:38:15 +07:00
nntrivi2001 364fe4a286 fix(files): an HTML file in the Changes panel shows as the page only on Preview
Changes stays the default view and Whole file shows the source, as for any
file. A Preview button — offered on the Changes diff too, where it was
missing — opens the page; Raw, Changes or Whole file go back to the source.
2026-10-29 10:38:15 +07:00
nntrivi2001 38ea78b489 chore: v2.8.33 — an HTML file opens as the page (Files panel, Chat preview, File Explorer) 2026-10-28 16:46:21 +07:00
nntrivi2001 7b5abc6ad9 feat(files): an HTML file opens as the page — Files panel, Chat preview, File Explorer
Clicking an .html/.htm file now renders it instead of showing its source or
downloading it:
- Changes/Files panel: a whole HTML file opens on Preview (Raw = the editor).
- Chat file preview: a workspace page brings its relative files along.
- File Explorer: double-click / Enter opens a viewer; Download stays on its
  header and right-click menu.

The page runs in the chat artifact's sandbox (iframe srcdoc, scripts, never
allow-same-origin). Its relative stylesheets (and their url()s), scripts,
images, media and icons are read by the dashboard through the existing
download routes and inlined as <style>/data: URLs (lib/htmlInline.ts: never
above the folder's root, ≤ 60 files / 30 MB) — the login cookie is
SameSite=Strict, so a frame could not fetch them itself, and no new route
serves files without it.
2026-10-28 16:46:07 +07:00
56 changed files with 4498 additions and 181 deletions
+6 -5
View File
@@ -1,4 +1,8 @@
{ {
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
},
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run:*)", "Bash(npm run:*)",
@@ -36,11 +40,8 @@
"Bash(node -e ':*)", "Bash(node -e ':*)",
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")", "Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
"Bash(claude --version)", "Bash(claude --version)",
"Bash(gh pr *)" "Bash(gh pr *)",
"Bash(grep *)"
] ]
},
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.32 (2026-10-28) — a Claude plan is the one Anthropic's profile endpoint reports now._ 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.8.40 (2026-10-29) — a Codex profile switch restarts Codex's app-server daemon so sessions follow._ 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -65,7 +65,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **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 <uuid>` (`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=<id>` — 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 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 <uuid>` (`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=<id>` — 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=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; 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. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; 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.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. - **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes. - **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane. - **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states. See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
/**
* @file HtmlFrame.tsx
* @description An HTML file rendered as a page, the one way every file view
* shows one (a Chat file preview, the Changes panel's Preview, the File
* Explorer's viewer): its relative CSS, scripts and images pulled in through
* the caller's guarded read (`lib/htmlInline.ts`), then run in the chat
* artifact's sandbox — `<iframe srcdoc>` with scripts but never the
* dashboard's origin (no `allow-same-origin`). Given no `html`, it reads the
* file itself through the same `fetchFile`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
/** A page read by `fetchFile` is shown up to this size. */
const HTML_MAX = 10 * 1024 * 1024;
export function HtmlFrame({
path,
html,
fetchFile,
title,
className = "",
testId = "html-frame",
}: {
/** The file's path relative to the place `fetchFile` reads from. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
/** Reads the page's relative files; none = the page as it is. */
fetchFile?: FetchFile | null;
title: string;
className?: string;
testId?: string;
}) {
const [doc, setDoc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let live = true;
// The page shown stays until its successor is ready (a version step, a
// re-read): no flash of the spinner between two renders of one file.
setFailed(false);
(async () => {
let text = html;
if (text === undefined) {
if (!fetchFile) throw new Error("nothing to read the page with");
const b = await fetchFile(path);
if (b.size > HTML_MAX) throw new Error("too big");
text = await blobText(b);
}
const page = text;
return fetchFile ? inlineHtml(page, path, fetchFile).catch(() => page) : page;
})().then(
(d) => live && setDoc(d),
() => live && setFailed(true)
);
return () => {
live = false;
};
}, [html, path, fetchFile]);
if (failed) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
<p className="break-all font-mono text-xs">{path}</p>
</div>
);
}
if (doc === null) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
return (
<iframe
data-testid={testId}
title={title}
srcDoc={artifactDocument("html", doc, false) ?? ""}
sandbox={frameSandbox("html")}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 bg-white ${className}`}
/>
);
}
/** A `FetchFile` over a download URL builder (a workspace's or a home's). */
export function fetchVia(url: (relPath: string) => string): FetchFile {
return async (rel) => {
const r = await fetch(url(rel), { credentials: "same-origin" });
if (!r.ok) throw new Error(String(r.status));
return r.blob();
};
}
+109
View File
@@ -0,0 +1,109 @@
/**
* @file HtmlViewerModal.tsx
* @description An HTML file shown as the page in a modal over the whole
* screen — the one viewer the File Explorer and the Changes panel's Preview
* open (the Chat's file preview opens its own panel as a modal instead, for
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
* artifact's sandbox, its relative files read through the caller's guarded
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
* underneath (the explorer's Delete) never fires from inside it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Download, X } from "lucide-react";
import { useDialog } from "../hooks/useDialog";
import { HtmlFrame } from "./HtmlFrame";
import type { FetchFile } from "../lib/htmlInline";
export function HtmlViewerModal({
path,
html,
fetchFile,
onDownload,
onClose,
testId = "html-viewer",
}: {
/** The file's path relative to the place `fetchFile` reads from; also its title. */
path: string;
/** The page's text; read through `fetchFile` when absent. */
html?: string;
fetchFile?: FetchFile | null;
onDownload?: () => void;
onClose: () => void;
testId?: string;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const name = path.slice(path.lastIndexOf("/") + 1);
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
onKeyDown={(e) => {
// Nothing reaches the view underneath — so Esc, which useDialog's
// document listener would never see, closes here.
e.stopPropagation();
if (e.key === "Escape") onClose();
}}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={name}
data-testid={testId}
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
{path}
</span>
{onDownload && (
<HeadButton label={t("fx.download")} onClick={onDownload}>
<Download className="h-4 w-4" />
</HeadButton>
)}
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
</div>
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
</div>
</div>,
document.body
);
}
function HeadButton({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
>
{children}
</button>
);
}
@@ -3,7 +3,8 @@
* @description Tests for the sidebar's workspace list: one flat row per * @description Tests for the sidebar's workspace list: one flat row per
* workspace in the stored order, a row expanding to its sessions (remembered), * workspace in the stored order, a row expanding to its sessions (remembered),
* a live session opening its pane and an ended one its transcript, the * a live session opening its pane and an ended one its transcript, the
* workspace's OWN sessions only, a session's state dot, rename and archive, * workspace's OWN sessions only, a session's state dot, rename and archive
* (a live one's pane stopped first),
* selection * selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the * by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, the status dot, and the * search, the row menu's items by kind and tool, the status dot, and the
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
await user.type(input, "Better name{Enter}"); await user.type(input, "Better name{Enter}");
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" }); expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
await user.click(screen.getByTestId("workspace-session-archive-s-live")); await user.click(screen.getByTestId("workspace-session-archive-s-live"));
// A live session's Archive stops its pane first — never a hidden pane left running.
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true }); expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
}); });
+6 -1
View File
@@ -683,7 +683,12 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
aria-expanded={isOpen} aria-expanded={isOpen}
className="flex w-full items-center gap-2 text-left disabled:cursor-default" className="flex w-full items-center gap-2 text-left disabled:cursor-default"
> >
<UserAvatar name={name} className="h-6 w-6 text-[10px]" /> <UserAvatar
name={name}
userId={g.userId}
avatarAt={g.avatarAt}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary"> <span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{name} {name}
</span> </span>
@@ -69,6 +69,7 @@ const stats: HostStats = {
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
host: { stats: vi.fn(), history: vi.fn() }, host: { stats: vi.fn(), history: vi.fn() },
me: { avatarUrl: () => null }, me: { avatarUrl: () => null },
users: { avatarUrl: () => null },
})); }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
+52 -2
View File
@@ -17,7 +17,8 @@
* (the clipboard outlives the dialog); upload files or whole folders (picker * (the clipboard outlives the dialog); upload files or whole folders (picker
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch; * or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
* drag items onto a folder to move them (Ctrl: copy); download a file as * drag items onto a folder to move them (Ctrl: copy); download a file as
* itself, anything else as one zip; a taken name asks Replace / Keep both / * itself, anything else as one zip — except an HTML file, which opens as the
* page (`HtmlViewer`, Download on its header and right-click menu); a taken name asks Replace / Keep both /
* Skip. Deleting forever asks first when it empties the Trash or touches * Skip. Deleting forever asks first when it empties the Trash or touches
* another user's. An admin's Trash opens on one folder per user (with its * another user's. An admin's Trash opens on one folder per user (with its
* item count); each folder is THAT user's Trash, to restore from or purge * item count); each folder is THAT user's Trash, to restore from or purge
@@ -90,6 +91,8 @@ import {
} from "../../lib/api"; } from "../../lib/api";
import type { Lane } from "../../lib/types"; import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format"; import { formatBytes } from "../../lib/format";
@@ -166,6 +169,7 @@ const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)); const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
const errCode = (e: unknown) => (e as { code?: string })?.code; const errCode = (e: unknown) => (e as { code?: string })?.code;
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir"; const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
const isHtml = (e: FsEntry) => !isFolder(e) && /\.html?$/i.test(e.name);
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase(); const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]); const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
const CODE = new Set([ const CODE = new Set([
@@ -498,8 +502,11 @@ export function FileBrowser({
}; };
// ── Actions ───────────────────────────────────────────────────────────── // ── Actions ─────────────────────────────────────────────────────────────
// An HTML page opens as itself (`HtmlViewer`); any other file downloads.
const [viewing, setViewing] = useState<FsEntry | null>(null);
const openEntry = (e: FsEntry) => { const openEntry = (e: FsEntry) => {
if (isFolder(e)) go(e.path); if (isFolder(e)) go(e.path);
else if (isHtml(e)) setViewing(e);
else download([e]); else download([e]);
}; };
const goUp = () => { const goUp = () => {
@@ -970,6 +977,14 @@ export function FileBrowser({
onClick={() => setMenu(null)} onClick={() => setMenu(null)}
data-testid="file-explorer" data-testid="file-explorer"
> >
{viewing && (
<HtmlViewer
entry={viewing}
place={target}
onDownload={() => download([viewing])}
onClose={() => setViewing(null)}
/>
)}
{cloneParent && ( {cloneParent && (
<CloneHereDialog <CloneHereDialog
open={cloning} open={cloning}
@@ -1656,13 +1671,18 @@ export function FileBrowser({
{menuItem ? ( {menuItem ? (
<> <>
<MenuItem onSelect={close(() => openEntry(menuItem))}> <MenuItem onSelect={close(() => openEntry(menuItem))}>
{isFolder(menuItem) ? t("fx.open") : t("fx.download")} {isFolder(menuItem) || isHtml(menuItem) ? t("fx.open") : t("fx.download")}
</MenuItem> </MenuItem>
{(isFolder(menuItem) || menuTargets.length > 1) && ( {(isFolder(menuItem) || menuTargets.length > 1) && (
<MenuItem onSelect={close(() => download(menuTargets))}> <MenuItem onSelect={close(() => download(menuTargets))}>
{t("fx.downloadZip")} {t("fx.downloadZip")}
</MenuItem> </MenuItem>
)} )}
{isHtml(menuItem) && menuTargets.length === 1 && (
<MenuItem onSelect={close(() => download([menuItem]))}>
{t("fx.download")}
</MenuItem>
)}
<MenuSeparator /> <MenuSeparator />
{!ro && ( {!ro && (
<MenuItem onSelect={close(() => putClip(menuTargets, true))}> <MenuItem onSelect={close(() => putClip(menuTargets, true))}>
@@ -1916,6 +1936,36 @@ export function FileExplorer({
); );
} }
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
* its relative CSS, scripts and images read through the same download route. */
function HtmlViewer({
entry,
place,
onDownload,
onClose,
}: {
entry: FsEntry;
place: FsPlace;
onDownload: () => void;
onClose: () => void;
}) {
const placeKey = JSON.stringify(place);
const fetchFile = useMemo(
() => fetchVia((p) => api.files.downloadUrl(place, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[placeKey]
);
return (
<HtmlViewerModal
path={entry.path}
fetchFile={fetchFile}
onDownload={onDownload}
onClose={onClose}
testId="fx-html-viewer"
/>
);
}
function HeadButton({ function HeadButton({
label, label,
onClick, onClick,
@@ -13,7 +13,10 @@
* side, each block with the diff's Undo / Keep while nothing * side, each block with the diff's Undo / Keep while nothing
* is unsaved): Changes folds the unchanged lines away, Whole file * is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a * shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an * whole markdown file can also be read rendered (Preview), an HTML
* file's Preview beside Whole file opens the page in a modal
* (`HtmlViewerModal`, its relative CSS, scripts and images read
* like downloads), and an
* image file is shown as the picture (`GET …/git/raw`) * image file is shown as the picture (`GET …/git/raw`)
* history — recent commits; opening one shows that commit's diff * history — recent commits; opening one shows that commit's diff
* browse — quick-open by path, or the repo's tracked file tree (the * browse — quick-open by path, or the repo's tracked file tree (the
@@ -77,7 +80,16 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import {
Suspense,
lazy,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ChevronDown, ChevronDown,
@@ -127,6 +139,8 @@ import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer"; import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { fetchVia } from "../HtmlFrame";
import { HtmlViewerModal } from "../HtmlViewerModal";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
import { dragProjectPath } from "../../lib/promptAttach"; import { dragProjectPath } from "../../lib/promptAttach";
@@ -185,6 +199,9 @@ function Delta({
); );
} }
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */ /** What the viewer shows as a picture (`readImageAt`'s list on the server). */
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i; const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
// CodeMirror is loaded only once a file is edited. // CodeMirror is loaded only once a file is edited.
@@ -874,10 +891,13 @@ export function LaneChangesPanel({
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified"); const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]); useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
const [whole, setWhole] = useState(false); const [whole, setWhole] = useState(false);
// A whole markdown file shown rendered instead of as source; per file, each // A whole markdown file shown rendered instead of as source; per file,
// one opening as Settings → Appearance says (`lib/markdownView.ts`). // opening as Settings → Appearance says (`lib/markdownView.ts`).
const [mdPreview, setMdPreview] = useState(readMarkdownPreview); const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]); useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
// An HTML file's page, open in the modal viewer (Preview beside Whole file).
const [htmlOpen, setHtmlOpen] = useState(false);
useEffect(() => setHtmlOpen(false), [openFile, mode]);
const [data, setData] = useState<LaneChanges | null>(null); const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones) // A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads // has no changes, review or history — only its files, which Files reads
@@ -2044,6 +2064,14 @@ export function LaneChangesPanel({
const dirty = draft !== null && draft !== text?.lines.join("\n"); const dirty = draft !== null && draft !== text?.lines.join("\n");
// Rendered only where the toggle is offered: a whole markdown file. // Rendered only where the toggle is offered: a whole markdown file.
const mdShown = mdPreview && whole && isMarkdown(openFile); const mdShown = mdPreview && whole && isMarkdown(openFile);
// An HTML page's relative CSS/scripts/images, read like a download — only
// from the working tree: a commit's page would get today's files.
const htmlFetch = useMemo(
() =>
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[range, JSON.stringify(target)]
);
dirtyRef.current = dirty; dirtyRef.current = dirty;
useUnsavedGuard(dirty); useUnsavedGuard(dirty);
const dirtyChangeRef = useRef(onDirtyChange); const dirtyChangeRef = useRef(onDirtyChange);
@@ -2560,6 +2588,31 @@ export function LaneChangesPanel({
))} ))}
</div> </div>
)} )}
{/* An HTML file opens as the page in a modal over the screen;
a commit's page once its text is read (Whole file). */}
{mode === "file" && isHtml(openFile) && (
<button
type="button"
data-testid="lane-file-preview"
disabled={dirty || (range === "commit" && !text)}
onClick={() => setHtmlOpen(true)}
className="flex items-center gap-1 rounded border border-border bg-surface-2 px-2 py-1 text-[11px] text-fg-secondary hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
<Eye className="h-3 w-3" />
{t("changes.preview")}
</button>
)}
{htmlOpen && openFile && (
<HtmlViewerModal
path={openFile}
// A commit's page is its text at that commit (no sibling
// files); a working one is read whole from the disk.
html={range === "commit" ? text?.lines.join("\n") : undefined}
fetchFile={htmlFetch}
onDownload={!local && range !== "commit" ? () => download(openFile) : undefined}
onClose={() => setHtmlOpen(false)}
/>
)}
</div> </div>
)} )}
</div> </div>
@@ -2969,6 +3022,7 @@ export function LaneChangesPanel({
{t("changes.annotate")} {t("changes.annotate")}
</button> </button>
)} )}
{/* An HTML file's Preview sits beside Whole file instead. */}
{whole && text && !dirty && isMarkdown(openFile) && ( {whole && text && !dirty && isMarkdown(openFile) && (
<button <button
type="button" type="button"
+12 -11
View File
@@ -14,7 +14,7 @@
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it * a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot, * owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in * **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane, * its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and * any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel). * Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -22,6 +22,7 @@
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { endRuns } from "../../lib/stopSession";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -307,22 +308,22 @@ function HomeBody({
} }
}; };
const archiveSession = async (s: Session) => { const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id); const run = liveRunFor(lane, liveRuns, s.id);
// A live one is stopped first, as the tab's X does (its own confirm):
// archiving only its row left a pane running that no list showed.
if (run) {
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
await patchSession(s, { archived: true });
return;
}
const wt = sessionWorktreeName(lane, s); const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog( const ok = await confirmDialog(
wt && !live wt
? { ? {
title: t("tree.archiveWorktreeTitle"), title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }), message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"), confirmLabel: t("tree.archiveWorktreeConfirm"),
} }
: live
? {
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
: { : {
title: t("tree.archiveSession"), title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"), confirmLabel: t("sessionMenu.archive"),
@@ -330,7 +331,7 @@ function HomeBody({
} }
); );
if (!ok) return; if (!ok) return;
if (wt && !live) { if (wt) {
try { try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => { await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err; if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
@@ -24,7 +24,7 @@
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that * The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A * loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session, * live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each * and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. `readOnly` (another user's * dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row * workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume. * opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
@@ -32,7 +32,7 @@
*/ */
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm"; import { endRuns } from "../../lib/stopSession";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react"; import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
}; };
const archive = async (s: Session) => { const archive = async (s: Session) => {
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s); if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
// A live one keeps running in its tab; archiving only hides its row. // A live one is stopped first, as the tab's X does: archiving only its row
if ( // left a pane running that no list showed any more.
runFor(s) && const run = runFor(s);
!(await confirmDialog({ if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}))
)
return;
await patch(s, { archived: true }); await patch(s, { archived: true });
}; };
const archiveWithWorktree = async (s: Session) => { const archiveWithWorktree = async (s: Session) => {
@@ -9,7 +9,8 @@
* purge there, sending the item's store) and * purge there, sending the item's store) and
* shows a Trash that could not be read as an error, never as empty; nests a * shows a Trash that could not be read as an error, never as empty; nests a
* user's workspaces under their home in the sidebar (under a fold-only row * user's workspaces under their home in the sidebar (under a fold-only row
* when the viewer may not open that home). * when the viewer may not open that home); opens an HTML file as the page
* instead of downloading it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -136,6 +137,38 @@ describe("FileExplorer", () => {
expect(api.files.list).toHaveBeenLastCalledWith(1, "src"); expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
}); });
it("opens an HTML file as the page, its stylesheet read through the download", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: "deck.html", path: "deck.html", type: "file", size: 9, mtime: 0 },
],
});
const body: Record<string, string> = {
"/dl/1/deck.html": '<link rel="stylesheet" href="s.css"><p>deck</p>',
"/dl/1/s.css": "p{color:red}",
};
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (u) => new Response(body[String(u)] ?? "", { status: 200 }));
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
open();
await screen.findByText("deck.html");
fireEvent.doubleClick(row("deck.html"));
const viewer = await screen.findByTestId("fx-html-viewer");
await waitFor(() =>
expect(within(viewer).getByTestId("html-frame").getAttribute("srcdoc")).toContain(
"<style>p{color:red}</style>"
)
);
expect(click).not.toHaveBeenCalled();
fireEvent.click(within(viewer).getByRole("button", { name: "Close" }));
expect(screen.queryByTestId("fx-html-viewer")).toBeNull();
fetchSpy.mockRestore();
click.mockRestore();
});
it("makes a folder inline", async () => { it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" }); vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup(); const user = userEvent.setup();
@@ -544,6 +544,71 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-editor")).toBeNull(); expect(screen.queryByTestId("lane-file-editor")).toBeNull();
}); });
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
entries: [{ name: "deck.html", path: "deck.html", dir: false, status: "??" }],
truncated: false,
});
vi.mocked(api.lanes.gitFile).mockResolvedValue({
available: true,
path: "deck.html",
rev: "work",
binary: false,
truncated: false,
lines: ["<p>deck</p>"],
sha: "c".repeat(40),
});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
try {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("deck.html"));
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
expect(screen.queryByTestId("html-frame")).toBeNull();
await user.click(screen.getByTestId("lane-file-preview"));
const modal = await screen.findByTestId("html-viewer");
const frame = await within(modal).findByTestId("html-frame");
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByTestId("html-viewer")).toBeNull());
// The source stayed where it was, under the modal.
expect(screen.getByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
} finally {
fetchSpy.mockRestore();
}
});
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
files: [
{ path: "deck.html", status: "??", untracked: true, added: 1, deleted: 0, binary: false },
],
})
);
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
try {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await user.click(await screen.findByTitle("deck.html"));
await screen.findByRole("button", { name: "Changes", pressed: true });
await user.click(await screen.findByTestId("lane-file-preview"));
const modal = await screen.findByTestId("html-viewer");
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
} finally {
fetchSpy.mockRestore();
}
});
it("shows an image file as the picture, with no edit control", async () => { it("shows an image file as the picture, with no edit control", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({ vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true, available: true,
+87 -10
View File
@@ -12,6 +12,8 @@
* text file it read, wrote or named (a generated report) anywhere else through * text file it read, wrote or named (a generated report) anywhere else through
* `GET /api/sessions/:id/file`. * `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout. * Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
* `FilePreview` with `modal` (an HTML page, from PaneChat) is the same view in
* a modal over the whole screen.
* `SkillListModal` is the same dialog over the session's skills, each one's * `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's * SKILL.md found by name (`plugin:name` from the plugin list, else the user's
* or the project's); a built-in skill has no file and shows by name only. * or the project's); a built-in skill has no file and shows by name only.
@@ -39,6 +41,7 @@ import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach"; import { dragSkill } from "../../lib/promptAttach";
import { readMarkdownPreview } from "../../lib/markdownView"; import { readMarkdownPreview } from "../../lib/markdownView";
import { MarkdownPreview } from "../MarkdownPreview"; import { MarkdownPreview } from "../MarkdownPreview";
import { HtmlFrame, fetchVia } from "../HtmlFrame";
import { CodeBlock } from "../conversation/CodeBlock"; import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme"; import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
@@ -85,6 +88,18 @@ export const extOf = (p: string) => {
export const displayName = (p: string) => export const displayName = (p: string) =>
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p); DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
/** A file inside the pane's folder: the git target that reads it and its path there. */
function workspaceFile(path: string, scope: PreviewScope) {
const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, "");
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) return null;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
return { target, rel: file.slice(base.length + 1) };
}
type Content = type Content =
| { kind: "loading" } | { kind: "loading" }
| { kind: "image"; src: string } | { kind: "image"; src: string }
@@ -121,8 +136,8 @@ function useContent(path: string, scope: PreviewScope): Content {
}; };
} }
const file = slashed(path); const file = slashed(path);
const base = slashed(scope.cwd).replace(/\/+$/, ""); const inside = workspaceFile(path, scope);
if (scope.laneId === null || !base || !file.startsWith(`${base}/`)) { if (!inside) {
// Claude Code's own files (memory, a skill): the config pages' guarded read. // Claude Code's own files (memory, a skill): the config pages' guarded read.
if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) { if (/\/\.claude\//.test(file) && !IMAGE_RE.test(file)) {
show( show(
@@ -147,11 +162,7 @@ function useContent(path: string, scope: PreviewScope): Content {
live = false; live = false;
}; };
} }
const rel = file.slice(base.length + 1); const { target, rel } = inside;
const target =
base === slashed(scope.laneCwd).replace(/\/+$/, "")
? scope.laneId
: gitTarget(scope.laneId, scope.sessionId);
if (IMAGE_RE.test(path)) { if (IMAGE_RE.test(path)) {
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) }); setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
return; return;
@@ -235,9 +246,28 @@ function defaultView(path: string): View {
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview"; return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
} }
function Body({ path, content, view }: { path: string; content: Content; view: View }) { function Body({
path,
content,
view,
scope,
}: {
path: string;
content: Content;
view: View;
scope: PreviewScope;
}) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const kind = artifactKind(path); const kind = artifactKind(path);
// A page in the workspace brings its relative CSS, scripts and images along.
const inside = kind === "html" ? workspaceFile(path, scope) : null;
const insideTarget = inside ? JSON.stringify(inside.target) : null;
const page = useMemo(() => {
if (!inside) return null;
const { target, rel } = inside;
return { rel, fetchFile: fetchVia((p) => api.lanes.gitDownloadUrl(target, p)) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insideTarget, inside?.rel]);
// An image the route refused (gone, server mode) shows like no preview. // An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null); const [broken, setBroken] = useState<string | null>(null);
const shown: Content = const shown: Content =
@@ -290,6 +320,18 @@ function Body({ path, content, view }: { path: string; content: Content; view: V
); );
} }
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />; if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
if (page && !shown.truncated) {
return (
<HtmlFrame
key={path}
path={page.rel}
html={shown.text}
fetchFile={page.fetchFile}
title={baseName(path)}
testId="artifact-frame"
/>
);
}
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />; return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
} }
@@ -317,6 +359,7 @@ export function FilePreview({
onClose, onClose,
className = "", className = "",
messages, messages,
modal = false,
}: { }: {
path: string; path: string;
scope: PreviewScope; scope: PreviewScope;
@@ -324,12 +367,16 @@ export function FilePreview({
className?: string; className?: string;
/** The conversation, for the versions its Write/Edit calls left. */ /** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[]; messages?: TranscriptMessage[];
/** A modal over the whole screen (backdrop, Esc closes) instead of a panel. */
modal?: boolean;
}) { }) {
const { t } = useTranslation("run"); const { t } = useTranslation("run");
const kind = artifactKind(path); const kind = artifactKind(path);
const loaded = useContent(path, scope); const loaded = useContent(path, scope);
const [view, setView] = useState<View>(() => defaultView(path)); const [view, setView] = useState<View>(() => defaultView(path));
const [full, setFull] = useState(false); const [full, setFull] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useDialog(modal, modalRef, onClose);
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
const versions = useMemo( const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []), () => (messages ? artifactVersions(messages, path) : []),
@@ -384,7 +431,9 @@ export function FilePreview({
<section <section
data-testid="chat-file-preview" data-testid="chat-file-preview"
className={ className={
full modal
? "flex h-[92vh] w-[96vw] max-w-7xl min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
: full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl" ? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}` : `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
} }
@@ -482,6 +531,7 @@ export function FilePreview({
<Share2 className="h-3.5 w-3.5" /> <Share2 className="h-3.5 w-3.5" />
)} )}
</button> </button>
{!modal && (
<button <button
type="button" type="button"
data-testid="artifact-expand" data-testid="artifact-expand"
@@ -492,6 +542,7 @@ export function FilePreview({
> >
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />} {full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button> </button>
)}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -503,9 +554,35 @@ export function FilePreview({
</button> </button>
</div> </div>
</header> </header>
<Body path={path} content={content} view={view} /> <Body path={path} content={content} view={view} scope={scope} />
</section> </section>
); );
if (modal) {
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={onClose}
onKeyDown={(e) => {
// Nothing reaches the view underneath — so Esc, which useDialog's
// document listener would never see, closes here.
e.stopPropagation();
if (e.key === "Escape") onClose();
}}
role="presentation"
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={displayName(path)}
onClick={(e) => e.stopPropagation()}
>
{panel}
</div>
</div>,
document.body
);
}
// Full screen: over the whole page, where a pane's edges cannot clip it. // Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel; return full ? createPortal(panel, document.body) : panel;
} }
+23 -5
View File
@@ -35,7 +35,9 @@
* of files or the used skills a list with a preview (`FileListModal`, * of files or the used skills a list with a preview (`FileListModal`,
* `SkillListModal`). Anything there drags into the prompt box. A file in a turn's * `SkillListModal`). Anything there drags into the prompt box. A file in a turn's
* "Edited N files" card opens in the Workspace's Changes panel * "Edited N files" card opens in the Workspace's Changes panel
* (`openInChanges`) when it lies in the pane's folder. The unsent prompt and * (`openInChanges`) when it lies in the pane's folder. An HTML file — a card,
* an Outputs row, a path in a reply — opens instead as the page, the same
* preview in a modal over the whole screen. The unsent prompt and
* its attachments are kept per session (`lib/chatDraft.ts`), so a reload * its attachments are kept per session (`lib/chatDraft.ts`), so a reload
* leaves them as typed; switching to the terminal pastes them into its input * leaves them as typed; switching to the terminal pastes them into its input
* box and back brings that box's text here (`ChatToggle`). A send scrolls the * box and back brings that box's text here (`ChatToggle`). A send scrolls the
@@ -582,6 +584,12 @@ export const PaneChat = memo(function PaneChat({
setPreview(null); setPreview(null);
}; };
const [preview, setPreview] = useState<string | null>(null); const [preview, setPreview] = useState<string | null>(null);
// An HTML file shown as the page, in a modal over the screen.
const [htmlView, setHtmlView] = useState<string | null>(null);
const show = useCallback(
(path: string) => (/\.html?$/i.test(path) ? setHtmlView(path) : setPreview(path)),
[]
);
// The side panel's width as a fraction of the pane: the dragged one, else // The side panel's width as a fraction of the pane: the dragged one, else
// a default per content (a preview wants more room than the panel). // a default per content (a preview wants more room than the panel).
const [dragged, setDragged] = useState(storedSide); const [dragged, setDragged] = useState(storedSide);
@@ -757,20 +765,21 @@ export const PaneChat = memo(function PaneChat({
// not the workspace's Changes panel a plain file link goes to. // not the workspace's Changes panel a plain file link goes to.
const openArtifact = useCallback( const openArtifact = useCallback(
(named: string) => { (named: string) => {
setPreview(resolveIn(cwd, named)); show(resolveIn(cwd, named));
}, },
[cwd] [cwd, show]
); );
const openFile = useCallback( const openFile = useCallback(
(named: string) => { (named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder. // A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
const path = resolveIn(cwd, named); const path = resolveIn(cwd, named);
if (/\.html?$/i.test(path)) return show(path);
// The workspace's Changes panel when the file is in its folder; // The workspace's Changes panel when the file is in its folder;
// else this view's own preview. // else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return; if (laneId !== null && openInChanges(laneId, cwd, path)) return;
setPreview(path); setPreview(path);
}, },
[laneId, cwd] [laneId, cwd, show]
); );
// Before the session starts the CLI may be asking in the terminal (trust // Before the session starts the CLI may be asking in the terminal (trust
@@ -984,7 +993,7 @@ export const PaneChat = memo(function PaneChat({
<ChatOutline <ChatOutline
outline={activity} outline={activity}
onClose={() => openOutline(false)} onClose={() => openOutline(false)}
onOpenFile={setPreview} onOpenFile={show}
onOpenList={setList} onOpenList={setList}
/> />
) )
@@ -1006,6 +1015,15 @@ export const PaneChat = memo(function PaneChat({
/> />
</div> </div>
)} )}
{htmlView && (
<FilePreview
path={htmlView}
scope={scope}
messages={convMessages}
onClose={() => setHtmlView(null)}
modal
/>
)}
{list === "skills" && ( {list === "skills" && (
<SkillListModal <SkillListModal
title={t("chat.outline.skills")} title={t("chat.outline.skills")}
+14 -1
View File
@@ -468,7 +468,8 @@ export const TerminalView = memo(function TerminalView({
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`): // A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would // fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown. // reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight; const hasBox = () =>
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit(); if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the // Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went // page here, and having to click it first meant the first thing typed went
@@ -761,6 +762,16 @@ export const TerminalView = memo(function TerminalView({
refit(); refit();
}; };
document.fonts?.addEventListener("loadingdone", onFontLoaded); document.fonts?.addEventListener("loadingdone", onFontLoaded);
// The browser may drop or reuse the WebGL glyph atlas's GPU memory without
// a context-loss event (sleep/resume, a long-backgrounded tab, a display or
// zoom change on macOS): xterm keeps slicing the old coordinates and every
// cell shows a clipped or foreign glyph while the buffer stays correct.
// Rebuilding the atlas when the page comes back is cheap and redraws clean.
const onPageShown = () => {
if (document.visibilityState === "visible") term.clearTextureAtlas();
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
// Settings → Appearance changed the terminal's font or size: an open // Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing. // pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => { const onFontPref = () => {
@@ -779,6 +790,8 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null; searchRef.current = null;
resizeObserver.disconnect(); resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded); document.fonts?.removeEventListener("loadingdone", onFontLoaded);
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref); window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert); term.textarea?.removeEventListener("focus", reassert);
detachIme(); detachIme();
@@ -217,6 +217,17 @@ describe("the artifact view", () => {
cleanup(); cleanup();
}); });
it("shows a page as a modal over the screen, closed by Esc", async () => {
const onClose = vi.fn();
render(<FilePreview path="/w/page.html" scope={scope} onClose={onClose} modal />);
const dialog = await screen.findByRole("dialog", { name: "page.html" });
expect(dialog.parentElement?.parentElement).toBe(document.body);
expect(await screen.findByTestId("artifact-frame")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-expand")).toBeNull();
fireEvent.keyDown(screen.getByTestId("artifact-tab-code"), { key: "Escape" });
expect(onClose).toHaveBeenCalled();
});
it("steps back to an older version the conversation wrote", async () => { it("steps back to an older version the conversation wrote", async () => {
html(["<p>two</p>"]); html(["<p>two</p>"]);
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({ const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
@@ -242,7 +253,9 @@ describe("the artifact view", () => {
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc") ((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two"); ).toContain("two");
fireEvent.click(versions.querySelector("button")!); fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>"); await vi.waitFor(() =>
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>")
);
cleanup(); cleanup();
}); });
@@ -262,11 +262,11 @@ function LabelEdit({
function useRunner(load: () => Promise<unknown>) { function useRunner(load: () => Promise<unknown>) {
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const run = useCallback( const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => { async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
setBusy(key); setBusy(key);
try { try {
await fn(); const r = await fn();
toast.success(success); toast.success(typeof success === "function" ? success(r) : success);
await load(); await load();
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err); toast.error(err);
@@ -399,7 +399,14 @@ function OtherProfiles({
void run( void run(
`switch:${p.name}`, `switch:${p.name}`,
() => api.toolProfiles.activate(tool, p.name), () => api.toolProfiles.activate(tool, p.name),
t("toolProfiles.switchSuccess", { name: p.label, tool: label }) (r) =>
t(
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
{
name: p.label,
tool: label,
}
)
) )
} }
className={btnCls} className={btnCls}
@@ -800,9 +807,7 @@ function ProfileUsageLine({
/** Org / seat / plan line shared by the active card and each profile row. */ /** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) { function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter( const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
Boolean
);
if (parts.length === 0) return null; if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>; return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
} }
+9 -1
View File
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import type { AuthUser, ServerUserUsage } from "../../lib/api"; import type { AuthUser, ServerUserUsage } from "../../lib/api";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
import { UserAvatar } from "./UserAvatar";
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"]; export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
/** A role, every user, or the users in no pool group. */ /** A role, every user, or the users in no pool group. */
@@ -168,7 +169,14 @@ export function MemberPicker({
checked={chosen.has(u.id)} checked={chosen.has(u.id)}
onChange={(on) => setMany([u.id], on)} onChange={(on) => setMany([u.id], on)}
label={ label={
<span> <span className="inline-flex items-center gap-1.5">
<UserAvatar
name={u.displayName || u.username}
userId={u.id}
avatarAt={u.avatarAt}
letters={1}
className="h-5 w-5 text-[10px]"
/>
{u.username} {u.username}
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>} {u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
{note?.(u)} {note?.(u)}
+8 -6
View File
@@ -36,6 +36,7 @@ import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon"; import { AiToolIcon } from "../AiToolIcon";
import { MemberPicker } from "./MemberPicker"; import { MemberPicker } from "./MemberPicker";
import { UserAvatar } from "./UserAvatar";
/** Pool tools with their labels, in display order. */ /** Pool tools with their labels, in display order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [ export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
@@ -363,12 +364,13 @@ export function PoolGroups({
} ${isDragged(drag) ? "opacity-40" : ""}`} } ${isDragged(drag) ? "opacity-40" : ""}`}
data-testid="pool-member" data-testid="pool-member"
> >
<span <UserAvatar
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent" name={name}
aria-hidden userId={id}
> avatarAt={u?.avatarAt}
{name.charAt(0)} letters={1}
</span> className="h-4 w-4 text-[9px]"
/>
{name} {name}
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>} {u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
{isAdmin && from && ( {isAdmin && from && (
+3 -1
View File
@@ -4,7 +4,9 @@
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET * (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one — * /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the * or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account. * sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
* Health's per-user CPU/RAM card.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -33,6 +33,7 @@ import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { passwordProblems } from "../../lib/passwordRules"; import { passwordProblems } from "../../lib/passwordRules";
import { PasswordRules } from "./PasswordRules"; import { PasswordRules } from "./PasswordRules";
import { UserAvatar } from "./UserAvatar";
import { useDialog } from "../../hooks/useDialog"; import { useDialog } from "../../hooks/useDialog";
import { Switch } from "../Switch"; import { Switch } from "../Switch";
import { Checkbox } from "../Checkbox"; import { Checkbox } from "../Checkbox";
@@ -243,12 +244,12 @@ export function UserEditDialog({
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl" className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
> >
<div className="flex items-start gap-3 border-b border-border px-5 py-4"> <div className="flex items-start gap-3 border-b border-border px-5 py-4">
<span <UserAvatar
aria-hidden name={shown}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent" userId={user.id}
> avatarAt={user.avatarAt}
{shown.slice(0, 2)} className="h-10 w-10 text-sm"
</span> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary"> <h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
{t("edit.title", { name: user.displayName || user.username })} {t("edit.title", { name: user.displayName || user.username })}
@@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
updateGroup: vi.fn(), updateGroup: vi.fn(),
deleteGroup: vi.fn(), deleteGroup: vi.fn(),
})); }));
const users = vi.hoisted(() => ({ list: vi.fn() })); const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
const aiTools = vi.hoisted(() => ({ const aiTools = vi.hoisted(() => ({
list: vi.fn(), list: vi.fn(),
ompSharing: vi.fn(), ompSharing: vi.fn(),
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api"; import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() })); const users = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
avatarUrl: () => null,
}));
const confirmDialog = vi.hoisted(() => vi.fn()); const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog })); vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() })); const lanes = vi.hoisted(() => ({ list: vi.fn() }));
@@ -16,6 +16,7 @@ const users = vi.hoisted(() => ({
setQuotaDefault: vi.fn(), setQuotaDefault: vi.fn(),
update: vi.fn(), update: vi.fn(),
get: vi.fn(), get: vi.fn(),
avatarUrl: () => null,
})); }));
const lanes = vi.hoisted(() => ({ list: vi.fn() })); const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
-2
View File
@@ -505,8 +505,6 @@
"changes.loadMore": "Load more commits", "changes.loadMore": "Load more commits",
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it", "changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
"changes.splitter": "Resize the changes panel", "changes.splitter": "Resize the changes panel",
"tree.archiveLiveTitle": "Archive a live session?",
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
"tree.state.awaiting": "waiting for you", "tree.state.awaiting": "waiting for you",
"tree.state.error": "error", "tree.state.error": "error",
"tree.state.live": "live", "tree.state.live": "live",
+1
View File
@@ -1396,6 +1396,7 @@
"noActive": "{{tool}} holds no login on this machine.", "noActive": "{{tool}} holds no login on this machine.",
"noProfiles": "Could not load the profiles.", "noProfiles": "Could not load the profiles.",
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.", "switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.", "deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
"loginHint": "Add another {{tool}} login by signing in from a terminal first:", "loginHint": "Add another {{tool}} login by signing in from a terminal first:",
"subtitle": { "subtitle": {
+1
View File
@@ -79,6 +79,7 @@
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).", "EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
"EBADPASSWORD": "Password cannot be empty.", "EBADPASSWORD": "Password cannot be empty.",
"EBADEMAIL": "That email address does not look valid.", "EBADEMAIL": "That email address does not look valid.",
"ENAMETAKEN": "Another account already uses that name. Pick one that is yours.",
"EBADFIELD": "One of the values is not valid.", "EBADFIELD": "One of the values is not valid.",
"ENOLANE": "That workspace no longer exists.", "ENOLANE": "That workspace no longer exists.",
"ENOTOWNED": "That workspace is not this user's any more.", "ENOTOWNED": "That workspace is not this user's any more.",
-2
View File
@@ -505,8 +505,6 @@
"changes.loadMore": "Tải thêm commit", "changes.loadMore": "Tải thêm commit",
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi", "changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
"changes.splitter": "Đổi độ rộng khung thay đổi", "changes.splitter": "Đổi độ rộng khung thay đổi",
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
"tree.state.awaiting": "đang chờ bạn", "tree.state.awaiting": "đang chờ bạn",
"tree.state.error": "lỗi", "tree.state.error": "lỗi",
"tree.state.live": "đang chạy", "tree.state.live": "đang chạy",
+1
View File
@@ -1384,6 +1384,7 @@
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.", "noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
"noProfiles": "Không tải được danh sách hồ sơ.", "noProfiles": "Không tải được danh sách hồ sơ.",
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.", "switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.", "deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:", "loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
"subtitle": { "subtitle": {
+1
View File
@@ -79,6 +79,7 @@
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).", "EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
"EBADPASSWORD": "Mật khẩu không được để trống.", "EBADPASSWORD": "Mật khẩu không được để trống.",
"EBADEMAIL": "Địa chỉ email không hợp lệ.", "EBADEMAIL": "Địa chỉ email không hợp lệ.",
"ENAMETAKEN": "Tên này đã có tài khoản khác dùng. Hãy chọn tên của chính bạn.",
"EBADFIELD": "Có một giá trị không hợp lệ.", "EBADFIELD": "Có một giá trị không hợp lệ.",
"ENOLANE": "Workspace đó không còn tồn tại.", "ENOLANE": "Workspace đó không còn tồn tại.",
"ENOTOWNED": "Workspace đó không còn thuộc người này.", "ENOTOWNED": "Workspace đó không còn thuộc người này.",
@@ -0,0 +1,53 @@
/**
* @file htmlInline.test.ts
* @description An HTML preview's relative CSS, scripts and images are inlined
* from the place's own files, never from above its root or off a URL.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { inlineHtml, resolveRef } from "../htmlInline";
const files: Record<string, string> = {
"site/css/app.css": "body{background:url(../img/bg.png)}",
"site/js/app.js": "window.ok=1",
"site/img/logo.png": "PNG",
"site/img/bg.png": "BG",
};
const fetchFile = vi.fn(async (p: string) => {
if (!(p in files)) throw new Error("404");
return new Blob([files[p]!]);
});
describe("resolveRef", () => {
it("resolves against the page's folder, from the root on a leading slash", () => {
expect(resolveRef("site", "css/a.css?v=2#x")).toBe("site/css/a.css");
expect(resolveRef("site/sub", "../img/a%20b.png")).toBe("site/img/a b.png");
expect(resolveRef("site", "/top.css")).toBe("top.css");
});
it("skips URLs, anchors and anything above the root", () => {
for (const r of ["https://x/a.js", "//cdn/a.js", "data:x", "#top", "", "../../etc/passwd"])
expect(resolveRef("site", r)).toBeNull();
});
});
describe("inlineHtml", () => {
it("inlines stylesheets (with their url()s), scripts and images", async () => {
const html = `<!doctype html><html><head><link rel="stylesheet" href="css/app.css">
<script src="js/app.js"></script><script src="https://cdn.example/x.js"></script></head>
<body><img src="img/logo.png" srcset="img/logo@2x.png 2x"><img src="missing.png"></body></html>`;
const out = await inlineHtml(html, "site/index.html", fetchFile);
expect(out.startsWith("<!doctype html>")).toBe(true);
expect(out).toContain(`<style>body{background:url("data:image/png;base64,${btoa("BG")}")}</style>`);
expect(out).toContain(`src="data:text/javascript;base64,${btoa("window.ok=1")}"`);
expect(out).toContain(`src="data:image/png;base64,${btoa("PNG")}"`);
expect(out).not.toContain("srcset");
expect(out).toContain('src="https://cdn.example/x.js"');
expect(out).toContain('src="missing.png"');
expect(fetchFile).not.toHaveBeenCalledWith(expect.stringContaining("cdn"));
});
it("returns the page untouched when nothing is local", async () => {
const html = "<p>hi <a href='https://x'>x</a></p>";
expect(await inlineHtml(html, "a.html", fetchFile)).toBe(html);
});
});
+3 -1
View File
@@ -684,6 +684,8 @@ export interface HostUsageGroup {
username?: string; username?: string;
/** The user's display name, null when none is set (absent from an older server). */ /** The user's display name, null when none is set (absent from an older server). */
displayName?: string | null; displayName?: string | null;
/** When the user's avatar was last set (null = none; absent from an older server). */
avatarAt?: string | null;
/** % of the whole machine's CPU (100 = every core busy) over the sample. */ /** % of the whole machine's CPU (100 = every core busy) over the sample. */
cpu: number; cpu: number;
/** Resident memory, bytes (shared pages count in every process). */ /** Resident memory, bytes (shared pages count in every process). */
@@ -1399,7 +1401,7 @@ export const api = {
), ),
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */ /** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
activate: (tool: string, name: string) => activate: (tool: string, name: string) =>
request<{ ok: true; name: string; savedOutgoing: string | null }>( request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
`/tool-profiles/${encodeURIComponent(tool)}/activate`, `/tool-profiles/${encodeURIComponent(tool)}/activate`,
{ method: "POST", body: JSON.stringify({ name }) } { method: "POST", body: JSON.stringify({ name }) }
), ),
+230
View File
@@ -0,0 +1,230 @@
/**
* @file htmlInline.ts
* @description An HTML file made whole for its sandboxed preview. The page
* runs in an `<iframe srcdoc>` with no same-origin access, so it can fetch
* nothing from the dashboard — and the dashboard's login cookie is
* SameSite=Strict, so even a URL it could reach would answer 401. Instead the
* DASHBOARD reads the page's relative stylesheets, scripts, images, media and
* the `url(...)`s inside its CSS through a read route that already guards
* them (the caller's `fetchFile`: a workspace's or a home's download), and
* writes them into the page as `<style>` and `data:` URLs before it renders.
* Only paths inside the page's own place are followed (`..` above its root is
* skipped), and a page can pull at most `MAX_ASSETS` files / `MAX_BYTES` in
* all, so a big site never stalls the viewer. A reference that cannot be read
* is left as it was. `fetch()`/XHR from the page's own script, ES module
* imports and links to other pages still do not work.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** Reads one file by its path relative to the place's root; throws when it cannot. */
export type FetchFile = (relPath: string) => Promise<Blob>;
const MAX_ASSETS = 60;
const MAX_BYTES = 30 * 1024 * 1024;
const MIME: Record<string, string> = {
css: "text/css",
js: "text/javascript",
mjs: "text/javascript",
json: "application/json",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
mp4: "video/mp4",
webm: "video/webm",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
};
const mimeOf = (p: string) =>
MIME[(/\.([A-Za-z0-9]+)$/.exec(p)?.[1] || "").toLowerCase()] ?? "application/octet-stream";
/** The folder part of a relative path ("" at the root). */
export const dirOf = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
/**
* A page's reference as a path from the place's root, or null when it is not
* a local file (a URL, `//host`, `data:`, an anchor) or climbs above the root.
* A leading `/` counts from the root, as a site served from it would.
*/
export function resolveRef(dir: string, ref: string): string | null {
const r = ref.trim();
if (!r || r.startsWith("#") || r.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(r)) return null;
let p = r.replace(/[?#].*$/, "");
try {
p = decodeURIComponent(p);
} catch {
// A stray % is a literal one.
}
const out: string[] = [];
for (const seg of (p.startsWith("/") ? p : `${dir}/${p}`).split("/")) {
if (!seg || seg === ".") continue;
if (seg === "..") {
if (!out.length) return null;
out.pop();
} else out.push(seg);
}
return out.length ? out.join("/") : null;
}
/** A Blob's text / bytes, through FileReader where Blob lacks the methods (jsdom). */
export function blobText(b: Blob): Promise<string> {
if (typeof b.text === "function") return b.text();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(String(r.result));
r.onerror = () => fail(r.error);
r.readAsText(b);
});
}
function blobBytes(b: Blob): Promise<ArrayBuffer> {
if (typeof b.arrayBuffer === "function") return b.arrayBuffer();
return new Promise((ok, fail) => {
const r = new FileReader();
r.onload = () => ok(r.result as ArrayBuffer);
r.onerror = () => fail(r.error);
r.readAsArrayBuffer(b);
});
}
function base64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(s);
}
const URL_RE = /url\(\s*(['"]?)([^'")]+?)\1\s*\)/gi;
/** Inline the relative resources of `html` (the file at `htmlPath`). */
export async function inlineHtml(
html: string,
htmlPath: string,
fetchFile: FetchFile
): Promise<string> {
const doc = new DOMParser().parseFromString(html, "text/html");
const cache = new Map<string, Promise<Blob | null>>();
let count = 0;
let bytes = 0;
let changed = false;
const read = (rel: string): Promise<Blob | null> => {
let p = cache.get(rel);
if (!p) {
if (count >= MAX_ASSETS) return Promise.resolve(null);
count++;
p = fetchFile(rel)
.then((b) => {
if (bytes + b.size > MAX_BYTES) return null;
bytes += b.size;
return b;
})
.catch(() => null);
cache.set(rel, p);
}
return p;
};
const dataUrl = async (rel: string): Promise<string | null> => {
const b = await read(rel);
return b ? `data:${mimeOf(rel)};base64,${base64(await blobBytes(b))}` : null;
};
/** CSS with its own relative `url(...)`s made data URLs (from the CSS's folder). */
const inlineCss = async (css: string, dir: string): Promise<string> => {
const refs = new Map<string, string | null>();
for (const m of css.matchAll(URL_RE)) {
const rel = resolveRef(dir, m[2]!);
if (rel && !refs.has(rel)) refs.set(rel, null);
}
await Promise.all([...refs.keys()].map(async (rel) => refs.set(rel, await dataUrl(rel))));
return css.replace(URL_RE, (all, _q, ref: string) => {
const rel = resolveRef(dir, ref);
const url = rel ? refs.get(rel) : null;
return url ? `url("${url}")` : all;
});
};
const dir = dirOf(htmlPath);
const jobs: Promise<void>[] = [];
for (const link of doc.querySelectorAll<HTMLLinkElement>("link[rel~='stylesheet' i][href]")) {
const rel = resolveRef(dir, link.getAttribute("href")!);
if (!rel) continue;
jobs.push(
(async () => {
const b = await read(rel);
if (!b) return;
const style = doc.createElement("style");
const media = link.getAttribute("media");
if (media) style.setAttribute("media", media);
style.textContent = await inlineCss(await blobText(b), dirOf(rel));
link.replaceWith(style);
changed = true;
})()
);
}
for (const style of doc.querySelectorAll("style")) {
const css = style.textContent || "";
if (!/url\(/i.test(css)) continue;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
style.textContent = out;
changed = true;
}
})
);
}
for (const el of doc.querySelectorAll<HTMLElement>("[style*='url(' i]")) {
const css = el.getAttribute("style")!;
jobs.push(
inlineCss(css, dir).then((out) => {
if (out !== css) {
el.setAttribute("style", out);
changed = true;
}
})
);
}
const attrs: [string, string][] = [
["script[src]", "src"],
["img[src]", "src"],
["source[src]", "src"],
["video[src]", "src"],
["audio[src]", "src"],
["video[poster]", "poster"],
["input[type='image' i][src]", "src"],
["link[rel~='icon' i][href]", "href"],
];
for (const [sel, attr] of attrs) {
for (const el of doc.querySelectorAll(sel)) {
const rel = resolveRef(dir, el.getAttribute(attr)!);
if (!rel) continue;
jobs.push(
dataUrl(rel).then((url) => {
if (!url) return;
el.setAttribute(attr, url);
// A srcset beside it would win over the inlined src and still miss.
if (el.hasAttribute("srcset")) el.removeAttribute("srcset");
changed = true;
})
);
}
}
await Promise.all(jobs);
if (!changed) return html;
const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype/i.test(html) ? "<!doctype html>\n" : "";
return doctype + doc.documentElement.outerHTML;
}
+2 -1
View File
@@ -6,7 +6,8 @@
* message. Both ask first when any pane holds an AI agent (it may be * message. Both ask first when any pane holds an AI agent (it may be
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps. * mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a * Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
* session's right-click menu (`SessionMenu`). * session's right-click menu (`SessionMenu`), and a live session's Archive
* (`WorkspaceSessionList`, `WorkspaceHome`) — archiving never leaves a pane running.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
+41 -6
View File
@@ -48,7 +48,8 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ChevronRight, ChevronRight,
@@ -1629,12 +1630,41 @@ function DetailDialog({
); );
} }
/** "N installs" on a card; a click lists who installed it, when, and where. */ /** "N installs" on a card; a click lists who installed it, when, and where.
* The list floats over the page (a portal at the button, below it or above
* when there is no room, kept inside the window): inside the card list's
* `overflow-hidden` it was cut off at the last card. */
function InstallsButton({ item }: { item: Artifact }) { function InstallsButton({ item }: { item: Artifact }) {
const { t } = useTranslation("artifacts"); const { t } = useTranslation("artifacts");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [data, setData] = useState<ArtifactInstalls | null>(null); const [data, setData] = useState<ArtifactInstalls | null>(null);
const box = useRef<HTMLSpanElement>(null); const box = useRef<HTMLSpanElement>(null);
const pop = useRef<HTMLDivElement>(null);
const [at, setAt] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
if (!open) return setAt(null);
const place = () => {
const b = box.current?.getBoundingClientRect();
const p = pop.current;
if (!b || !p) return;
const w = p.offsetWidth;
const h = p.offsetHeight;
const gap = 4;
const below = b.bottom + gap + h <= window.innerHeight - 8 || b.top - gap - h < 8;
setAt({
left: Math.max(8, Math.min(b.left, window.innerWidth - w - 8)),
top: below ? b.bottom + gap : b.top - gap - h,
});
};
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open, data]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -1647,7 +1677,8 @@ function InstallsButton({ item }: { item: Artifact }) {
toast.error(err); toast.error(err);
}); });
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false); const n = e.target as Node;
if (!box.current?.contains(n) && !pop.current?.contains(n)) setOpen(false);
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false); if (e.key === "Escape") setOpen(false);
@@ -1670,12 +1701,15 @@ function InstallsButton({ item }: { item: Artifact }) {
> >
{t("installs", { count: item.installs })} {t("installs", { count: item.installs })}
</button> </button>
{open && ( {open &&
createPortal(
<div <div
ref={pop}
role="dialog" role="dialog"
aria-label={t("installsTitle")} aria-label={t("installsTitle")}
data-testid="artifact-installs" data-testid="artifact-installs"
className="absolute left-0 z-30 mt-1 w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl" style={at ? { left: at.left, top: at.top } : { left: 0, top: 0, visibility: "hidden" }}
className="fixed z-[60] w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
> >
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p> <p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
{data === null ? ( {data === null ? (
@@ -1728,7 +1762,8 @@ function InstallsButton({ item }: { item: Artifact }) {
)} )}
</ul> </ul>
)} )}
</div> </div>,
document.body
)} )}
</span> </span>
); );
@@ -362,6 +362,11 @@ describe("Artifacts", () => {
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument(); expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
expect(within(box).getByText(/khoa/)).toBeInTheDocument(); expect(within(box).getByText(/khoa/)).toBeInTheDocument();
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument(); expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
// Over the page, outside the card list that clips its overflow — and a
// click inside it keeps it open.
expect(box.parentElement).toBe(document.body);
await user.click(within(box).getByText(/khoa/));
expect(screen.getByTestId("artifact-installs")).toBeInTheDocument();
await user.keyboard("{Escape}"); await user.keyboard("{Escape}");
expect(screen.queryByTestId("artifact-installs")).toBeNull(); expect(screen.queryByTestId("artifact-installs")).toBeNull();
}); });
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.32 (rebuild client to match) UI build v2.8.40 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+4 -4
View File
@@ -89,9 +89,9 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode | | `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared | | `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees). In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login). `GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
@@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited | | `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) | | `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` | | `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` | | `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL`; `409 ENAMETAKEN` for a display name that reads as another account's display name or username (marks, case, punctuation, bracketed parts like "(you)" and look-alike letters ignored; keeping the name you have is always allowed) |
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` | | `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends | | `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none | | `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none |
@@ -3036,7 +3036,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
DELETE /api/tool-profiles/:tool Body: { name } DELETE /api/tool-profiles/:tool Body: { name }
``` ```
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins. The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
### AI Proxy profiles ### AI Proxy profiles
+17 -4
View File
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -372,7 +372,13 @@ Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
workspace list's owner groups, the Users page and its audit log, the Edit user workspace list's owner groups, the Users page and its audit log, the Edit user
form — it shows their display name when one is set, else the username (the form — it shows their display name when one is set, else the username (the
Users page keeps `@username` beside a display name). Logging in, the CLI and Users page keeps `@username` beside a display name). Logging in, the CLI and
the Linux account always use the username. the Linux account always use the username. No one can pose as a colleague: a
display name that reads as another account's display name or username — marks,
case, spaces, punctuation, a bracketed part like "(you)" and look-alike letters
from other alphabets ignored — is refused (`409 ENAMETAKEN`), whether the user
sets it in Settings → Account or an admin does on the Users page. A name an
account already has is kept on an unrelated save; rename an old clash from the
Users page.
Under S3 an admin's folder browser opens on their own home too; ticking Under S3 an admin's folder browser opens on their own home too; ticking
**Server folder** opens the host account's home (`/home/<host>`), where a **Server folder** opens the host account's home (`/home/<host>`), where a
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.32", "version": "2.8.40",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.32", "version": "2.8.40",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.32", "version": "2.8.40",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
@@ -222,6 +222,26 @@ describe("Settings → Account (/api/me)", () => {
assert.equal(bad.body.error.code, "EBADEMAIL"); assert.equal(bad.body.error.code, "EBADEMAIL");
}); });
it("refuses a display name that reads as another account's (ENAMETAKEN)", async () => {
assert.equal(auth.nameKey("Nguyễn Ngọc Trí Vĩ (you)"), auth.nameKey("nguyen.ngoc-tri VI"));
assert.equal(auth.nameKey("Đào"), "dao");
// Cyrillic о and а in place of Latin letters.
assert.equal(auth.nameKey("Mіа Trоn"), auth.nameKey("Mia Tron"));
const leo = await login("leo", "member-pass-2");
// mia's display name is "Mia Tran"; boss has none, only a username.
for (const name of ["MIA TRẦN (you)", "Mia\u200b Tran", "mia", "Bóss"]) {
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
assert.equal(r.status, 409, name);
assert.equal(r.body.error.code, "ENAMETAKEN");
}
assert.equal(auth.findUser("leo").display_name, "Leo N");
// Keeping one's own name, or a name of one's own, still works.
for (const name of ["Leo N", "Leo Nguyen"]) {
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
assert.equal(r.status, 200, name);
}
});
it("changes its own password only with the old one, keeping this login", async () => { it("changes its own password only with the old one, keeping this login", async () => {
const leo = await login("leo", "member-pass-2"); const leo = await login("leo", "member-pass-2");
const other = await login("leo", "member-pass-2"); const other = await login("leo", "member-pass-2");
+27
View File
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
); );
}); });
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-b", "bob@example.com");
profiles.saveProfile("codex");
assert.equal(profiles.activateProfile("codex", "alice").following, false);
const log = path.join(ROOT, "codex-argv.log");
const bin = path.join(ROOT, "fake-codex.sh");
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
fs.mkdirSync(path.dirname(sock), { recursive: true });
fs.writeFileSync(sock, "");
process.env.AGENTHUB_CODEX_BIN = bin;
try {
assert.equal(profiles.activateProfile("codex", "bob").following, true);
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
await new Promise((r) => setTimeout(r, 20));
assert.equal(
fs.readFileSync(log, "utf8").trim(),
`${process.env.CODEX_HOME} app-server daemon restart`
);
} finally {
delete process.env.AGENTHUB_CODEX_BIN;
}
});
it("refuses to switch away from an unsaved login", () => { it("refuses to switch away from an unsaved login", () => {
login("acct-a", "alice@example.com"); login("acct-a", "alice@example.com");
profiles.saveProfile("codex"); profiles.saveProfile("codex");
+6
View File
@@ -93,6 +93,12 @@ describe("GET /api/sessions/:id/stats tool totals", () => {
assert.equal(body.tool_call_count, 2); assert.equal(body.tool_call_count, 2);
assert.equal(body.distinct_tool_count, 1); assert.equal(body.distinct_tool_count, 1);
}); });
it("counts each call once in tools_used, not its Pre and Post events", async () => {
const one = (await get("/api/sessions/ua-1/stats")).body.tools_used;
assert.ok(one.every((t) => t.count === 1));
const two = (await get("/api/sessions/ua-2/stats")).body.tools_used;
assert.deepEqual(two, [{ tool_name: "Read", count: 2 }]);
});
}); });
describe("GET /api/events labels", () => { describe("GET /api/events labels", () => {
+6
View File
@@ -2547,10 +2547,16 @@ const stmts = {
FROM events FROM events
WHERE session_id = ? AND tool_name IS NOT NULL WHERE session_id = ? AND tool_name IS NOT NULL
`), `),
// One row per call, like sessionToolCallTotals: a call is its PreToolUse,
// or its PostToolUse in a session that has none (imported). Counting every
// event with a tool_name counted each call twice.
sessionToolUsageCounts: db.prepare(` sessionToolUsageCounts: db.prepare(`
SELECT tool_name, COUNT(*) as count SELECT tool_name, COUNT(*) as count
FROM events FROM events
WHERE session_id = ? AND tool_name IS NOT NULL WHERE session_id = ? AND tool_name IS NOT NULL
AND event_type = CASE
WHEN EXISTS (SELECT 1 FROM events WHERE session_id = ? AND event_type = 'PreToolUse')
THEN 'PreToolUse' ELSE 'PostToolUse' END
GROUP BY tool_name GROUP BY tool_name
ORDER BY count DESC ORDER BY count DESC
LIMIT 15 LIMIT 15
+45 -1
View File
@@ -20,7 +20,8 @@
* admin on the Users page (`server/routes/users.js`), where every change * admin on the Users page (`server/routes/users.js`), where every change
* needs the admin's own password again (`confirmPassword`, throttled). * needs the admin's own password again (`confirmPassword`, throttled).
* An account also carries an optional display name and email * An account also carries an optional display name and email
* (`validateProfile`), which its owner edits in Settings → Account without * (`validateProfile`; no two accounts may read as one name — `assertNameFree`,
* ENAMETAKEN), which its owner edits in Settings → Account without
* that step-up; changing their OWN password there needs the old one and * that step-up; changing their OWN password there needs the old one and
* keeps the login it was changed from (`setPassword`'s `keepToken`). * keeps the login it was changed from (`setPassword`'s `keepToken`).
* - Two password bars: an admin may GIVE any non-empty password (a create or a * - Two password bars: an admin may GIVE any non-empty password (a create or a
@@ -247,6 +248,47 @@ function validateProfile(body) {
return out; return out;
} }
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
const CONFUSABLE = {
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
};
/**
* The name two accounts may not share: no marks (Vĩ = Vi), đ = d, any case,
* look-alike letters folded, and only letters and digits outside brackets —
* so "Nguyễn Ngọc Trí Vĩ (you)" and "nguyen.ngoc-tri vi" are both one name.
*/
function nameKey(s) {
return String(s || "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/\([^)]*\)|\[[^\]]*\]/g, "")
.replace(/[đð]/g, "d")
.replace(/./gu, (c) => CONFUSABLE[c] ?? c)
.replace(/[^\p{L}\p{N}]/gu, "");
}
/**
* Refuse (ENAMETAKEN) a display name that reads as another account's display
* name or username, so no one can pose as a colleague. Keeping the name one
* already has is always allowed — a clash from before this rule is no reason
* to block an admin's unrelated edit.
*/
function assertNameFree(userId, fields) {
if (!("display_name" in fields) || !fields.display_name) return;
const key = nameKey(fields.display_name);
if (!key) return;
const own = sql("SELECT display_name FROM users WHERE id = ?").get(userId);
if (own && own.display_name === fields.display_name) return;
const rows = sql("SELECT id, username, display_name FROM users WHERE id != ?").all(userId);
if (rows.some((u) => nameKey(u.username) === key || nameKey(u.display_name) === key)) {
throw err("ENAMETAKEN", "displayName: another account already uses that name");
}
}
/** Write `validateProfile`'s result for one account. */ /** Write `validateProfile`'s result for one account. */
function setProfile(userId, fields) { function setProfile(userId, fields) {
for (const [col, value] of Object.entries(fields)) { for (const [col, value] of Object.entries(fields)) {
@@ -812,6 +854,8 @@ module.exports = {
unassignWorkspace, unassignWorkspace,
ownedWorkspaces, ownedWorkspaces,
validateProfile, validateProfile,
nameKey,
assertNameFree,
setProfile, setProfile,
validatePassword, validatePassword,
validateOwnPassword, validateOwnPassword,
+2 -1
View File
@@ -555,12 +555,13 @@ async function usage(t0, t1, p0, p1, mem) {
const round = (n) => Math.round(n * 10) / 10; const round = (n) => Math.round(n * 10) / 10;
/** A user's group fields: id, username and display name (every viewer reads them). */ /** A user's group fields: id, username, display name and avatar version (every viewer reads them). */
const userExtra = (u) => ({ const userExtra = (u) => ({
kind: "user", kind: "user",
userId: u.id, userId: u.id,
username: u.username, username: u.username,
displayName: u.displayName ?? null, displayName: u.displayName ?? null,
avatarAt: u.avatarAt ?? null,
}); });
async function collect() { async function collect() {
+35 -2
View File
@@ -6,7 +6,9 @@
* swap it back in later, touching nothing but that login. * swap it back in later, touching nothing but that login.
* *
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API * - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
* key). Config, sessions, skills and history stay where they are. * key). Config, sessions, skills and history stay where they are. A
* switch then restarts Codex's shared app-server daemon when one runs,
* so running and new sessions follow the new login (`follow`).
* - `omp`: every credential Oh My Pi stores, read with `omp auth export` * - `omp`: every credential Oh My Pi stores, read with `omp auth export`
* and put back with `omp auth import -` (secrets on stdin, never argv). * and put back with `omp auth import -` (secrets on stdin, never argv).
* Import MERGES, so a switch first empties omp's own credential tables in * Import MERGES, so a switch first empties omp's own credential tables in
@@ -85,6 +87,15 @@ const codex = {
writeJson(codexAuthFile(), secret); writeJson(codexAuthFile(), secret);
return [codexAuthFile()]; return [codexAuthFile()];
}, },
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
// outlives every TUI and holds the login it started with: a new auth.json
// reached neither a running session nor a NEW one, which kept the old
// (often out-of-quota) account. Restarting the daemon makes it read the new
// login, and every open TUI reconnects to it on its own (verified on
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
follow() {
return restartCodexDaemon();
},
identify(auth) { identify(auth) {
if (auth.tokens && auth.tokens.id_token) { if (auth.tokens && auth.tokens.id_token) {
const c = jwtClaims(auth.tokens.id_token) || {}; const c = jwtClaims(auth.tokens.id_token) || {};
@@ -107,6 +118,26 @@ const codex = {
}, },
}; };
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
const codexDaemonSocket = () =>
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
function restartCodexDaemon() {
if (!fs.existsSync(codexDaemonSocket())) return false;
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
if (!bin) return false;
cp.execFile(
bin,
["app-server", "daemon", "restart"],
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
(e) => {
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
}
);
return true;
}
// ── Oh My Pi ─────────────────────────────────────────────────────────── // ── Oh My Pi ───────────────────────────────────────────────────────────
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp"); const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
} }
const target = readProfile(tool, name); const target = readProfile(tool, name);
const written = a.write(target.secret, live); const written = a.write(target.secret, live);
return { ok: true, name, savedOutgoing, written }; // `following`: running sessions take the new login without a restart of their own.
const following = typeof a.follow === "function" ? a.follow() : false;
return { ok: true, name, savedOutgoing, written, following };
} }
function deleteProfile(tool, name) { function deleteProfile(tool, name) {
+5
View File
@@ -489,6 +489,11 @@ const paths = {
items: { type: "string" }, items: { type: "string" },
description: "Files the switch wrote.", description: "Files the switch wrote.",
}, },
following: {
type: "boolean",
description:
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
},
}, },
}, },
"Switched." "Switched."
+2
View File
@@ -197,6 +197,7 @@ const { recordAccountChange } = require("../lib/audit");
const ACCOUNT_STATUS = { const ACCOUNT_STATUS = {
EBADFIELD: 400, EBADFIELD: 400,
EBADEMAIL: 400, EBADEMAIL: 400,
ENAMETAKEN: 409,
EBADPASSWORD: 400, EBADPASSWORD: 400,
EWEAKPASSWORD: 400, EWEAKPASSWORD: 400,
EBADIMAGE: 400, EBADIMAGE: 400,
@@ -262,6 +263,7 @@ router.patch(
sameOriginGuard, sameOriginGuard,
accountWrite(async (_req, user, body) => { accountWrite(async (_req, user, body) => {
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email }); const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
auth.assertNameFree(user.id, fields);
auth.setProfile(user.id, fields); auth.setProfile(user.id, fields);
return { out: accountView(user.id), action: "own profile" }; return { out: accountView(user.id), action: "own profile" };
}) })
+1 -1
View File
@@ -702,7 +702,7 @@ router.get("/:id/file", async (req, res) => {
function sessionStats(sessionId) { function sessionStats(sessionId) {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0; const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId); const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
const tools = stmts.sessionToolUsageCounts.all(sessionId); const tools = stmts.sessionToolUsageCounts.all(sessionId, sessionId);
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count. // Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse. // A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {}; const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
+2
View File
@@ -171,6 +171,7 @@ router.get("/tmux", (_req, res) => {
const STATUS = { const STATUS = {
EBADUSER: 400, EBADUSER: 400,
EBADEMAIL: 400, EBADEMAIL: 400,
ENAMETAKEN: 409,
ENOTOWNED: 409, ENOTOWNED: 409,
EBADPASSWORD: 400, EBADPASSWORD: 400,
EWEAKPASSWORD: 400, EWEAKPASSWORD: 400,
@@ -562,6 +563,7 @@ router.patch(
// 1. Validate everything — nothing is written until all of it passes. // 1. Validate everything — nothing is written until all of it passes.
const profile = auth.validateProfile(body); const profile = auth.validateProfile(body);
auth.assertNameFree(u.id, profile);
const roleChange = body.role !== undefined && body.role !== u.role; const roleChange = body.role !== undefined && body.role !== u.role;
if (roleChange) { if (roleChange) {
if (self) throw ESELF("you cannot change your own role"); if (self) throw ESELF("you cannot change your own role");