Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef | |||
| d70a7c9403 | |||
| 63eaa739d4 | |||
| 9f8dc9082e | |||
| f1a90e120e | |||
| db5a7776cc | |||
| 6c2c094f64 | |||
| 609a35f251 | |||
| 97c39d5e15 | |||
| 4a82790734 | |||
| 80000793f4 | |||
| 39bea78a9c | |||
| 364fe4a286 | |||
| 38ea78b489 | |||
| 7b5abc6ad9 |
@@ -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": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>>();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }) }
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+7
-1
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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" };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) || {};
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user