Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db5a7776cc | |||
| 6c2c094f64 | |||
| 609a35f251 | |||
| 97c39d5e15 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.35 (2026-10-29) — an HTML file's Preview sits beside Whole file in the Changes panel._ 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.37 (2026-10-29) — the Artifacts page's installs list floats over the page, never clipped._ 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. 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`); 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 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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -91,7 +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 { HtmlFrame, fetchVia } from "../HtmlFrame";
|
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";
|
||||||
@@ -1935,10 +1936,8 @@ export function FileExplorer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An HTML file shown as the page, over the explorer: its relative CSS,
|
/** An HTML file as the page, in the shared modal viewer (`HtmlViewerModal`):
|
||||||
* scripts and images read through the same download route (`HtmlFrame`).
|
* its relative CSS, scripts and images read through the same download route. */
|
||||||
* Keys and clicks stop here — through the portal they would otherwise reach
|
|
||||||
* the explorer's own shortcuts (Delete would trash the selection). */
|
|
||||||
function HtmlViewer({
|
function HtmlViewer({
|
||||||
entry,
|
entry,
|
||||||
place,
|
place,
|
||||||
@@ -1950,49 +1949,20 @@ function HtmlViewer({
|
|||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
useDialog(true, panelRef, onClose);
|
|
||||||
const placeKey = JSON.stringify(place);
|
const placeKey = JSON.stringify(place);
|
||||||
const fetchFile = useMemo(
|
const fetchFile = useMemo(
|
||||||
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
() => fetchVia((p) => api.files.downloadUrl(place, p)),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[placeKey]
|
[placeKey]
|
||||||
);
|
);
|
||||||
return createPortal(
|
return (
|
||||||
<div
|
<HtmlViewerModal
|
||||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
path={entry.path}
|
||||||
onClick={(e) => {
|
fetchFile={fetchFile}
|
||||||
e.stopPropagation();
|
onDownload={onDownload}
|
||||||
onClose();
|
onClose={onClose}
|
||||||
}}
|
testId="fx-html-viewer"
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
/>
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={entry.name}
|
|
||||||
data-testid="fx-html-viewer"
|
|
||||||
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">
|
|
||||||
{entry.path}
|
|
||||||
</span>
|
|
||||||
<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={entry.path} fetchFile={fetchFile} title={entry.name} />
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
* 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), an HTML
|
* whole markdown file can also be read rendered (Preview), an HTML
|
||||||
* file shown as the page by Preview beside Whole file
|
* file's Preview beside Whole file opens the page in a modal
|
||||||
* (`HtmlFrame`, its relative CSS, scripts and images read like
|
* (`HtmlViewerModal`, its relative CSS, scripts and images read
|
||||||
* downloads), and an
|
* 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
|
||||||
@@ -139,7 +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 { HtmlFrame, fetchVia } from "../HtmlFrame";
|
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";
|
||||||
@@ -198,7 +199,7 @@ function Delta({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An HTML page, which the viewer can show rendered (`HtmlFrame`). */
|
/** An HTML page, which Preview opens as the page (`HtmlViewerModal`). */
|
||||||
const isHtml = (p: string | null | undefined) => !!p && /\.html?$/i.test(p);
|
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). */
|
||||||
@@ -890,11 +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 or HTML file shown rendered instead of as source; per
|
// A whole markdown file shown rendered instead of as source; per file,
|
||||||
// file, markdown opening as Settings → Appearance says
|
// opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||||
// (`lib/markdownView.ts`), an HTML page only once Preview is clicked.
|
|
||||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||||
useEffect(() => setMdPreview(!isHtml(openFile) && 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
|
||||||
@@ -2059,10 +2062,8 @@ 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 or HTML file.
|
// Rendered only where the toggle is offered: a whole markdown file.
|
||||||
const mdShown = mdPreview && whole && (isMarkdown(openFile) || isHtml(openFile));
|
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||||
// The HTML page is showing (the Preview view of the Changes / Whole file / Preview group).
|
|
||||||
const htmlShown = mdShown && isHtml(openFile);
|
|
||||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
// 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.
|
// from the working tree: a commit's page would get today's files.
|
||||||
const htmlFetch = useMemo(
|
const htmlFetch = useMemo(
|
||||||
@@ -2562,11 +2563,8 @@ export function LaneChangesPanel({
|
|||||||
<button
|
<button
|
||||||
key={String(w)}
|
key={String(w)}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={whole === w && !htmlShown}
|
aria-pressed={whole === w}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Changes and Whole file show an HTML page's source;
|
|
||||||
// only Preview runs it.
|
|
||||||
if (htmlShown) setMdPreview(false);
|
|
||||||
if (whole === w) return;
|
if (whole === w) return;
|
||||||
// Both views of a working file are the same editor: the
|
// Both views of a working file are the same editor: the
|
||||||
// draft goes along, unsaved or not.
|
// draft goes along, unsaved or not.
|
||||||
@@ -2580,7 +2578,7 @@ export function LaneChangesPanel({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||||
whole === w && !htmlShown
|
whole === w
|
||||||
? "bg-surface-4 text-fg-primary"
|
? "bg-surface-4 text-fg-primary"
|
||||||
: "text-fg-secondary hover:text-fg-primary"
|
: "text-fg-secondary hover:text-fg-primary"
|
||||||
}`}
|
}`}
|
||||||
@@ -2588,28 +2586,33 @@ export function LaneChangesPanel({
|
|||||||
{t(w ? "changes.wholeFile" : "changes.changesView")}
|
{t(w ? "changes.wholeFile" : "changes.changesView")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{/* An HTML file's third view: the page itself. */}
|
|
||||||
{isHtml(openFile) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="lane-file-preview"
|
|
||||||
aria-pressed={htmlShown}
|
|
||||||
disabled={dirty}
|
|
||||||
onClick={() => {
|
|
||||||
setMdPreview(true);
|
|
||||||
setWhole(true);
|
|
||||||
}}
|
|
||||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
||||||
htmlShown
|
|
||||||
? "bg-surface-4 text-fg-primary"
|
|
||||||
: "text-fg-secondary hover:text-fg-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("changes.preview")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
@@ -3164,17 +3167,7 @@ export function LaneChangesPanel({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
) : whole ? (
|
) : whole ? (
|
||||||
text && mdShown && isHtml(openFile) ? (
|
text && mdShown ? (
|
||||||
<HtmlFrame
|
|
||||||
key={openFile}
|
|
||||||
path={text.path}
|
|
||||||
// A cut-off page is read whole through the download.
|
|
||||||
html={text.truncated && htmlFetch ? undefined : text.lines.join("\n")}
|
|
||||||
fetchFile={htmlFetch}
|
|
||||||
title={text.path}
|
|
||||||
className="rounded border border-border-light"
|
|
||||||
/>
|
|
||||||
) : text && mdShown ? (
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||||
<MarkdownPreview text={text.lines.join("\n")} />
|
<MarkdownPreview text={text.lines.join("\n")} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -544,7 +544,7 @@ 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 after Preview, the source after Whole file", async () => {
|
it("opens a whole HTML file as its source, the page in a modal after Preview", async () => {
|
||||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
path: "",
|
path: "",
|
||||||
@@ -560,22 +560,31 @@ describe("LaneChangesPanel", () => {
|
|||||||
lines: ["<p>deck</p>"],
|
lines: ["<p>deck</p>"],
|
||||||
sha: "c".repeat(40),
|
sha: "c".repeat(40),
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const fetchSpy = vi
|
||||||
render(<LaneChangesPanel laneId={7} full />);
|
.spyOn(globalThis, "fetch")
|
||||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||||
await user.click(await within(sidebar).findByText("deck.html"));
|
try {
|
||||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
const user = userEvent.setup();
|
||||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
render(<LaneChangesPanel laneId={7} full />);
|
||||||
await user.click(screen.getByTestId("lane-file-preview"));
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||||
const frame = await screen.findByTestId("html-frame");
|
await user.click(await within(sidebar).findByText("deck.html"));
|
||||||
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
||||||
expect(screen.getByRole("button", { name: "Preview", pressed: true })).toBeInTheDocument();
|
expect(screen.queryByTestId("html-frame")).toBeNull();
|
||||||
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
await user.click(screen.getByTestId("lane-file-preview"));
|
||||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("<p>deck</p>");
|
const modal = await screen.findByTestId("html-viewer");
|
||||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
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("opens a changed HTML file on its Changes, the page only after Preview", async () => {
|
it("offers Preview on a changed HTML file's Changes, opening the page in a modal", async () => {
|
||||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||||
changes({
|
changes({
|
||||||
files: [
|
files: [
|
||||||
@@ -583,28 +592,21 @@ describe("LaneChangesPanel", () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
const fetchSpy = vi
|
||||||
available: true,
|
.spyOn(globalThis, "fetch")
|
||||||
path: "deck.html",
|
.mockResolvedValue(new Response("<p>deck</p>", { status: 200 }));
|
||||||
rev: "work",
|
try {
|
||||||
binary: false,
|
const user = userEvent.setup();
|
||||||
truncated: false,
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
lines: ["<p>deck</p>"],
|
await user.click(await screen.findByTitle("deck.html"));
|
||||||
sha: "c".repeat(40),
|
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||||
});
|
await user.click(await screen.findByTestId("lane-file-preview"));
|
||||||
const user = userEvent.setup();
|
const modal = await screen.findByTestId("html-viewer");
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
expect(await within(modal).findByTestId("html-frame")).toBeInTheDocument();
|
||||||
await user.click(await screen.findByTitle("deck.html"));
|
expect(screen.getByRole("button", { name: "Changes", pressed: true })).toBeInTheDocument();
|
||||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
} finally {
|
||||||
expect(screen.queryByTestId("html-frame")).toBeNull();
|
fetchSpy.mockRestore();
|
||||||
await user.click(await screen.findByTestId("lane-file-preview"));
|
}
|
||||||
const frame = await screen.findByTestId("html-frame");
|
|
||||||
expect(frame.getAttribute("srcdoc")).toContain("<p>deck</p>");
|
|
||||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
|
||||||
// Back to Changes: the diff again, not the page.
|
|
||||||
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
|
||||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("html-frame")).toBeNull());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -357,6 +359,7 @@ export function FilePreview({
|
|||||||
onClose,
|
onClose,
|
||||||
className = "",
|
className = "",
|
||||||
messages,
|
messages,
|
||||||
|
modal = false,
|
||||||
}: {
|
}: {
|
||||||
path: string;
|
path: string;
|
||||||
scope: PreviewScope;
|
scope: PreviewScope;
|
||||||
@@ -364,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) : []),
|
||||||
@@ -424,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}`
|
||||||
}
|
}
|
||||||
@@ -522,16 +531,18 @@ export function FilePreview({
|
|||||||
<Share2 className="h-3.5 w-3.5" />
|
<Share2 className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!modal && (
|
||||||
type="button"
|
<button
|
||||||
data-testid="artifact-expand"
|
type="button"
|
||||||
onClick={() => setFull((f) => !f)}
|
data-testid="artifact-expand"
|
||||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
onClick={() => setFull((f) => !f)}
|
||||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||||
className={iconBtn}
|
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||||
>
|
className={iconBtn}
|
||||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
>
|
||||||
</button>
|
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -546,6 +557,32 @@ export function FilePreview({
|
|||||||
<Body path={path} content={content} view={view} scope={scope} />
|
<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")}
|
||||||
|
|||||||
@@ -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") => ({
|
||||||
|
|||||||
@@ -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,66 +1701,70 @@ function InstallsButton({ item }: { item: Artifact }) {
|
|||||||
>
|
>
|
||||||
{t("installs", { count: item.installs })}
|
{t("installs", { count: item.installs })}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open &&
|
||||||
<div
|
createPortal(
|
||||||
role="dialog"
|
<div
|
||||||
aria-label={t("installsTitle")}
|
ref={pop}
|
||||||
data-testid="artifact-installs"
|
role="dialog"
|
||||||
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"
|
aria-label={t("installsTitle")}
|
||||||
>
|
data-testid="artifact-installs"
|
||||||
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
|
style={at ? { left: at.left, top: at.top } : { left: 0, top: 0, visibility: "hidden" }}
|
||||||
{data === null ? (
|
className="fixed z-[60] w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
|
||||||
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
|
>
|
||||||
) : (
|
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
|
||||||
<ul className="max-h-64 space-y-1 overflow-auto">
|
{data === null ? (
|
||||||
{data.items.map((i, n) => (
|
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
|
||||||
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<ul className="max-h-64 space-y-1 overflow-auto">
|
||||||
<span className="min-w-0 flex-1 truncate text-fg-primary">
|
{data.items.map((i, n) => (
|
||||||
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
|
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
|
||||||
{i.replaced && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
<span className="min-w-0 flex-1 truncate text-fg-primary">
|
||||||
)}
|
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
|
||||||
</span>
|
{i.replaced && (
|
||||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||||
{new Date(i.at).toLocaleString()}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||||
{i.paths && (
|
{new Date(i.at).toLocaleString()}
|
||||||
<div className="mt-0.5 space-y-0.5">
|
</span>
|
||||||
{i.paths.length ? (
|
|
||||||
i.paths.map((p) => (
|
|
||||||
<p
|
|
||||||
key={p}
|
|
||||||
className="truncate font-mono text-[10px] text-fg-muted"
|
|
||||||
title={p}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</p>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="font-mono text-[10px] text-fg-muted">
|
|
||||||
{t("installsPluginWhere")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{i.paths && (
|
||||||
</li>
|
<div className="mt-0.5 space-y-0.5">
|
||||||
))}
|
{i.paths.length ? (
|
||||||
{data.unrecorded > 0 && (
|
i.paths.map((p) => (
|
||||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
<p
|
||||||
{t("installsUnrecorded", { count: data.unrecorded })}
|
key={p}
|
||||||
</li>
|
className="truncate font-mono text-[10px] text-fg-muted"
|
||||||
)}
|
title={p}
|
||||||
{data.items.length === 0 && data.unrecorded === 0 && (
|
>
|
||||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
{p}
|
||||||
{t("installsNoneOfYours")}
|
</p>
|
||||||
</li>
|
))
|
||||||
)}
|
) : (
|
||||||
</ul>
|
<p className="font-mono text-[10px] text-fg-muted">
|
||||||
)}
|
{t("installsPluginWhere")}
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{data.unrecorded > 0 && (
|
||||||
|
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||||
|
{t("installsUnrecorded", { count: data.unrecorded })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{data.items.length === 0 && data.unrecorded === 0 && (
|
||||||
|
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||||
|
{t("installsNoneOfYours")}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</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.35 (rebuild client to match)
|
UI build v2.8.37 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-6
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.35",
|
"version": "2.8.37",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.35",
|
"version": "2.8.37",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.35",
|
"version": "2.8.37",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user