feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
+9
-9
@@ -30,7 +30,7 @@
|
|||||||
<title>AgentHub - SmartGift Solution</title>
|
<title>AgentHub - SmartGift Solution</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="keywords"
|
name="keywords"
|
||||||
@@ -45,23 +45,23 @@
|
|||||||
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||||
/>
|
/>
|
||||||
<meta property="og:image" content="/og-image.svg" />
|
<meta property="og:image" content="/og-image.png" />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta property="og:image:type" content="image/svg+xml" />
|
<meta property="og:image:type" content="image/png" />
|
||||||
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
<meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||||
|
|
||||||
<!-- Twitter / X Card -->
|
<!-- Twitter / X Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:image" content="/og-image.svg" />
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
<meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||||
<meta name="twitter:creator" content="@smartgift" />
|
<meta name="twitter:creator" content="@smartgift" />
|
||||||
|
|
||||||
<!-- JSON-LD structured data -->
|
<!-- JSON-LD structured data -->
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebApplication",
|
"@type": "WebApplication",
|
||||||
"name": "AgentHub",
|
"name": "AgentHub",
|
||||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
"description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
|
||||||
"applicationCategory": "DeveloperApplication",
|
"applicationCategory": "DeveloperApplication",
|
||||||
"operatingSystem": "Windows, macOS, Linux",
|
"operatingSystem": "Windows, macOS, Linux",
|
||||||
"softwareVersion": "1.0.0",
|
"softwareVersion": "1.0.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 411 KiB |
+72
-86
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 30 KiB |
@@ -10,6 +10,7 @@
|
|||||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||||
* Once an account is known (local mode is one), its own UI preferences are
|
* Once an account is known (local mode is one), its own UI preferences are
|
||||||
* loaded into this browser (`lib/userPrefs.ts`).
|
* loaded into this browser (`lib/userPrefs.ts`).
|
||||||
|
* The login page follows the OS's light/dark (`applySystemTheme`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,11 +18,15 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useState,
|
useState,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { BRAND_LOGO } from "../lib/brand";
|
||||||
|
import { applySystemTheme } from "../hooks/useTheme";
|
||||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||||
import { hydrate } from "../lib/userPrefs";
|
import { hydrate } from "../lib/userPrefs";
|
||||||
|
|
||||||
@@ -144,9 +149,18 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
applySystemTheme();
|
||||||
|
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||||
|
mq?.addEventListener?.("change", applySystemTheme);
|
||||||
|
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const submit = async (e: FormEvent) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -164,38 +178,83 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||||
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
|
<form
|
||||||
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
|
onSubmit={submit}
|
||||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||||
<span>{t("auth.username")}</span>
|
>
|
||||||
<input
|
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||||
className="input w-full"
|
theme's own accent so it holds in every theme. */}
|
||||||
autoComplete="username"
|
<div
|
||||||
autoFocus
|
aria-hidden
|
||||||
required
|
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||||
value={username}
|
style={{
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
backgroundImage:
|
||||||
/>
|
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||||
</label>
|
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||||
<label className="block space-y-1 text-sm text-fg-secondary">
|
backgroundPosition: "center top",
|
||||||
<span>{t("auth.password")}</span>
|
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||||
<input
|
}}
|
||||||
className="input w-full"
|
/>
|
||||||
type="password"
|
<div className="relative space-y-5">
|
||||||
autoComplete="current-password"
|
<div className="text-center">
|
||||||
required
|
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||||
value={password}
|
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
{t("auth.title")}
|
||||||
/>
|
</h1>
|
||||||
</label>
|
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||||
{error && (
|
</div>
|
||||||
<p role="alert" className="text-sm text-status-danger">
|
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||||
{error}
|
<span>{t("auth.username")}</span>
|
||||||
</p>
|
<input
|
||||||
)}
|
className="input h-11 w-full px-4"
|
||||||
<button type="submit" className="btn-primary w-full" disabled={busy}>
|
autoComplete="username"
|
||||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
autoFocus
|
||||||
</button>
|
required
|
||||||
|
placeholder={t("auth.username")}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||||
|
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
className="input h-11 w-full pl-4 pr-11"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||||
|
light theme, light in a dark one, like the reference design. */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* @file ImageLightbox.tsx
|
||||||
|
* @description One image shown full size over the whole page; a click or
|
||||||
|
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||||
|
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||||
|
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||||
|
* layer, under anything stacked above it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export function ImageLightbox({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={alt}
|
||||||
|
data-testid="image-lightbox"
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, type Ref } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
@@ -27,12 +27,15 @@ export function PathPickButton({
|
|||||||
start,
|
start,
|
||||||
onPick,
|
onPick,
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
|
buttonRef,
|
||||||
}: {
|
}: {
|
||||||
kind?: "dir" | "file";
|
kind?: "dir" | "file";
|
||||||
/** The field's current value — the picker opens there. */
|
/** The field's current value — the picker opens there. */
|
||||||
start?: string;
|
start?: string;
|
||||||
onPick: (path: string) => void;
|
onPick: (path: string) => void;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
|
/** Lets a field beside it open the same picker (a click on an empty path). */
|
||||||
|
buttonRef?: Ref<HTMLButtonElement>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -61,6 +64,7 @@ export function PathPickButton({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||||
* checkmark space inconsistently; this control aligns labels, flips above the
|
* checkmark space inconsistently; this control aligns labels, flips above the
|
||||||
* trigger when viewport space is tight, and marks the chosen option with accent
|
* trigger when space is tight — in its split pane (`data-menu-bounds`, see
|
||||||
|
* `menuRoom`) or else the viewport — and marks the chosen option with accent
|
||||||
* color plus a Lucide check.
|
* color plus a Lucide check.
|
||||||
*
|
*
|
||||||
* ## Accessibility — WAI-ARIA "listbox button"
|
* ## Accessibility — WAI-ARIA "listbox button"
|
||||||
@@ -74,6 +75,14 @@
|
|||||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||||
import { ChevronDown, Check } from "lucide-react";
|
import { ChevronDown, Check } from "lucide-react";
|
||||||
|
|
||||||
|
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||||
|
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||||
|
* over anything crossing its edge — else the viewport. */
|
||||||
|
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
|
||||||
|
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
|
||||||
|
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
|
||||||
|
}
|
||||||
|
|
||||||
/** Single option in a {@link Select} list. */
|
/** Single option in a {@link Select} list. */
|
||||||
export interface SelectOption<T extends string> {
|
export interface SelectOption<T extends string> {
|
||||||
/** Stored value emitted via `onChange`. */
|
/** Stored value emitted via `onChange`. */
|
||||||
@@ -144,9 +153,11 @@ export function Select<T extends string>({
|
|||||||
const idx = options.findIndex((o) => o.value === value);
|
const idx = options.findIndex((o) => o.value === value);
|
||||||
setActive(idx >= 0 ? idx : 0);
|
setActive(idx >= 0 ? idx : 0);
|
||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const { above, below } = menuRoom(
|
||||||
const below = window.innerHeight - rect.bottom;
|
buttonRef.current,
|
||||||
setOpenUp(below < 288 && rect.top > below);
|
buttonRef.current.getBoundingClientRect()
|
||||||
|
);
|
||||||
|
setOpenUp(below < 288 && above > below);
|
||||||
}
|
}
|
||||||
listRef.current?.focus({ preventScroll: true });
|
listRef.current?.focus({ preventScroll: true });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
|
||||||
import { useAuth, usePermissions } from "./AuthGate";
|
import { useAuth, usePermissions } from "./AuthGate";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { WorkspaceTree } from "./WorkspaceTree";
|
import { WorkspaceTree } from "./WorkspaceTree";
|
||||||
@@ -88,13 +89,6 @@ const NAV_KEYS = [
|
|||||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
|
||||||
// owned by the brand, not vendored here — a machine with no network shows the
|
|
||||||
// alt text instead.
|
|
||||||
const BRAND_LOGO =
|
|
||||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
|
||||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "sidebar-collapsed";
|
const STORAGE_KEY = "sidebar-collapsed";
|
||||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||||
const RECENT_EVENTS_CAP = 8;
|
const RECENT_EVENTS_CAP = 8;
|
||||||
@@ -496,6 +490,7 @@ export function Sidebar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
data-testid="sidebar-tree-rail"
|
||||||
title={t("lanes:tree.title")}
|
title={t("lanes:tree.title")}
|
||||||
aria-label={t("lanes:tree.title")}
|
aria-label={t("lanes:tree.title")}
|
||||||
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||||
* live tab is opened from its session row. Live status comes from the shared
|
* live tab is opened from its session row. Live status comes from the shared
|
||||||
* lane list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a
|
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||||
* lane's status or session moves (at most every 3 s) and handed over by the
|
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
|
||||||
|
* analyst: EVERY workspace, grouped under its owner (`owner_name`; yours
|
||||||
|
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||||
|
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||||
|
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,10 +29,11 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { Plus, Search } from "lucide-react";
|
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
||||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { useWorkLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
|
import { useAuth, usePermissions } from "./AuthGate";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
import { useThrottledValue } from "../hooks/useThrottledValue";
|
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||||
import {
|
import {
|
||||||
@@ -70,7 +75,12 @@ export function WorkspaceTree() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const selected = selectedFrom(location.pathname, location.search);
|
const selected = selectedFrom(location.pathname, location.search);
|
||||||
const { lanes, refresh: refreshLanes } = useWorkLanes();
|
const { lanes, refresh: refreshLanes } = useLanes();
|
||||||
|
const { state: auth } = useAuth();
|
||||||
|
const { seesAll, canEditLane } = usePermissions();
|
||||||
|
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||||
|
// Server mode staff see every user's workspaces, so the list says whose.
|
||||||
|
const grouped = auth.mode === "server" && seesAll;
|
||||||
const prefs = useProjectTreePrefs();
|
const prefs = useProjectTreePrefs();
|
||||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -132,10 +142,17 @@ export function WorkspaceTree() {
|
|||||||
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
|
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
|
||||||
const toolFilter =
|
const toolFilter =
|
||||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||||
const sorted = useMemo(
|
const sorted = useMemo(() => {
|
||||||
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
const list = sortByStoredOrder(lanes, order).filter(
|
||||||
[lanes, order, toolFilter]
|
(l) => !toolFilter || l.tool === toolFilter
|
||||||
);
|
);
|
||||||
|
if (!grouped) return list;
|
||||||
|
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||||
|
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||||
|
return [...list].sort(
|
||||||
|
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
|
||||||
|
);
|
||||||
|
}, [lanes, order, toolFilter, grouped, meId]);
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const nameHit = (l: Lane) =>
|
const nameHit = (l: Lane) =>
|
||||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||||
@@ -312,42 +329,90 @@ export function WorkspaceTree() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div ref={listRef} className="space-y-px px-1">
|
<div ref={listRef} className="space-y-px px-1">
|
||||||
{sorted.map((lane, i) => (
|
{sorted.map((lane, i) => {
|
||||||
<div key={lane.id} className="relative">
|
const owner = ownerKey(lane);
|
||||||
{dropAt === i && <DropLine />}
|
// A search opens every group, like it opens every workspace.
|
||||||
{visible(lane) && (
|
const folded = grouped && !q && !!prefs.collapsedOwners[owner];
|
||||||
<WorkspaceRow
|
const readOnly = !canEditLane(lane);
|
||||||
lane={lane}
|
const ownerLabel =
|
||||||
index={i}
|
lane.owner_id == null
|
||||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
? t("tree.noOwner")
|
||||||
expanded={expanded(lane.id)}
|
: lane.owner_id === meId
|
||||||
renaming={renamingId === lane.id}
|
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
||||||
onSelect={() => {
|
: (lane.owner_name ?? `#${lane.owner_id}`);
|
||||||
select(lane.id);
|
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||||
}}
|
return (
|
||||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
<div key={lane.id} className="relative">
|
||||||
onNewSession={() => select(lane.id)}
|
{groupStart && groupShown && (
|
||||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
<button
|
||||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
type="button"
|
||||||
onRenameStart={() => setRenamingId(lane.id)}
|
data-testid={`workspace-owner-${owner}`}
|
||||||
onRenameEnd={(title) => void rename(lane, title)}
|
aria-expanded={!folded}
|
||||||
/>
|
title={t("tree.ownerToggle", { name: ownerLabel })}
|
||||||
)}
|
onClick={() =>
|
||||||
{expanded(lane.id) && (
|
writeProjectTreePrefs({
|
||||||
<WorkspaceSessionList
|
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
|
||||||
lane={lane}
|
})
|
||||||
liveRuns={liveRuns}
|
}
|
||||||
// A workspace found by its name shows all of its sessions.
|
className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
|
||||||
query={q && nameHit(lane) ? "" : query}
|
>
|
||||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
{folded ? (
|
||||||
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||||
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
) : (
|
||||||
hidden={!visible(lane)}
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
/>
|
)}
|
||||||
)}
|
<span
|
||||||
</div>
|
aria-hidden
|
||||||
))}
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
|
||||||
|
>
|
||||||
|
{lane.owner_name?.[0] ?? "?"}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{ownerLabel}</span>
|
||||||
|
<span className="ml-auto shrink-0 tabular-nums">
|
||||||
|
{sorted.filter((l) => ownerKey(l) === owner).length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{dropAt === i && <DropLine />}
|
||||||
|
{visible(lane) && !folded && (
|
||||||
|
<WorkspaceRow
|
||||||
|
lane={lane}
|
||||||
|
index={i}
|
||||||
|
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
|
||||||
|
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||||
|
expanded={expanded(lane.id)}
|
||||||
|
renaming={renamingId === lane.id}
|
||||||
|
onSelect={() => {
|
||||||
|
// A read-only workspace has no home to open: it only unfolds.
|
||||||
|
if (readOnly) return setExpanded(lane.id, !expanded(lane.id));
|
||||||
|
select(lane.id);
|
||||||
|
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||||
|
}}
|
||||||
|
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||||
|
onNewSession={() => select(lane.id)}
|
||||||
|
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||||
|
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||||
|
onRenameStart={() => setRenamingId(lane.id)}
|
||||||
|
onRenameEnd={(title) => void rename(lane, title)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{expanded(lane.id) && !folded && (
|
||||||
|
<WorkspaceSessionList
|
||||||
|
lane={lane}
|
||||||
|
liveRuns={liveRuns}
|
||||||
|
readOnly={readOnly}
|
||||||
|
// A workspace found by its name shows all of its sessions.
|
||||||
|
query={q && nameHit(lane) ? "" : query}
|
||||||
|
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||||
|
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
||||||
|
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
||||||
|
hidden={!visible(lane)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{dropAt === sorted.length && (
|
{dropAt === sorted.length && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DropLine />
|
<DropLine />
|
||||||
@@ -400,6 +465,9 @@ export function WorkspaceTree() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A workspace's owner group: its user id, or "none". */
|
||||||
|
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||||
|
|
||||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||||
function DropLine() {
|
function DropLine() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,17 +5,19 @@
|
|||||||
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
||||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||||
* analyst reads everything but works only in their own workspaces, a
|
* analyst reads everything but works only in their own workspaces, a
|
||||||
* developer only ever sees (and so changes) their own.
|
* developer only ever sees (and so changes) their own. The login page wears the
|
||||||
|
* OS's light/dark; a sign-in swaps in the account's saved appearance.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <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";
|
||||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import "../../i18n";
|
import "../../i18n";
|
||||||
|
|
||||||
const me = vi.fn();
|
const me = vi.fn();
|
||||||
const login = vi.fn();
|
const login = vi.fn();
|
||||||
|
const prefs = vi.fn();
|
||||||
const LANES = [
|
const LANES = [
|
||||||
{ id: 1, owner_id: 5 },
|
{ id: 1, owner_id: 5 },
|
||||||
{ id: 2, owner_id: 6 },
|
{ id: 2, owner_id: 6 },
|
||||||
@@ -25,6 +27,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
api: {
|
api: {
|
||||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||||
|
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ const user = { id: 1, username: "mia", role: "developer" };
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
me.mockReset();
|
me.mockReset();
|
||||||
login.mockReset();
|
login.mockReset();
|
||||||
|
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,15 +111,15 @@ describe("AuthGate", () => {
|
|||||||
render(<AuthGate>app</AuthGate>);
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
|
||||||
const name = await screen.findByLabelText(/username/i);
|
const name = await screen.findByLabelText(/username/i);
|
||||||
const pass = screen.getByLabelText(/password/i);
|
const pass = screen.getByLabelText(/^password$/i);
|
||||||
await userEvent.type(name, "mia");
|
await userEvent.type(name, "mia");
|
||||||
await userEvent.type(pass, "nope");
|
await userEvent.type(pass, "nope");
|
||||||
await userEvent.click(screen.getByRole("button"));
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("app")).toBeNull();
|
expect(screen.queryByText("app")).toBeNull();
|
||||||
|
|
||||||
await userEvent.type(pass, "member-pass-1");
|
await userEvent.type(pass, "member-pass-1");
|
||||||
await userEvent.click(screen.getByRole("button"));
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||||
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
|
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
|
||||||
|
|
||||||
@@ -126,6 +130,32 @@ describe("AuthGate", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("AuthGate theme", () => {
|
||||||
|
it("wears the OS theme while signed out and the account's own once signed in", async () => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
// The previous user's choice, left in this browser.
|
||||||
|
localStorage.setItem("theme", "dark");
|
||||||
|
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||||
|
const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||||
|
vi.stubGlobal("matchMedia", () => mq); // the OS is light
|
||||||
|
me.mockResolvedValue({ mode: "server", user: null });
|
||||||
|
prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } });
|
||||||
|
login.mockResolvedValue({ user });
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
|
||||||
|
await userEvent.type(await screen.findByLabelText(/username/i), "mia");
|
||||||
|
expect(html.dataset.mode).toBe("light");
|
||||||
|
expect(html.hasAttribute("data-theme")).toBe(false);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||||
|
await waitFor(() => expect(html.dataset.theme).toBe("orca-dark"));
|
||||||
|
expect(html.dataset.mode).toBe("dark");
|
||||||
|
expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("usePermissions / useWorkLanes", () => {
|
describe("usePermissions / useWorkLanes", () => {
|
||||||
const as = async (state: unknown) => {
|
const as = async (state: unknown) => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* @file Select.test.tsx
|
* @file Select.test.tsx
|
||||||
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
|
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
|
||||||
* listbox/option roles with aria-selected, aria-activedescendant tracking,
|
* listbox/option roles with aria-selected, aria-activedescendant tracking,
|
||||||
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
|
* Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
|
||||||
|
* list that flips up by the room in its split pane, not the window's.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
|
|||||||
fireEvent.click(screen.getByText("Apple"));
|
fireEvent.click(screen.getByText("Apple"));
|
||||||
expect(onChange).toHaveBeenCalledWith("a");
|
expect(onChange).toHaveBeenCalledWith("a");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
|
||||||
|
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
|
||||||
|
render(
|
||||||
|
<div data-menu-bounds data-testid="pane">
|
||||||
|
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
|
||||||
|
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
|
||||||
|
const button = screen.getByRole("button", { name: "Fruit" });
|
||||||
|
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||||
|
vi.stubGlobal("innerHeight", 2000);
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* @file WorkspaceTree.owners.test.tsx
|
||||||
|
* @description Server mode, the sidebar workspace list for an analyst: every
|
||||||
|
* user's workspaces, grouped under their owner (yours first, no owner last),
|
||||||
|
* groups folding and remembered; another user's workspace is read only — no
|
||||||
|
* menu, no new session, and a session opens its analysis page, never a pane.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
|
import type { Lane, Session } from "../../lib/types";
|
||||||
|
|
||||||
|
const ME = { id: 2, username: "ana", role: "analyst" as const };
|
||||||
|
|
||||||
|
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
cwd: `/w/${id}`,
|
||||||
|
kind: "adopted",
|
||||||
|
tool: "claude",
|
||||||
|
status: "idle",
|
||||||
|
liveness: "idle",
|
||||||
|
pipeline_nodes: [],
|
||||||
|
owner_id: owner,
|
||||||
|
owner_name: name,
|
||||||
|
}) as unknown as Lane;
|
||||||
|
|
||||||
|
const lanes = [
|
||||||
|
lane(1, 3, "bob", "Bob app"),
|
||||||
|
lane(2, null, null, "Old"),
|
||||||
|
lane(3, ME.id, "ana", "Mine"),
|
||||||
|
lane(4, 1, "admin", "Admin site"),
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mock("../AuthGate", () => ({
|
||||||
|
useAuth: () => ({ state: { mode: "server", user: ME } }),
|
||||||
|
usePermissions: () => ({
|
||||||
|
isAdmin: false,
|
||||||
|
seesAll: true,
|
||||||
|
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
|
||||||
|
sessions: {
|
||||||
|
list: vi.fn(async () => ({
|
||||||
|
sessions: [
|
||||||
|
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
|
||||||
|
] as Session[],
|
||||||
|
total: 1,
|
||||||
|
limit: 6,
|
||||||
|
offset: 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
run: { list: vi.fn(async () => ({ items: [] })) },
|
||||||
|
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WorkspaceTree } from "../WorkspaceTree";
|
||||||
|
|
||||||
|
function Where() {
|
||||||
|
const loc = useLocation();
|
||||||
|
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<WorkspaceTree />
|
||||||
|
<Where />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe("WorkspaceTree for an analyst", () => {
|
||||||
|
it("groups every workspace under its owner: yours first, no owner last", async () => {
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
const order = screen
|
||||||
|
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
|
||||||
|
.map((el) => el.dataset.testid);
|
||||||
|
expect(order).toEqual([
|
||||||
|
"workspace-owner-2",
|
||||||
|
"workspace-row-3",
|
||||||
|
"workspace-owner-1",
|
||||||
|
"workspace-row-4",
|
||||||
|
"workspace-owner-3",
|
||||||
|
"workspace-row-1",
|
||||||
|
"workspace-owner-none",
|
||||||
|
"workspace-row-2",
|
||||||
|
]);
|
||||||
|
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds a group and remembers it", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await user.click(await screen.findByTestId("workspace-owner-3"));
|
||||||
|
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
|
||||||
|
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
|
||||||
|
"3": true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps another user's workspace read only", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await screen.findByTestId("workspace-row-1");
|
||||||
|
// Yours keeps its controls; theirs has none.
|
||||||
|
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
|
||||||
|
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
|
||||||
|
// Picking it unfolds it instead of opening a workspace home.
|
||||||
|
await user.click(screen.getByTestId("workspace-open-1"));
|
||||||
|
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
|
||||||
|
const list = await screen.findByTestId("workspace-sessions-1");
|
||||||
|
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
|
||||||
|
await user.click(within(list).getByTestId("workspace-session-s-1"));
|
||||||
|
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,11 @@
|
|||||||
* the message stream via MessageList. Combines a WebSocket subscription, a
|
* the message stream via MessageList. Combines a WebSocket subscription, a
|
||||||
* visibility-gated polling fallback, and a manual refresh button so the view
|
* visibility-gated polling fallback, and a manual refresh button so the view
|
||||||
* stays caught up even when hooks miss frames or the user is mid-text-only
|
* stays caught up even when hooks miss frames or the user is mid-text-only
|
||||||
* turn (no PreToolUse fires until Stop).
|
* turn (no PreToolUse fires until Stop). The view sticks to its end while
|
||||||
|
* the reader is there: anything that makes it taller — a new line, the live
|
||||||
|
* step, an image loading, the pane shown again after being hidden by CSS
|
||||||
|
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
||||||
|
* reader scrolls up; `follow` (a chat's send) sends it back to the end.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -79,12 +83,21 @@ interface ConversationViewProps {
|
|||||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||||
* page-height cap — the parent sizes it and puts a composer under it. */
|
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
|
/** Opens a file named in a turn's "Edited N files" card. */
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
|
/** The session runs in a pane: show the step in progress. */
|
||||||
|
live?: boolean;
|
||||||
|
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||||
|
follow?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationView({
|
export function ConversationView({
|
||||||
sessionId,
|
sessionId,
|
||||||
initialTranscriptId,
|
initialTranscriptId,
|
||||||
fill = false,
|
fill = false,
|
||||||
|
onOpenFile,
|
||||||
|
live = false,
|
||||||
|
follow = 0,
|
||||||
}: ConversationViewProps) {
|
}: ConversationViewProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
@@ -105,6 +118,7 @@ export function ConversationView({
|
|||||||
const lastLineRef = useRef(0);
|
const lastLineRef = useRef(0);
|
||||||
const firstLineRef = useRef(0);
|
const firstLineRef = useRef(0);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
const isAtBottomRef = useRef(true);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
// When a fetch is in flight and a new trigger arrives (WS event, poll,
|
// When a fetch is in flight and a new trigger arrives (WS event, poll,
|
||||||
@@ -392,6 +406,30 @@ export function ConversationView({
|
|||||||
}
|
}
|
||||||
}, [hasMore, loadingHistory, loadHistory]);
|
}, [hasMore, loadingHistory, loadHistory]);
|
||||||
|
|
||||||
|
// Stick to the end while the reader is there. Scroll events say whether
|
||||||
|
// they are; a size change — of the content, or of the box itself when a
|
||||||
|
// hidden pane is shown — never fires one, so it is watched here.
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!container || !content || typeof ResizeObserver === "undefined") return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
observer.observe(container);
|
||||||
|
observer.observe(content);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The reader sent a message: show it, and what follows, as it lands.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!follow) return;
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setAwayFromEnd(false);
|
||||||
|
setShowNewMsg(false);
|
||||||
|
scrollToBottom();
|
||||||
|
}, [follow, scrollToBottom]);
|
||||||
|
|
||||||
// Auto-scroll to bottom after initial load
|
// Auto-scroll to bottom after initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && messages.length > 0) {
|
if (!loading && messages.length > 0) {
|
||||||
@@ -450,38 +488,42 @@ export function ConversationView({
|
|||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||||
>
|
>
|
||||||
{/* History loading indicator */}
|
<div ref={contentRef}>
|
||||||
{loadingHistory && (
|
{/* History loading indicator */}
|
||||||
<div className="flex justify-center py-3">
|
{loadingHistory && (
|
||||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
<div className="flex justify-center py-3">
|
||||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||||
</div>
|
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scroll-up for history hint */}
|
{/* Scroll-up for history hint */}
|
||||||
{hasMore && !loadingHistory && !loading && (
|
{hasMore && !loadingHistory && !loading && (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
|
<span className="text-[11px] text-fg-muted">
|
||||||
</div>
|
↑ {t("conversation.scrollForOlder")}
|
||||||
)}
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||||
{t("conversation.loading")}
|
{t("conversation.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="mx-auto max-w-md py-12 text-center">
|
<div className="mx-auto max-w-md py-12 text-center">
|
||||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||||
{t("conversation.emptyBody")}
|
{t("conversation.emptyBody")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MessageList messages={messages} loading={false} />
|
<MessageList messages={messages} loading={false} onOpenFile={onOpenFile} live={live} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claude Desktop's round "back to the end" button */}
|
{/* Claude Desktop's round "back to the end" button */}
|
||||||
|
|||||||
@@ -4,7 +4,16 @@
|
|||||||
* transcript: alternating user / assistant rows with collapsible thinking
|
* transcript: alternating user / assistant rows with collapsible thinking
|
||||||
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
|
* blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and
|
||||||
* MarkdownContent for prose. Used by ConversationView as the main body of
|
* MarkdownContent for prose. Used by ConversationView as the main body of
|
||||||
* the Conversation tab on the Session detail page.
|
* the Conversation tab on the Session detail page. An image the prompt box
|
||||||
|
* attached reaches the transcript as its saved path; a user turn shows the
|
||||||
|
* image instead (`GET /api/run/drop/:name`). Each assistant turn that changed
|
||||||
|
* files ends with Claude Desktop's "Edited N files" card: every file with the
|
||||||
|
* lines added and removed (the tool results' `diff`); a click opens the file
|
||||||
|
* when the caller can (`onOpenFile`). A thinking block that carries text is
|
||||||
|
* the model's narration to the user (Claude Code ≥ 2.1.280 stores real
|
||||||
|
* thinking empty) and reads as prose, like Claude Desktop shows it; a live
|
||||||
|
* chat (`live`) ends, until its turn is over, with what the model is doing
|
||||||
|
* now — the call still running, else "Thinking…" — and for how long.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -53,7 +62,8 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { memo, useState, useMemo } from "react";
|
import { memo, useEffect, useState, useMemo } from "react";
|
||||||
|
import { ImageLightbox } from "../ImageLightbox";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +79,8 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Workflow,
|
Workflow,
|
||||||
Cog,
|
Cog,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||||
|
|
||||||
@@ -113,12 +125,16 @@ const SENDER_STYLES: Record<
|
|||||||
};
|
};
|
||||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||||
import { MarkdownContent } from "./MarkdownContent";
|
import { MarkdownContent } from "./MarkdownContent";
|
||||||
import { fmt, formatModelName } from "../../lib/format";
|
import { fmt, formatModelName, formatMs, parseDate } from "../../lib/format";
|
||||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: TranscriptMessage[];
|
messages: TranscriptMessage[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/** Opens a file named in the "Edited N files" card. */
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
|
/** The session is running in a pane: show the step in progress. */
|
||||||
|
live?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a map from tool_use id → tool_result for matching */
|
/** Build a map from tool_use id → tool_result for matching */
|
||||||
@@ -135,6 +151,16 @@ function buildToolResultMap(messages: TranscriptMessage[]): Map<string, Transcri
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The whole prompt a message was answered from: `input_tokens` is only its
|
||||||
|
* uncached tail (a few tokens once the cache is warm), the rest is cache. */
|
||||||
|
function promptTokens(u: NonNullable<TranscriptMessage["usage"]>): number {
|
||||||
|
return (
|
||||||
|
(u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const usageTitle = (u: NonNullable<TranscriptMessage["usage"]>) =>
|
||||||
|
`input ${fmt(u.input_tokens || 0)} · cache read ${fmt(u.cache_read_input_tokens || 0)} · cache write ${fmt(u.cache_creation_input_tokens || 0)} · output ${fmt(u.output_tokens || 0)}`;
|
||||||
|
|
||||||
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
|
/** Detect if text is skill loading content (starts with "Base directory for this skill:") */
|
||||||
function isSkillContent(text: string): boolean {
|
function isSkillContent(text: string): boolean {
|
||||||
return text.startsWith("Base directory for this skill:");
|
return text.startsWith("Base directory for this skill:");
|
||||||
@@ -390,34 +416,120 @@ type Item =
|
|||||||
| { kind: "row"; key: string; msg: TranscriptMessage }
|
| { kind: "row"; key: string; msg: TranscriptMessage }
|
||||||
| { kind: "user"; key: string; msg: TranscriptMessage }
|
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||||
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||||
| { kind: "work"; key: string; blocks: TranscriptContent[] };
|
| { kind: "work"; key: string; blocks: TranscriptContent[] }
|
||||||
|
| { kind: "edits"; key: string; blocks: TranscriptContent[] };
|
||||||
|
|
||||||
|
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
||||||
|
|
||||||
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
||||||
const items: Item[] = [];
|
const items: Item[] = [];
|
||||||
|
// The file-changing calls of the turn so far, closed into one card when the
|
||||||
|
// human speaks again (or the list ends).
|
||||||
|
let edits: TranscriptContent[] = [];
|
||||||
|
const endTurn = (key: string) => {
|
||||||
|
if (edits.length) items.push({ kind: "edits", key: `edits:${key}`, blocks: edits });
|
||||||
|
edits = [];
|
||||||
|
};
|
||||||
messages.forEach((msg, idx) => {
|
messages.forEach((msg, idx) => {
|
||||||
const key = keys[idx]!;
|
const key = keys[idx]!;
|
||||||
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
|
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
|
||||||
// Purely tool_result: shown inside its call's block.
|
// Purely tool_result: shown inside its call's block.
|
||||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
|
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
|
||||||
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||||
if (sender === "user") return items.push({ kind: "user", key, msg });
|
if (sender === "user") {
|
||||||
|
endTurn(key);
|
||||||
|
return items.push({ kind: "user", key, msg });
|
||||||
|
}
|
||||||
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
||||||
msg.content.forEach((block, b) => {
|
msg.content.forEach((block, b) => {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
|
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
|
||||||
|
if (block.type === "tool_use") {
|
||||||
if (last?.kind === "work") last.blocks.push(block);
|
if (last?.kind === "work") last.blocks.push(block);
|
||||||
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||||
} else if (block.type === "text" && block.text?.trim()) {
|
} else if ((block.type === "text" || block.type === "thinking") && block.text?.trim()) {
|
||||||
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
|
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
endTurn(keys[keys.length - 1] ?? "end");
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({ messages, loading }: MessageListProps) {
|
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
|
||||||
|
* the lines added and removed. A call still running or failed is left out. */
|
||||||
|
function EditedFiles({
|
||||||
|
blocks,
|
||||||
|
toolResultMap,
|
||||||
|
onOpenFile,
|
||||||
|
}: {
|
||||||
|
blocks: TranscriptContent[];
|
||||||
|
toolResultMap: Map<string, TranscriptContent>;
|
||||||
|
onOpenFile?: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
// `counted`: a result carried its +/- lines (an older server sends none).
|
||||||
|
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||||
|
for (const b of blocks) {
|
||||||
|
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||||
|
const p = input.file_path ?? input.notebook_path;
|
||||||
|
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||||
|
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||||
|
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||||
|
f.added += r.diff?.added ?? 0;
|
||||||
|
f.removed += r.diff?.removed ?? 0;
|
||||||
|
f.counted ||= !!r.diff;
|
||||||
|
files.set(p, f);
|
||||||
|
}
|
||||||
|
if (files.size === 0) return null;
|
||||||
|
let added = 0;
|
||||||
|
let removed = 0;
|
||||||
|
let counted = false;
|
||||||
|
files.forEach((f) => {
|
||||||
|
added += f.added;
|
||||||
|
removed += f.removed;
|
||||||
|
counted ||= f.counted;
|
||||||
|
});
|
||||||
|
const counts = (a: number, r: number, show = counted) =>
|
||||||
|
show && (
|
||||||
|
<span className="flex-shrink-0 font-mono text-xs">
|
||||||
|
<span className="text-status-success">+{a}</span>{" "}
|
||||||
|
<span className="text-status-danger">−{r}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-sm text-fg-primary">
|
||||||
|
<Pencil className="h-3.5 w-3.5 text-fg-muted" />
|
||||||
|
<span className="flex-1">{t("conversation.edited", { count: files.size })}</span>
|
||||||
|
{counts(added, removed)}
|
||||||
|
</div>
|
||||||
|
{[...files].map(([p, f]) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
title={p}
|
||||||
|
disabled={!onOpenFile}
|
||||||
|
onClick={() => onOpenFile?.(p)}
|
||||||
|
className="flex w-full items-center gap-2 border-b border-border px-3 py-2 text-left text-sm text-fg-secondary last:border-b-0 enabled:hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.split("/").pop()}</span>
|
||||||
|
{counts(f.added, f.removed, f.counted)}
|
||||||
|
{onOpenFile && <ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({ messages, loading, onOpenFile, live = false }: MessageListProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||||
|
const pending = useMemo(
|
||||||
|
() => (live ? workingOn(messages, toolResultMap) : null),
|
||||||
|
[live, messages, toolResultMap]
|
||||||
|
);
|
||||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||||
|
|
||||||
@@ -434,7 +546,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
<div className="mx-auto w-full max-w-[max(48rem,70%)] space-y-4 px-4 py-4">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case "event":
|
case "event":
|
||||||
@@ -453,14 +565,133 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||||
case "work":
|
case "work":
|
||||||
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||||
|
case "edits":
|
||||||
|
// The card closes a finished turn; one still working has none yet.
|
||||||
|
if (pending && item === items[items.length - 1]) return null;
|
||||||
|
return (
|
||||||
|
<EditedFiles
|
||||||
|
key={item.key}
|
||||||
|
blocks={item.blocks}
|
||||||
|
toolResultMap={toolResultMap}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
{pending && <RunningStep block={pending.block} since={pending.since} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No line for this long and no call running: the turn died with no end
|
||||||
|
// written (a crash, a kill) — stop saying it works.
|
||||||
|
const IDLE_AFTER_MS = 15 * 60_000;
|
||||||
|
|
||||||
|
/** What the model is doing while its turn is not over: the call still
|
||||||
|
* waiting for its result (`block`), else thinking (`block: null`); `since`
|
||||||
|
* is that line's time. Null once the turn ended. */
|
||||||
|
export function workingOn(
|
||||||
|
messages: TranscriptMessage[],
|
||||||
|
results: Map<string, TranscriptContent>,
|
||||||
|
now = Date.now()
|
||||||
|
): { block: TranscriptContent | null; since: string | null } | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = messages[i]!;
|
||||||
|
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||||
|
// A rename marker or a harness line says nothing about the turn.
|
||||||
|
if (msg.type === "session_event" || sender === "system" || sender === "orchestrator") continue;
|
||||||
|
const since = msg.timestamp ?? null;
|
||||||
|
const fresh = !since || now - parseDate(since).getTime() < IDLE_AFTER_MS;
|
||||||
|
if (msg.type === "assistant") {
|
||||||
|
const call = [...msg.content].reverse().find((b) => b.type === "tool_use");
|
||||||
|
if (call?.id && !results.has(call.id)) return { block: call, since };
|
||||||
|
// "tool_use": a call follows. "end_turn" (or none, from an older server): over.
|
||||||
|
return msg.stop_reason === "tool_use" && fresh ? { block: null, since } : null;
|
||||||
|
}
|
||||||
|
// A prompt or a tool result: the model answers it next — unless the user stopped it.
|
||||||
|
const text = msg.content.find((c) => c.type === "text")?.text ?? "";
|
||||||
|
if (text.startsWith("[Request interrupted")) return null;
|
||||||
|
return fresh ? { block: null, since } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claude Desktop's live line: the step running now and for how long. */
|
||||||
|
function RunningStep({ block, since }: { block: TranscriptContent | null; since: string | null }) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
const input = block?.input as Record<string, unknown> | undefined;
|
||||||
|
const title = !block
|
||||||
|
? t("conversation.working")
|
||||||
|
: typeof input?.description === "string" && input.description
|
||||||
|
? input.description
|
||||||
|
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<div data-testid="chat-running-step" className="flex items-center gap-2 text-sm text-fg-muted">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
|
||||||
|
<span className="min-w-0 truncate">{title}</span>
|
||||||
|
{since && (
|
||||||
|
<span className="flex-shrink-0 font-mono text-xs">
|
||||||
|
{formatMs(now - parseDate(since).getTime())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = "text-[10px] font-mono text-fg-muted";
|
const meta = "text-[10px] font-mono text-fg-muted";
|
||||||
|
|
||||||
|
// What the prompt box sends for an attached image: its saved path under the
|
||||||
|
// drop dir (`<tmp>/agenthub-drops-<uid>/<16 hex>[-name].png`, server/routes/run.js).
|
||||||
|
const DROP_IMAGE =
|
||||||
|
/(?<=^|\s)\S*[\\/]agenthub-drops--?\d+[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi;
|
||||||
|
|
||||||
|
/** A user turn's text without its dropped-image paths, and those images' names. */
|
||||||
|
export function splitDropImages(text: string): { text: string; images: string[] } {
|
||||||
|
const images: string[] = [];
|
||||||
|
const rest = text.replace(DROP_IMAGE, (_all, name: string) => {
|
||||||
|
images.push(name);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
return { text: images.length ? rest.replace(/[ \t]+$/gm, "").trim() : text, images };
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropImage({ name }: { name: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [gone, setGone] = useState(false);
|
||||||
|
const src = `/api/run/drop/${encodeURIComponent(name)}`;
|
||||||
|
// Drops are pruned after a day: the name is all that is left to show.
|
||||||
|
if (gone)
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-2 py-1 text-xs text-fg-muted">
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{name.replace(/^[0-9a-f]{16}-?/, "") || name}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="block overflow-hidden rounded-xl border border-border bg-surface-2 hover:border-accent/50"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setGone(true)}
|
||||||
|
className="max-h-48 max-w-64 object-contain"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && <ImageLightbox src={src} alt={name} onClose={() => setOpen(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The human's turn: a bubble on the right, its time on hover. A long one (a
|
/** The human's turn: a bubble on the right, its time on hover. A long one (a
|
||||||
* paste, a compaction summary) shows its first lines until opened. */
|
* paste, a compaction summary) shows its first lines until opened. */
|
||||||
const LONG_BUBBLE = 800;
|
const LONG_BUBBLE = 800;
|
||||||
@@ -468,35 +699,54 @@ const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage })
|
|||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
|
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const blocks = useMemo(
|
||||||
|
() =>
|
||||||
|
msg.content.map((block) =>
|
||||||
|
block.type === "text" && block.text ? splitDropImages(block.text) : null
|
||||||
|
),
|
||||||
|
[msg.content]
|
||||||
|
);
|
||||||
|
const images = blocks.flatMap((b) => b?.images ?? []);
|
||||||
|
const hasText = blocks.some((b) => b?.text);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="chat-user"
|
data-testid="chat-user"
|
||||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||||
className="group flex flex-col items-end gap-1"
|
className="group flex flex-col items-end gap-1"
|
||||||
>
|
>
|
||||||
<div
|
{images.length > 0 && (
|
||||||
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
|
<div
|
||||||
long && !open
|
data-testid="chat-user-images"
|
||||||
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
className="flex max-w-[85%] flex-wrap justify-end gap-2"
|
||||||
: ""
|
>
|
||||||
}`}
|
{images.map((name) => (
|
||||||
>
|
<DropImage key={name} name={name} />
|
||||||
{msg.content.map((block, b) => {
|
))}
|
||||||
if (block.type !== "text" || !block.text) return null;
|
</div>
|
||||||
// Plain typing reads as typed; anything structured keeps its rendering.
|
)}
|
||||||
const plain =
|
{hasText && (
|
||||||
!hasTuiTags(block.text) &&
|
<div
|
||||||
!isSkillContent(block.text) &&
|
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
|
||||||
!isTaskNotification(block.text);
|
long && !open
|
||||||
return plain ? (
|
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
||||||
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
: ""
|
||||||
{stripAnsi(block.text).trim()}
|
}`}
|
||||||
</p>
|
>
|
||||||
) : (
|
{blocks.map((block, b) => {
|
||||||
renderTextBlock(block.text, b)
|
const text = block?.text;
|
||||||
);
|
if (!text) return null;
|
||||||
})}
|
// Plain typing reads as typed; anything structured keeps its rendering.
|
||||||
</div>
|
const plain = !hasTuiTags(text) && !isSkillContent(text) && !isTaskNotification(text);
|
||||||
|
return plain ? (
|
||||||
|
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||||
|
{stripAnsi(text).trim()}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
renderTextBlock(text, b)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{long && (
|
{long && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -540,8 +790,8 @@ const AssistantText = memo(function AssistantText({
|
|||||||
>
|
>
|
||||||
{msg.model && <span>{formatModelName(msg.model)}</span>}
|
{msg.model && <span>{formatModelName(msg.model)}</span>}
|
||||||
{msg.usage && (
|
{msg.usage && (
|
||||||
<span>
|
<span title={usageTitle(msg.usage)}>
|
||||||
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
|
↓ {fmt(promptTokens(msg.usage))} · ↑ {fmt(msg.usage.output_tokens)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
|
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
|
||||||
@@ -592,6 +842,8 @@ const WORK_KIND: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
|
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
|
||||||
|
const KIND_ORDER = ["commands", "edits", "reads", "searches", "web", "agents", "tools"];
|
||||||
|
|
||||||
export function workLabel(blocks: TranscriptContent[]): string {
|
export function workLabel(blocks: TranscriptContent[]): string {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
@@ -600,7 +852,9 @@ export function workLabel(blocks: TranscriptContent[]): string {
|
|||||||
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
if (!counts.size) return i18n.t("sessions:conversation.thinking");
|
if (!counts.size) return i18n.t("sessions:conversation.thinking");
|
||||||
|
// Claude Desktop's order: commands, edits, reads, … and other tools last.
|
||||||
const text = [...counts]
|
const text = [...counts]
|
||||||
|
.sort(([a], [b]) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b))
|
||||||
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
|
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
|
||||||
.join(", ");
|
.join(", ");
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
@@ -751,8 +1005,11 @@ const MessageRow = memo(function MessageRow({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.usage && (
|
{msg.usage && (
|
||||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
<span
|
||||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
title={usageTitle(msg.usage)}
|
||||||
|
className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span className="text-status-success/70">↓ {fmt(promptTokens(msg.usage))}</span>
|
||||||
<span className="text-fg-muted">·</span>
|
<span className="text-fg-muted">·</span>
|
||||||
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
|
<span className="text-syntax-number">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @file ConversationView.follow.test.tsx
|
||||||
|
* @description A chat sticks to its end: when it grows or is shown again
|
||||||
|
* after being hidden (a size change, which fires no scroll event) it scrolls
|
||||||
|
* to the newest while the reader is at the end, leaves a reader who scrolled
|
||||||
|
* up alone, and a send (`follow`) brings it back to the end.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ConversationView } from "../ConversationView";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
sessions: {
|
||||||
|
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||||
|
transcript: vi.fn(async () => ({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
content: [{ type: "text", text: "hello" }],
|
||||||
|
timestamp: "2026-10-09T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
has_more: false,
|
||||||
|
first_line: 1,
|
||||||
|
last_line: 1,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let resize: (() => void) | null = null;
|
||||||
|
const original = globalThis.ResizeObserver;
|
||||||
|
beforeEach(() => {
|
||||||
|
resize = null;
|
||||||
|
// @ts-expect-error test stub
|
||||||
|
globalThis.ResizeObserver = class {
|
||||||
|
constructor(cb: () => void) {
|
||||||
|
resize = cb;
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.ResizeObserver = original;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The scroll box, with the layout jsdom does not compute. */
|
||||||
|
function box(height: number) {
|
||||||
|
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
|
||||||
|
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
|
||||||
|
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
|
||||||
|
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ConversationView sticks to its end", () => {
|
||||||
|
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
|
||||||
|
const view = render(<ConversationView sessionId="s1" fill live />);
|
||||||
|
await screen.findByText("hello");
|
||||||
|
const el = box(1000);
|
||||||
|
// Shown again after display:none, or taller: only a resize says so.
|
||||||
|
act(() => resize?.());
|
||||||
|
expect(el.scrollTop).toBe(1000);
|
||||||
|
|
||||||
|
// The reader scrolls up: growing no longer pulls them down.
|
||||||
|
el.scrollTop = 100;
|
||||||
|
fireEvent.scroll(el);
|
||||||
|
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
|
||||||
|
act(() => resize?.());
|
||||||
|
expect(el.scrollTop).toBe(100);
|
||||||
|
|
||||||
|
// They send a message: back to the end, and it sticks again.
|
||||||
|
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
|
||||||
|
expect(el.scrollTop).toBe(1400);
|
||||||
|
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
|
||||||
|
act(() => resize?.());
|
||||||
|
expect(el.scrollTop).toBe(1600);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* @file MessageList.edits.test.tsx
|
||||||
|
* @description Each assistant turn that changed files ends with Claude
|
||||||
|
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||||
|
* removed summed over the turn, a failed call left out, and a row click handed
|
||||||
|
* to the caller.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { MessageList } from "../MessageList";
|
||||||
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
|
const at = "2026-10-09T08:00:00.000Z";
|
||||||
|
const user = (text: string): TranscriptMessage =>
|
||||||
|
({
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
}) as TranscriptMessage;
|
||||||
|
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
|
||||||
|
({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "tool_use", id, name, input: { file_path } }],
|
||||||
|
}) as TranscriptMessage;
|
||||||
|
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
|
||||||
|
({
|
||||||
|
type: "user",
|
||||||
|
sender: "tool",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
|
||||||
|
}) as TranscriptMessage;
|
||||||
|
|
||||||
|
describe("the Edited N files card", () => {
|
||||||
|
it("sums each file's lines over the turn and opens a file on click", () => {
|
||||||
|
const onOpenFile = vi.fn();
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
messages={[
|
||||||
|
user("fix it"),
|
||||||
|
call("1", "Edit", "/w/a.ts"),
|
||||||
|
result("1", { added: 3, removed: 1 }),
|
||||||
|
call("2", "Write", "/w/b.md"),
|
||||||
|
result("2", { added: 10, removed: 0 }),
|
||||||
|
call("3", "Edit", "/w/a.ts"),
|
||||||
|
result("3", { added: 2, removed: 2 }),
|
||||||
|
call("4", "Edit", "/w/c.ts"),
|
||||||
|
result("4", undefined, true),
|
||||||
|
user("thanks"),
|
||||||
|
call("5", "Read", "/w/a.ts"),
|
||||||
|
result("5"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const cards = screen.getAllByTestId("edited-files");
|
||||||
|
expect(cards).toHaveLength(1); // the second turn only read
|
||||||
|
const card = cards[0]!;
|
||||||
|
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
|
||||||
|
expect(within(card).getByText("+15")).toBeTruthy();
|
||||||
|
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||||
|
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||||
|
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||||
|
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* @file MessageList.images.test.tsx
|
||||||
|
* @description An image the prompt box attached reaches the transcript as its
|
||||||
|
* saved drop path; the user's turn shows the image (served by
|
||||||
|
* `GET /api/run/drop/:name`), opens it full size on the page, and keeps any
|
||||||
|
* other path as typed.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MessageList, splitDropImages } from "../MessageList";
|
||||||
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
|
const SHOT = "366d0a87d12176a2-image.png";
|
||||||
|
|
||||||
|
describe("dropped images in a user turn", () => {
|
||||||
|
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
||||||
|
expect(
|
||||||
|
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
||||||
|
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT] });
|
||||||
|
// Windows' temp dir, no prefix name.
|
||||||
|
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||||
|
"0123456789abcdef.webp",
|
||||||
|
]);
|
||||||
|
// Not a drop, or not an image: left as typed.
|
||||||
|
for (const text of [
|
||||||
|
"/home/me/shot.png",
|
||||||
|
"/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf",
|
||||||
|
]) {
|
||||||
|
expect(splitDropImages(text)).toEqual({ text, images: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the image instead of its path, full size on click", () => {
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
timestamp: "2026-10-09T08:00:00.000Z",
|
||||||
|
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
|
||||||
|
},
|
||||||
|
] as TranscriptMessage[];
|
||||||
|
render(<MessageList messages={messages} loading={false} />);
|
||||||
|
|
||||||
|
const bubble = screen.getByTestId("chat-user");
|
||||||
|
expect(bubble).toHaveTextContent("look at this");
|
||||||
|
expect(bubble).not.toHaveTextContent("agenthub-drops");
|
||||||
|
const img = screen.getByRole("img", { name: SHOT });
|
||||||
|
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
|
||||||
|
|
||||||
|
fireEvent.click(img);
|
||||||
|
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MessageList, workLabel } from "../MessageList";
|
import { MessageList, workLabel, workingOn } from "../MessageList";
|
||||||
import type { TranscriptMessage } from "../../../lib/types";
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||||
@@ -76,5 +76,132 @@ describe("MessageList — sender attribution", () => {
|
|||||||
"Ran 2 commands, edited a file, read a file"
|
"Ran 2 commands, edited a file, read a file"
|
||||||
);
|
);
|
||||||
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
|
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
|
||||||
|
// Desktop's fixed order, whatever ran first: other tools always last.
|
||||||
|
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
|
||||||
|
"Ran 1 command, read a file, used a tool"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a thinking block's text as narration, between two groups", () => {
|
||||||
|
const call = (id: string) =>
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
|
||||||
|
});
|
||||||
|
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
|
||||||
|
const say = (text: string) =>
|
||||||
|
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
|
||||||
|
expect(screen.getByText("Found an issue.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the whole prompt as ↓, not just its uncached tail", () => {
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
content: [{ type: "text", text: "done" }],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 2,
|
||||||
|
output_tokens: 40,
|
||||||
|
cache_read_input_tokens: 120000,
|
||||||
|
cache_creation_input_tokens: 3000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends a live chat with the step still running", () => {
|
||||||
|
const messages = [
|
||||||
|
msg({ type: "user", sender: "user", content: [{ type: "text", text: "test it" }] }),
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "t1",
|
||||||
|
name: "Bash",
|
||||||
|
input: { command: "npm test", description: "Run the full client suite" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
||||||
|
const row = screen.getByTestId("chat-running-step");
|
||||||
|
expect(row).toHaveTextContent("Run the full client suite");
|
||||||
|
expect(row).toHaveTextContent(/1m \ds/);
|
||||||
|
// Its result arrived: nothing is running any more.
|
||||||
|
rerender(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
live
|
||||||
|
messages={[
|
||||||
|
...messages,
|
||||||
|
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("knows what a live turn is doing until it ends", () => {
|
||||||
|
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||||
|
const at = "2026-06-26T08:19:00.000Z";
|
||||||
|
const prompt = msg({ type: "user", sender: "user", timestamp: at });
|
||||||
|
const call = msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
stop_reason: "tool_use",
|
||||||
|
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
|
||||||
|
});
|
||||||
|
const result = msg({
|
||||||
|
sender: "tool",
|
||||||
|
timestamp: at,
|
||||||
|
content: [{ type: "tool_result", id: "t1", output: "ok" }],
|
||||||
|
});
|
||||||
|
const reply = (stop_reason?: string) =>
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
stop_reason,
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
});
|
||||||
|
const results = (m: ReturnType<typeof msg>[]) =>
|
||||||
|
new Map(
|
||||||
|
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
|
||||||
|
);
|
||||||
|
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
||||||
|
|
||||||
|
expect(state([prompt])).toEqual({ block: null, since: at }); // thinking on the prompt
|
||||||
|
expect(state([prompt, call])?.block?.id).toBe("t1"); // the call is running
|
||||||
|
expect(state([prompt, call, result])).toEqual({ block: null, since: at }); // thinking again
|
||||||
|
expect(state([prompt, call, result, reply("tool_use")])?.block).toBeNull(); // narration, a call follows
|
||||||
|
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
||||||
|
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
||||||
|
const stopped = msg({
|
||||||
|
type: "user",
|
||||||
|
sender: "user",
|
||||||
|
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||||
|
});
|
||||||
|
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||||
|
// Silent for 15 minutes with nothing running: the turn died.
|
||||||
|
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
|
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
|
||||||
|
* which a click into the field opens too.
|
||||||
* The worktree lane returned is `status: "provisioning"`:
|
* The worktree lane returned is `status: "provisioning"`:
|
||||||
* the route answers before the actual `git worktree add` runs, so this
|
* the route answers before the actual `git worktree add` runs, so this
|
||||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||||
@@ -327,6 +328,7 @@ export function AddLaneModal({
|
|||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const browseRef = useRef<HTMLButtonElement>(null);
|
||||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||||
|
|
||||||
// A summary on screen means the lane already exists and a step failed, so
|
// A summary on screen means the lane already exists and a step failed, so
|
||||||
@@ -459,7 +461,14 @@ export function AddLaneModal({
|
|||||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||||
<div className="min-w-0 flex-1">
|
{/* A click into the path opens the picker, as Browse does (the
|
||||||
|
OS's own where it can be); typing still works from the keyboard. */}
|
||||||
|
<div
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CwdAutocomplete
|
<CwdAutocomplete
|
||||||
inputId="add-lane-repo"
|
inputId="add-lane-repo"
|
||||||
value={sourceRepo}
|
value={sourceRepo}
|
||||||
@@ -467,7 +476,12 @@ export function AddLaneModal({
|
|||||||
suggestions={cwdSuggestions}
|
suggestions={cwdSuggestions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
<PathPickButton
|
||||||
|
start={sourceRepo}
|
||||||
|
onPick={setSourceRepo}
|
||||||
|
showLabel
|
||||||
|
buttonRef={browseRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] text-fg-muted">
|
<p className="mt-1 text-[10px] text-fg-muted">
|
||||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export function FolderBrowseModal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("browse.title")}
|
aria-label={t("browse.title")}
|
||||||
|
data-tour="folder-browse"
|
||||||
>
|
>
|
||||||
<div className="border-b border-border p-3">
|
<div className="border-b border-border p-3">
|
||||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
* 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
|
||||||
* mode switcher's Files button; at full width the sidebar)
|
* mode switcher's Files button; at full width the sidebar);
|
||||||
|
* a row dragged onto a prompt box attaches it as `@path`
|
||||||
*
|
*
|
||||||
* and three ranges: `work` (uncommitted, what the agent has touched right
|
* and three ranges: `work` (uncommitted, what the agent has touched right
|
||||||
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
|
* now), `branch` (`base...HEAD`, what this lane adds on top of its base) and
|
||||||
* `commit` (one commit, reached from history).
|
* `commit` (one commit, reached from history).
|
||||||
*
|
*
|
||||||
|
* A file can also be opened from outside the panel (`openRequest`: a chat's
|
||||||
|
* "Edited N files" card), the same way a click in the tree opens it.
|
||||||
|
*
|
||||||
* Only the overview polls. Review, file, history and browse are a deliberate
|
* Only the overview polls. Review, file, history and browse are a deliberate
|
||||||
* read of a frozen moment — re-fetching under the reader's scroll position is
|
* read of a frozen moment — re-fetching under the reader's scroll position is
|
||||||
* worse than a slightly stale view, and those are the expensive shapes.
|
* worse than a slightly stale view, and those are the expensive shapes.
|
||||||
@@ -121,6 +125,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
|||||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
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 { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||||
import type {
|
import type {
|
||||||
LaneChangeFile,
|
LaneChangeFile,
|
||||||
@@ -277,6 +282,8 @@ function TreeNodes({
|
|||||||
data-tree-ignored={e.ignored || undefined}
|
data-tree-ignored={e.ignored || undefined}
|
||||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||||
|
draggable
|
||||||
|
onDragStart={(ev) => dragProjectPath(ev, e.dir ? `${e.path}/` : e.path)}
|
||||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||||
aria-current={active === e.path ? "true" : undefined}
|
aria-current={active === e.path ? "true" : undefined}
|
||||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||||
@@ -792,18 +799,25 @@ function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
|||||||
export function LaneChangesPanel({
|
export function LaneChangesPanel({
|
||||||
laneId,
|
laneId,
|
||||||
session,
|
session,
|
||||||
|
source,
|
||||||
onNewLane,
|
onNewLane,
|
||||||
full = false,
|
full = false,
|
||||||
wide = full,
|
wide = full,
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
|
openRequest = null,
|
||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
|
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
||||||
|
openRequest?: { path: string; nonce: number } | null;
|
||||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
/** Show and act on that session's own worktree (`claude --worktree`)
|
/** Show and act on that session's own worktree (`claude --worktree`)
|
||||||
* instead of the workspace's folder. The Workspace keys the panel on it,
|
* instead of the workspace's folder. The Workspace keys the panel on it,
|
||||||
* so a different session is a fresh panel. */
|
* so a different session is a fresh panel. */
|
||||||
session?: string;
|
session?: string;
|
||||||
|
/** Show and act on that extra folder's repo instead (`lane_sources` id);
|
||||||
|
* wins over `session`, and is keyed on like it. */
|
||||||
|
source?: number;
|
||||||
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
|
||||||
onNewLane?: (branch?: string) => void;
|
onNewLane?: (branch?: string) => void;
|
||||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||||
@@ -815,11 +829,11 @@ export function LaneChangesPanel({
|
|||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
// One read of the working copy, shared by the header and every write below:
|
// One read of the working copy, shared by the header and every write below:
|
||||||
// a stage, a discard or a commit changes the counts the header shows.
|
// a stage, a discard or a commit changes the counts the header shows.
|
||||||
const [git, refreshGit] = useLaneGitFacts(laneId, session);
|
const [git, refreshGit] = useLaneGitFacts(laneId, session, source);
|
||||||
// A merge, cherry-pick or revert is open: no new one starts from history.
|
// A merge, cherry-pick or revert is open: no new one starts from history.
|
||||||
const opOpen = !!(git?.available && git.merging);
|
const opOpen = !!(git?.available && git.merging);
|
||||||
// Every git read and write below goes to the same tree as those facts.
|
// Every git read and write below goes to the same tree as those facts.
|
||||||
const target = gitTarget(laneId, session);
|
const target = gitTarget(laneId, session, source);
|
||||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||||
const comment = useCallback(
|
const comment = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -1232,6 +1246,11 @@ export function LaneChangesPanel({
|
|||||||
const show = (path: string) => {
|
const show = (path: string) => {
|
||||||
void confirmDrop().then((ok) => ok && showNow(path));
|
void confirmDrop().then((ok) => ok && showNow(path));
|
||||||
};
|
};
|
||||||
|
// A file named from outside the panel (a chat's "Edited N files" card).
|
||||||
|
useEffect(() => {
|
||||||
|
if (openRequest) show(openRequest.path);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per request
|
||||||
|
}, [openRequest?.nonce]);
|
||||||
// A file's history (Show History): the commits that touched it.
|
// A file's history (Show History): the commits that touched it.
|
||||||
const showHistory = (path: string) => {
|
const showHistory = (path: string) => {
|
||||||
void confirmDrop().then((ok) => {
|
void confirmDrop().then((ok) => {
|
||||||
@@ -2053,6 +2072,8 @@ export function LaneChangesPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void openWhole(p)}
|
onClick={() => void openWhole(p)}
|
||||||
|
draggable
|
||||||
|
onDragStart={(ev) => dragProjectPath(ev, p)}
|
||||||
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
{p}
|
{p}
|
||||||
@@ -2105,6 +2126,7 @@ export function LaneChangesPanel({
|
|||||||
<LaneGitHeader
|
<LaneGitHeader
|
||||||
laneId={laneId}
|
laneId={laneId}
|
||||||
session={session}
|
session={session}
|
||||||
|
source={source}
|
||||||
git={git}
|
git={git}
|
||||||
onChanged={rereadAll}
|
onChanged={rereadAll}
|
||||||
onNewLane={onNewLane}
|
onNewLane={onNewLane}
|
||||||
@@ -2487,7 +2509,9 @@ export function LaneChangesPanel({
|
|||||||
) : !data ? (
|
) : !data ? (
|
||||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||||
) : !data.available ? (
|
) : !data.available ? (
|
||||||
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
||||||
|
</p>
|
||||||
) : files.length === 0 && mode !== "file" ? (
|
) : files.length === 0 && mode !== "file" ? (
|
||||||
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
|
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
|
||||||
) : mode === "overview" ? (
|
) : mode === "overview" ? (
|
||||||
@@ -2678,6 +2702,7 @@ export function LaneChangesPanel({
|
|||||||
<LaneConflictView
|
<LaneConflictView
|
||||||
laneId={laneId}
|
laneId={laneId}
|
||||||
session={session}
|
session={session}
|
||||||
|
source={source}
|
||||||
path={openFile}
|
path={openFile}
|
||||||
onResolved={() => {
|
onResolved={() => {
|
||||||
refreshGit();
|
refreshGit();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
|
|||||||
export function LaneConflictView({
|
export function LaneConflictView({
|
||||||
laneId,
|
laneId,
|
||||||
session,
|
session,
|
||||||
|
source,
|
||||||
path,
|
path,
|
||||||
onResolved,
|
onResolved,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -45,6 +46,8 @@ export function LaneConflictView({
|
|||||||
laneId: number;
|
laneId: number;
|
||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
|
source?: number;
|
||||||
path: string;
|
path: string;
|
||||||
/** The file left the conflict (resolved, or a whole side taken). */
|
/** The file left the conflict (resolved, or a whole side taken). */
|
||||||
onResolved: () => void;
|
onResolved: () => void;
|
||||||
@@ -65,7 +68,7 @@ export function LaneConflictView({
|
|||||||
setChoices([]);
|
setChoices([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
api.lanes
|
api.lanes
|
||||||
.gitFile(gitTarget(laneId, session), path, "work")
|
.gitFile(gitTarget(laneId, session, source), path, "work")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
setFile(
|
setFile(
|
||||||
@@ -78,7 +81,7 @@ export function LaneConflictView({
|
|||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
};
|
};
|
||||||
}, [laneId, session, path]);
|
}, [laneId, session, source, path]);
|
||||||
|
|
||||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||||
@@ -102,15 +105,18 @@ export function LaneConflictView({
|
|||||||
run(async () => {
|
run(async () => {
|
||||||
if (!parts || !file?.sha) return;
|
if (!parts || !file?.sha) return;
|
||||||
await api.lanes.gitSaveFile(
|
await api.lanes.gitSaveFile(
|
||||||
gitTarget(laneId, session),
|
gitTarget(laneId, session, source),
|
||||||
path,
|
path,
|
||||||
applyChoices(parts, choices).join("\n"),
|
applyChoices(parts, choices).join("\n"),
|
||||||
file.sha
|
file.sha
|
||||||
);
|
);
|
||||||
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
|
await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
|
||||||
|
path,
|
||||||
|
side: "done",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const takeAll = (side: "ours" | "theirs") =>
|
const takeAll = (side: "ours" | "theirs") =>
|
||||||
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
|
run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
|
||||||
|
|
||||||
const choose = (i: number, c: ConflictChoice | undefined) =>
|
const choose = (i: number, c: ConflictChoice | undefined) =>
|
||||||
setChoices((prev) => {
|
setChoices((prev) => {
|
||||||
|
|||||||
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
|||||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| "fetch"
|
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||||
| "pull"
|
|
||||||
| "push"
|
|
||||||
| "switch"
|
|
||||||
| "branch-create"
|
|
||||||
| "branch-delete"
|
|
||||||
| "merge"
|
|
||||||
| "publish";
|
|
||||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||||
|
|
||||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||||
@@ -95,6 +88,7 @@ function Btn({
|
|||||||
export function LaneGitBar({
|
export function LaneGitBar({
|
||||||
laneId,
|
laneId,
|
||||||
session,
|
session,
|
||||||
|
source,
|
||||||
git,
|
git,
|
||||||
onChanged,
|
onChanged,
|
||||||
onNewLane,
|
onNewLane,
|
||||||
@@ -103,6 +97,8 @@ export function LaneGitBar({
|
|||||||
laneId: number;
|
laneId: number;
|
||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
|
source?: number;
|
||||||
git: LaneGitFacts;
|
git: LaneGitFacts;
|
||||||
/** Rendered at the start of the action row: the header's branch line, so
|
/** Rendered at the start of the action row: the header's branch line, so
|
||||||
* the facts and the buttons that act on them read as one row. */
|
* the facts and the buttons that act on them read as one row. */
|
||||||
@@ -165,7 +161,7 @@ export function LaneGitBar({
|
|||||||
setBusy(action);
|
setBusy(action);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
|
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
|
||||||
// Landed after a lane switch: every write below belongs to the lane
|
// Landed after a lane switch: every write below belongs to the lane
|
||||||
// no longer shown.
|
// no longer shown.
|
||||||
if (laneNow.current !== lane) return;
|
if (laneNow.current !== lane) return;
|
||||||
@@ -218,7 +214,7 @@ export function LaneGitBar({
|
|||||||
|
|
||||||
const refreshBranches = async () => {
|
const refreshBranches = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
|
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||||
if (laneNow.current !== laneId) return;
|
if (laneNow.current !== laneId) return;
|
||||||
setBranches(r.branches);
|
setBranches(r.branches);
|
||||||
setWorktrees(r.worktrees || []);
|
setWorktrees(r.worktrees || []);
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
|
|||||||
export function useLaneGitFacts(
|
export function useLaneGitFacts(
|
||||||
laneId: number,
|
laneId: number,
|
||||||
/** Read that session's own worktree instead (`?session=`). */
|
/** Read that session's own worktree instead (`?session=`). */
|
||||||
session?: string
|
session?: string,
|
||||||
|
/** Read that extra folder's repo instead (`?source=`). */
|
||||||
|
source?: number
|
||||||
): [LaneGitFacts | null, () => void] {
|
): [LaneGitFacts | null, () => void] {
|
||||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||||
@@ -54,7 +56,7 @@ export function useLaneGitFacts(
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const read = () => {
|
const read = () => {
|
||||||
api.lanes
|
api.lanes
|
||||||
.git(gitTarget(laneId, session))
|
.git(gitTarget(laneId, session, source))
|
||||||
.then((f) => {
|
.then((f) => {
|
||||||
if (alive) setFacts(keepIfSame(f));
|
if (alive) setFacts(keepIfSame(f));
|
||||||
})
|
})
|
||||||
@@ -73,7 +75,7 @@ export function useLaneGitFacts(
|
|||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [laneId, session, nonce]);
|
}, [laneId, session, source, nonce]);
|
||||||
|
|
||||||
return [facts, () => setNonce((n) => n + 1)];
|
return [facts, () => setNonce((n) => n + 1)];
|
||||||
}
|
}
|
||||||
@@ -322,20 +324,23 @@ export function LaneGitCounts({
|
|||||||
export function LaneGitRailCounts({
|
export function LaneGitRailCounts({
|
||||||
laneId,
|
laneId,
|
||||||
session,
|
session,
|
||||||
|
source,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
}: {
|
}: {
|
||||||
laneId: number;
|
laneId: number;
|
||||||
session?: string;
|
session?: string;
|
||||||
|
source?: number;
|
||||||
/** Opens the Changes panel from the file list's "… more files". */
|
/** Opens the Changes panel from the file list's "… more files". */
|
||||||
onShowAll?: () => void;
|
onShowAll?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [git] = useLaneGitFacts(laneId, session);
|
const [git] = useLaneGitFacts(laneId, session, source);
|
||||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LaneGitHeader({
|
export function LaneGitHeader({
|
||||||
laneId,
|
laneId,
|
||||||
session,
|
session,
|
||||||
|
source,
|
||||||
git,
|
git,
|
||||||
onChanged,
|
onChanged,
|
||||||
onNewLane,
|
onNewLane,
|
||||||
@@ -343,6 +348,8 @@ export function LaneGitHeader({
|
|||||||
laneId: number;
|
laneId: number;
|
||||||
/** Work in that session's own worktree (see `gitTarget`). */
|
/** Work in that session's own worktree (see `gitTarget`). */
|
||||||
session?: string;
|
session?: string;
|
||||||
|
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||||
|
source?: number;
|
||||||
git: LaneGitFacts | null;
|
git: LaneGitFacts | null;
|
||||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
@@ -374,6 +381,7 @@ export function LaneGitHeader({
|
|||||||
<LaneGitBar
|
<LaneGitBar
|
||||||
laneId={laneId}
|
laneId={laneId}
|
||||||
session={session}
|
session={session}
|
||||||
|
source={source}
|
||||||
git={git}
|
git={git}
|
||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
onNewLane={onNewLane}
|
onNewLane={onNewLane}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
|
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
|
||||||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||||||
* `ConsoleTabs.tsx`). Inside
|
* `ConsoleTabs.tsx`). Inside
|
||||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
* the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
|
||||||
|
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
|
||||||
* by default; its last row, **Open terminal**, is no AI — it starts a plain
|
* by default; its last row, **Open terminal**, is no AI — it starts a plain
|
||||||
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||||
@@ -59,6 +60,8 @@ import { fmtCost, timeAgo } from "../../lib/format";
|
|||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||||
|
import { ChatAddMenu } from "../run/ChatAddMenu";
|
||||||
|
import { addAttachments } from "../../lib/promptAttach";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
@@ -115,12 +118,15 @@ function HomeBody({
|
|||||||
lane,
|
lane,
|
||||||
onMenuAction,
|
onMenuAction,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
|
onSourcesChange,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||||
onMenuAction: (action: WorkspaceMenuAction) => void;
|
onMenuAction: (action: WorkspaceMenuAction) => void;
|
||||||
/** Called after a rename, so the page re-reads the lane list. */
|
/** Called after a rename, so the page re-reads the lane list. */
|
||||||
onRenamed?: () => void;
|
onRenamed?: () => void;
|
||||||
|
/** Called with the sources after a folder or file is added or removed. */
|
||||||
|
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -149,6 +155,7 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
}, [draftKey, prompt]);
|
}, [draftKey, prompt]);
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
const pickFiles = useRef<(() => void) | null>(null);
|
||||||
const [proxy, setProxy] = useSessionProxy();
|
const [proxy, setProxy] = useSessionProxy();
|
||||||
const proxies = useAiProxies();
|
const proxies = useAiProxies();
|
||||||
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||||
@@ -310,7 +317,9 @@ function HomeBody({
|
|||||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setView(await fn());
|
const next = await fn();
|
||||||
|
setView(next);
|
||||||
|
onSourcesChange?.(next);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(message(err));
|
setError(message(err));
|
||||||
@@ -535,9 +544,18 @@ function HomeBody({
|
|||||||
images={images}
|
images={images}
|
||||||
onImagesChange={setImages}
|
onImagesChange={setImages}
|
||||||
onBusyChange={setUploading}
|
onBusyChange={setUploading}
|
||||||
|
pickFiles={pickFiles}
|
||||||
bare
|
bare
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex items-center gap-1">
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<ChatAddMenu
|
||||||
|
cwd={lane.cwd}
|
||||||
|
laneId={lane.id}
|
||||||
|
skills={claude}
|
||||||
|
onUpload={() => pickFiles.current?.()}
|
||||||
|
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
|
||||||
|
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||||
|
/>
|
||||||
<div data-testid="workspace-home-tool">
|
<div data-testid="workspace-home-tool">
|
||||||
<Select<string>
|
<Select<string>
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||||
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
|
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
|
||||||
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
|
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
|
||||||
* dot's meaning is screen-reader text, and an idle dot is a ring.
|
* dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly`
|
||||||
|
* row (another user's workspace, for an analyst) has no menu, rename or `+`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, type MouseEvent } from "react";
|
import { useState, type MouseEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
|
|||||||
export function WorkspaceRow({
|
export function WorkspaceRow({
|
||||||
lane,
|
lane,
|
||||||
index,
|
index,
|
||||||
|
readOnly,
|
||||||
selected,
|
selected,
|
||||||
expanded,
|
expanded,
|
||||||
renaming,
|
renaming,
|
||||||
@@ -63,6 +65,9 @@ export function WorkspaceRow({
|
|||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** Position in the list — the unit both reorder paths speak in. */
|
/** Position in the list — the unit both reorder paths speak in. */
|
||||||
index: number;
|
index: number;
|
||||||
|
/** Set (to its tooltip) for a workspace this user may only read: no menu,
|
||||||
|
* no rename, no new session. */
|
||||||
|
readOnly?: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
renaming: boolean;
|
renaming: boolean;
|
||||||
@@ -82,7 +87,12 @@ export function WorkspaceRow({
|
|||||||
const openMenu = (e: MouseEvent) => {
|
const openMenu = (e: MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onMenu(e.clientX, e.clientY);
|
if (!readOnly) onMenu(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
const startRename = () => {
|
||||||
|
if (readOnly) return;
|
||||||
|
setDraft(lane.title || "");
|
||||||
|
onRenameStart();
|
||||||
};
|
};
|
||||||
const Chevron = expanded ? ChevronDown : ChevronRight;
|
const Chevron = expanded ? ChevronDown : ChevronRight;
|
||||||
const hoverBtn =
|
const hoverBtn =
|
||||||
@@ -140,22 +150,18 @@ export function WorkspaceRow({
|
|||||||
aria-current={selected ? "true" : undefined}
|
aria-current={selected ? "true" : undefined}
|
||||||
// A double-click's second click renames instead of opening again.
|
// A double-click's second click renames instead of opening again.
|
||||||
onClick={(e) => e.detail < 2 && onSelect()}
|
onClick={(e) => e.detail < 2 && onSelect()}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={startRename}
|
||||||
setDraft(lane.title || "");
|
|
||||||
onRenameStart();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "F2") {
|
if (e.key === "F2") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDraft(lane.title || "");
|
startRename();
|
||||||
onRenameStart();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||||
}}
|
}}
|
||||||
title={`${lane.cwd}\n${t("row.hint")}`}
|
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||||
>
|
>
|
||||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||||
@@ -163,7 +169,8 @@ export function WorkspaceRow({
|
|||||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!renaming && (
|
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||||
|
{!renaming && !readOnly && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -24,7 +24,9 @@
|
|||||||
* 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 asks first. F2 renames the focused row too; each
|
||||||
* dot carries its meaning as screen-reader text.
|
* 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
|
||||||
|
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -220,6 +222,7 @@ export function WorkspaceSessionList({
|
|||||||
selectedSessionId = null,
|
selectedSessionId = null,
|
||||||
onMatches,
|
onMatches,
|
||||||
hidden = false,
|
hidden = false,
|
||||||
|
readOnly = false,
|
||||||
}: {
|
}: {
|
||||||
lane: Lane;
|
lane: Lane;
|
||||||
/** Every live dashboard pane, from `GET /api/run`. */
|
/** Every live dashboard pane, from `GET /api/run`. */
|
||||||
@@ -235,6 +238,8 @@ export function WorkspaceSessionList({
|
|||||||
onMatches?: (count: number) => void;
|
onMatches?: (count: number) => void;
|
||||||
/** Mounted only to load and count, during a search that hides the row. */
|
/** Mounted only to load and count, during a search that hides the row. */
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** A workspace this user may only read. */
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -315,7 +320,7 @@ export function WorkspaceSessionList({
|
|||||||
};
|
};
|
||||||
}, [orphanIds]);
|
}, [orphanIds]);
|
||||||
const fresh =
|
const fresh =
|
||||||
sessions === null || q
|
sessions === null || q || readOnly
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
...laneRuns.filter(
|
...laneRuns.filter(
|
||||||
@@ -492,6 +497,7 @@ export function WorkspaceSessionList({
|
|||||||
// A double-click's second click renames; it must not
|
// A double-click's second click renames; it must not
|
||||||
// navigate (a second history entry) on top of the first.
|
// navigate (a second history entry) on top of the first.
|
||||||
if (e.detail > 1) return;
|
if (e.detail > 1) return;
|
||||||
|
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
|
||||||
navigate(
|
navigate(
|
||||||
run
|
run
|
||||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||||
@@ -501,19 +507,19 @@ export function WorkspaceSessionList({
|
|||||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => startRename(s)}
|
onDoubleClick={() => !readOnly && startRename(s)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "F2") {
|
if (e.key === "F2" && !readOnly) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startRename(s);
|
startRename(s);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => sessionCtx.open(e, s)}
|
onContextMenu={readOnly ? undefined : (e) => sessionCtx.open(e, s)}
|
||||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||||
sessionWorktreeName(lane, s)
|
sessionWorktreeName(lane, s)
|
||||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||||
: ""
|
: ""
|
||||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
}${readOnly ? "" : `\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}`}
|
||||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||||
selected
|
selected
|
||||||
? "bg-accent/10 text-fg-primary"
|
? "bg-accent/10 text-fg-primary"
|
||||||
@@ -539,21 +545,23 @@ export function WorkspaceSessionList({
|
|||||||
</span>
|
</span>
|
||||||
<AiToolIcon
|
<AiToolIcon
|
||||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
data-testid={`workspace-session-archive-${s.id}`}
|
type="button"
|
||||||
onClick={() => void archive(s)}
|
data-testid={`workspace-session-archive-${s.id}`}
|
||||||
title={t("tree.archiveSession")}
|
onClick={() => void archive(s)}
|
||||||
aria-label={t("tree.archiveSession")}
|
title={t("tree.archiveSession")}
|
||||||
// Invisible, not `hidden`: a keyboard user must be able to
|
aria-label={t("tree.archiveSession")}
|
||||||
// Tab onto it, and it shows when focused.
|
// Invisible, not `hidden`: a keyboard user must be able to
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
// Tab onto it, and it shows when focused.
|
||||||
>
|
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||||
<Archive className="h-3.5 w-3.5" />
|
>
|
||||||
</button>
|
<Archive className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -582,6 +582,19 @@ describe("AddLaneModal — folder browse", () => {
|
|||||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the picker on every click into the path field", async () => {
|
||||||
|
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
||||||
|
renderModal();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const field = document.getElementById("add-lane-repo")!;
|
||||||
|
|
||||||
|
await user.click(field);
|
||||||
|
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
|
||||||
|
await user.click(field);
|
||||||
|
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
||||||
|
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
it("falls back to the in-app browser when no native picker is available", async () => {
|
||||||
noNativePicker();
|
noNativePicker();
|
||||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
|
|||||||
@@ -255,6 +255,36 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(view).toHaveTextContent("keep");
|
expect(view).toHaveTextContent("keep");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a file asked for from outside (a chat's Edited files card), once per request", async () => {
|
||||||
|
const view = render(
|
||||||
|
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||||
|
range: "work",
|
||||||
|
path: "server/lib/git-ops.js",
|
||||||
|
context: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(api.lanes.changes).mockClear();
|
||||||
|
// The same request again changes nothing; a new nonce opens the next one.
|
||||||
|
view.rerender(
|
||||||
|
<LaneChangesPanel laneId={7} openRequest={{ path: "server/lib/git-ops.js", nonce: 1 }} />
|
||||||
|
);
|
||||||
|
view.rerender(<LaneChangesPanel laneId={7} openRequest={{ path: "src/main.ts", nonce: 2 }} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||||
|
range: "work",
|
||||||
|
path: "src/main.ts",
|
||||||
|
context: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(api.lanes.changes).not.toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
expect.objectContaining({ path: "server/lib/git-ops.js" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("asks for every file's hunks in review mode", async () => {
|
it("asks for every file's hunks in review mode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* the real UI); a step moves on when what it asked for has happened (the
|
* the real UI); a step moves on when what it asked for has happened (the
|
||||||
* dialog opened, the workspace exists, the session started), and steps back
|
* dialog opened, the workspace exists, the session started), and steps back
|
||||||
* when the user backed out (the dialog closed with nothing made). Targets are
|
* when the user backed out (the dialog closed with nothing made). Targets are
|
||||||
* existing `data-testid`s plus three `data-tour` marks in AddLaneModal. The
|
* existing `data-testid`s plus `data-tour` marks in AddLaneModal and
|
||||||
|
* FolderBrowseModal. The
|
||||||
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
|
* step reached is `localStorage["agenthub.tour"]`, so it is synced to the
|
||||||
* account and survives a reload; `done` ends it.
|
* account and survives a reload; `done` ends it.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -45,12 +46,14 @@ const STEPS: Record<
|
|||||||
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
|
{ target: () => Element | null; next?: () => StepId | null; info?: boolean }
|
||||||
> = {
|
> = {
|
||||||
add: {
|
add: {
|
||||||
|
// The sidebar's +; folded to a rail, its tree icon (which unfolds it).
|
||||||
target: () =>
|
target: () =>
|
||||||
shown('[data-testid="lane-empty"]') || shown('[data-testid="workspace-tree-add"]'),
|
shown('[data-testid="workspace-tree-add"]') || shown('[data-testid="sidebar-tree-rail"]'),
|
||||||
next: () => (modalOpen() ? "folder" : null),
|
next: () => (modalOpen() ? "folder" : null),
|
||||||
},
|
},
|
||||||
folder: {
|
folder: {
|
||||||
target: () => $('[data-tour="add-lane-folder"]'),
|
// The folder browser while it is open: it sits over the field.
|
||||||
|
target: () => $('[data-tour="folder-browse"]') || $('[data-tour="add-lane-folder"]'),
|
||||||
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
next: () => (modalOpen() ? null : composer() ? "prompt" : "add"),
|
||||||
info: true,
|
info: true,
|
||||||
},
|
},
|
||||||
@@ -113,7 +116,19 @@ export function OnboardingTour() {
|
|||||||
const moved = def.next?.();
|
const moved = def.next?.();
|
||||||
if (moved && moved !== step) return write(moved);
|
if (moved && moved !== step) return write(moved);
|
||||||
const el = def.target();
|
const el = def.target();
|
||||||
const r = el ? el.getBoundingClientRect() : null;
|
let r = el ? el.getBoundingClientRect() : null;
|
||||||
|
// The folder field's suggestions hang below it: light them too.
|
||||||
|
const list = el?.querySelector("[data-cwd-suggestions]")?.getBoundingClientRect();
|
||||||
|
if (r && list) {
|
||||||
|
const top = Math.min(r.top, list.top);
|
||||||
|
const left = Math.min(r.left, list.left);
|
||||||
|
r = new DOMRect(
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
Math.max(r.right, list.right) - left,
|
||||||
|
Math.max(r.bottom, list.bottom) - top
|
||||||
|
);
|
||||||
|
}
|
||||||
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
|
const d = el?.closest("[role=dialog]")?.getBoundingClientRect() ?? null;
|
||||||
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
|
setBox((prev) => (prev && d && prev.right === d.right ? prev : d));
|
||||||
// Only when it moved: a new DOMRect every tick would re-render forever.
|
// Only when it moved: a new DOMRect every tick would re-render forever.
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ function Wizard() {
|
|||||||
if (!last) return setIndex(index + 1);
|
if (!last) return setIndex(index + 1);
|
||||||
void markOnboarded();
|
void markOnboarded();
|
||||||
startTour();
|
startTour();
|
||||||
navigate("/workspace");
|
navigate("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ctrl/⌘+Enter continues, like Orca.
|
// Ctrl/⌘+Enter continues, like Orca.
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ describe("the tour", () => {
|
|||||||
it("follows the real UI: add → folder → create → prompt", async () => {
|
it("follows the real UI: add → folder → create → prompt", async () => {
|
||||||
const spy = boxed();
|
const spy = boxed();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
|
const page = mount('<button data-testid="workspace-tree-add">+</button>');
|
||||||
try {
|
try {
|
||||||
startTour();
|
startTour();
|
||||||
render(
|
render(
|
||||||
@@ -239,7 +239,7 @@ describe("the tour", () => {
|
|||||||
|
|
||||||
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
|
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
|
||||||
const spy = boxed();
|
const spy = boxed();
|
||||||
const page = mount('<button data-testid="lane-empty">Add workspace</button>');
|
const page = mount('<button data-testid="workspace-tree-add">+</button>');
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("agenthub.tour", "create");
|
localStorage.setItem("agenthub.tour", "create");
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatAddMenu.tsx
|
||||||
|
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
|
||||||
|
* files (the prompt box's own picker), what is already there to attach —
|
||||||
|
* Project files (a search of the box's folder, folders too:
|
||||||
|
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
|
||||||
|
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
|
||||||
|
* `lib/promptAttach.ts`) —, Skills (the user's and the project's)
|
||||||
|
* and Plugins → one plugin → the skills and commands it ships. Picking one
|
||||||
|
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||||
|
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||||
|
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||||
|
* and Manage opens the Claude config page on that tab.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
History,
|
||||||
|
Paperclip,
|
||||||
|
Plug,
|
||||||
|
Plus,
|
||||||
|
ScrollText,
|
||||||
|
Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
interface Lists {
|
||||||
|
skills: Entry[];
|
||||||
|
plugins: { name: string; items: Entry[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
function loadLists(cwd: string): Promise<Lists> {
|
||||||
|
return Promise.all([
|
||||||
|
api.ccConfig.skills(undefined, cwd).catch(() => ({ items: [] })),
|
||||||
|
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||||
|
]).then(([sk, pl]) => ({
|
||||||
|
skills: sk.items
|
||||||
|
.map((s) => ({ name: s.name, description: s.frontmatter?.description }))
|
||||||
|
.filter((s, i, all) => all.findIndex((o) => o.name === s.name) === i)
|
||||||
|
.sort(byName),
|
||||||
|
plugins: pl.plugins
|
||||||
|
.filter((p) => p.enabled !== false && p.contributes)
|
||||||
|
.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
items: [...p.contributes!.items.skills, ...p.contributes!.items.commands]
|
||||||
|
.map((it) => ({ name: it.name, description: it.description ?? undefined }))
|
||||||
|
.filter((it, i, all) => all.findIndex((o) => o.name === it.name) === i)
|
||||||
|
.sort(byName),
|
||||||
|
}))
|
||||||
|
.filter((p, i, all) => p.items.length > 0 && all.findIndex((o) => o.name === p.name) === i)
|
||||||
|
.sort(byName),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL =
|
||||||
|
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||||
|
const ROW =
|
||||||
|
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
more,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
onHover,
|
||||||
|
title,
|
||||||
|
testId,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
more?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onHover?: () => void;
|
||||||
|
title?: string;
|
||||||
|
testId?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
title={title}
|
||||||
|
data-testid={testId}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={onHover}
|
||||||
|
className={`${ROW} ${active ? "bg-surface-3 text-fg-primary" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-fg-muted">{icon}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{more && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-fg-muted" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project files: a search of the box's folder, shortest paths first. */
|
||||||
|
function ProjectFiles({ cwd, onPick }: { cwd: string; onPick: (rel: string) => void }) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [items, setItems] = useState<string[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
const tid = window.setTimeout(() => {
|
||||||
|
api.run
|
||||||
|
.files(cwd, q, true)
|
||||||
|
.then((r) => live && setItems(r.items))
|
||||||
|
.catch(() => live && setItems([]));
|
||||||
|
}, 120);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
window.clearTimeout(tid);
|
||||||
|
};
|
||||||
|
}, [cwd, q]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder={t("chat.searchFiles")}
|
||||||
|
data-testid="chat-add-project-search"
|
||||||
|
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent/50 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{!items || items.length === 0 ? (
|
||||||
|
<p className="px-3 py-1.5 text-xs text-fg-muted">{items ? t("chat.none") : "…"}</p>
|
||||||
|
) : (
|
||||||
|
items.map((p) => (
|
||||||
|
<Row
|
||||||
|
key={p}
|
||||||
|
icon={
|
||||||
|
p.endsWith("/") ? (
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
label={p}
|
||||||
|
title={p}
|
||||||
|
testId={`chat-add-project-${p}`}
|
||||||
|
onClick={() => onPick(p)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sub = "project" | "context" | "recent" | "skills" | "plugins";
|
||||||
|
|
||||||
|
export function ChatAddMenu({
|
||||||
|
cwd,
|
||||||
|
laneId = null,
|
||||||
|
skills,
|
||||||
|
onUpload,
|
||||||
|
onInsert,
|
||||||
|
onAttach,
|
||||||
|
}: {
|
||||||
|
cwd: string;
|
||||||
|
/** The workspace, for its Context files; none hides them. */
|
||||||
|
laneId?: number | null;
|
||||||
|
/** Offer Skills and Plugins: Claude only, they are Claude Code's. */
|
||||||
|
skills: boolean;
|
||||||
|
onUpload: () => void;
|
||||||
|
/** `/<name>` picked. */
|
||||||
|
onInsert: (command: string) => void;
|
||||||
|
/** An existing file picked: a project path, a context file, an upload. */
|
||||||
|
onAttach: (items: Attachment[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [sub, setSub] = useState<Sub | null>(null);
|
||||||
|
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||||
|
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||||
|
const [plugin, setPlugin] = useState<string | null>(null);
|
||||||
|
const [lists, setLists] = useState<Lists | null>(null);
|
||||||
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (skills && !lists) void loadLists(cwd).then(setLists);
|
||||||
|
|
||||||
|
const away = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||||
|
document.addEventListener("mousedown", away);
|
||||||
|
document.addEventListener("keydown", esc);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", away);
|
||||||
|
document.removeEventListener("keydown", esc);
|
||||||
|
};
|
||||||
|
}, [open, skills, lists, cwd]);
|
||||||
|
|
||||||
|
// Read again at each opening: either list may have grown since.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setRecent(recentUploads());
|
||||||
|
setContext(null);
|
||||||
|
if (laneId === null) return;
|
||||||
|
let live = true;
|
||||||
|
api.lanes
|
||||||
|
.sources(laneId)
|
||||||
|
.then(
|
||||||
|
(v) =>
|
||||||
|
live &&
|
||||||
|
setContext(
|
||||||
|
v.sources
|
||||||
|
.filter((s) => s.kind === "file" && !s.missing)
|
||||||
|
.map((s) => ({ path: `@${s.path}`, name: s.name }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch(() => live && setContext([]));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [open, laneId]);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false);
|
||||||
|
setSub(null);
|
||||||
|
setPlugin(null);
|
||||||
|
};
|
||||||
|
const pick = (command: string) => {
|
||||||
|
close();
|
||||||
|
onInsert(command);
|
||||||
|
};
|
||||||
|
const attach = (item: Attachment) => {
|
||||||
|
close();
|
||||||
|
onAttach([item]);
|
||||||
|
};
|
||||||
|
const manage = (tab: "skills" | "plugins") => {
|
||||||
|
close();
|
||||||
|
navigate(`/cc-config?tab=${tab}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = (items: Entry[], prefix: string, from?: string) =>
|
||||||
|
items.length === 0 ? (
|
||||||
|
<p className="px-3 py-1.5 text-xs text-fg-muted">{lists ? t("chat.none") : "…"}</p>
|
||||||
|
) : (
|
||||||
|
items.map((it) => (
|
||||||
|
<Row
|
||||||
|
key={it.name}
|
||||||
|
icon={<ScrollText className="h-4 w-4" />}
|
||||||
|
label={it.name}
|
||||||
|
title={[it.description, from].filter(Boolean).join("\n") || undefined}
|
||||||
|
testId={`chat-add-skill-${prefix}${it.name}`}
|
||||||
|
onClick={() => pick(`/${prefix}${it.name}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="chat-add"
|
||||||
|
aria-label={t("chat.add")}
|
||||||
|
title={t("chat.add")}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => (open ? close() : setOpen(true))}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
||||||
|
<Row
|
||||||
|
icon={<Paperclip className="h-4 w-4" />}
|
||||||
|
label={t("chat.upload")}
|
||||||
|
testId="chat-add-upload"
|
||||||
|
onHover={() => setSub(null)}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onUpload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
icon={<FolderOpen className="h-4 w-4" />}
|
||||||
|
label={t("chat.projectFiles")}
|
||||||
|
more
|
||||||
|
active={sub === "project"}
|
||||||
|
testId="chat-add-project"
|
||||||
|
onHover={() => setSub("project")}
|
||||||
|
onClick={() => setSub(sub === "project" ? null : "project")}
|
||||||
|
/>
|
||||||
|
{laneId !== null && (
|
||||||
|
<Row
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
label={t("chat.contextFiles")}
|
||||||
|
more
|
||||||
|
active={sub === "context"}
|
||||||
|
testId="chat-add-context"
|
||||||
|
onHover={() => setSub("context")}
|
||||||
|
onClick={() => setSub(sub === "context" ? null : "context")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Row
|
||||||
|
icon={<History className="h-4 w-4" />}
|
||||||
|
label={t("chat.recentUploads")}
|
||||||
|
more
|
||||||
|
active={sub === "recent"}
|
||||||
|
testId="chat-add-recent"
|
||||||
|
onHover={() => setSub("recent")}
|
||||||
|
onClick={() => setSub(sub === "recent" ? null : "recent")}
|
||||||
|
/>
|
||||||
|
{skills && (
|
||||||
|
<>
|
||||||
|
<Row
|
||||||
|
icon={<ScrollText className="h-4 w-4" />}
|
||||||
|
label={t("chat.skills")}
|
||||||
|
more
|
||||||
|
active={sub === "skills"}
|
||||||
|
testId="chat-add-skills"
|
||||||
|
onHover={() => setSub("skills")}
|
||||||
|
onClick={() => setSub(sub === "skills" ? null : "skills")}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
icon={<Plug className="h-4 w-4" />}
|
||||||
|
label={t("chat.plugins")}
|
||||||
|
more
|
||||||
|
active={sub === "plugins"}
|
||||||
|
testId="chat-add-plugins"
|
||||||
|
onHover={() => setSub("plugins")}
|
||||||
|
onClick={() => setSub(sub === "plugins" ? null : "plugins")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sub === "project" && (
|
||||||
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
||||||
|
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(sub === "context" || sub === "recent") && (
|
||||||
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{(sub === "context" ? context : recent)?.length ? (
|
||||||
|
(sub === "context" ? context! : recent).map((it) => (
|
||||||
|
<Row
|
||||||
|
key={it.path}
|
||||||
|
icon={<FileText className="h-4 w-4" />}
|
||||||
|
label={it.name}
|
||||||
|
title={it.path.replace(/^@/, "")}
|
||||||
|
testId={`chat-add-${sub}-${it.name}`}
|
||||||
|
onClick={() => attach(it)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-1.5 text-xs text-fg-muted">
|
||||||
|
{sub === "context" && !context ? "…" : t("chat.none")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sub === "skills" && (
|
||||||
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||||
|
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<Row
|
||||||
|
icon={<Settings2 className="h-4 w-4" />}
|
||||||
|
label={t("chat.manageSkills")}
|
||||||
|
onClick={() => manage("skills")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sub === "plugins" && (
|
||||||
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{lists && lists.plugins.length === 0 && (
|
||||||
|
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||||
|
)}
|
||||||
|
{!lists && <p className="px-3 py-1.5 text-xs text-fg-muted">…</p>}
|
||||||
|
{lists?.plugins.map((p) => (
|
||||||
|
<Row
|
||||||
|
key={p.name}
|
||||||
|
icon={<Plug className="h-4 w-4" />}
|
||||||
|
label={p.name}
|
||||||
|
more
|
||||||
|
active={plugin === p.name}
|
||||||
|
testId={`chat-add-plugin-${p.name}`}
|
||||||
|
onHover={() => setPlugin(p.name)}
|
||||||
|
onClick={() => setPlugin(plugin === p.name ? null : p.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<Row
|
||||||
|
icon={<Settings2 className="h-4 w-4" />}
|
||||||
|
label={t("chat.managePlugins")}
|
||||||
|
onClick={() => manage("plugins")}
|
||||||
|
/>
|
||||||
|
{shownPlugin && (
|
||||||
|
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{entries(
|
||||||
|
shownPlugin.items,
|
||||||
|
`${shownPlugin.name}:`,
|
||||||
|
t("chat.fromPlugin", { name: shownPlugin.name })
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatFilePreview.tsx
|
||||||
|
* @description A file a Chat pane's session touched, shown the way Claude
|
||||||
|
* Desktop shows one: `FilePreview` (name · EXT, Copy path, Close, then the
|
||||||
|
* content) and `FileListModal` (a list on the left, the picked file's preview
|
||||||
|
* on the right — Claude's Uploads dialog). Content comes only through routes
|
||||||
|
* that already guard it: a file inside the workspace through
|
||||||
|
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
||||||
|
* `~/.claude` (a memory file) through `GET /api/cc-config/file`, an attached
|
||||||
|
* image through `GET /api/run/drop/:name`. Anything else shows its path only.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||||
|
import { api, gitTarget } from "../../lib/api";
|
||||||
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
|
|
||||||
|
/** Where a Chat pane's files can be read from. */
|
||||||
|
export interface PreviewScope {
|
||||||
|
laneId: number | null;
|
||||||
|
/** The workspace's own folder. */
|
||||||
|
laneCwd: string;
|
||||||
|
/** The pane's folder — the workspace's, or the session's own worktree. */
|
||||||
|
cwd: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_RE = /\.(png|jpe?g|gif|webp)$/i;
|
||||||
|
const DROP_RE = /\/agenthub-drops-\d+\/([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)$/;
|
||||||
|
|
||||||
|
export const baseName = (p: string) => p.split("/").pop() || p;
|
||||||
|
/** `report.v2.xlsx` → `XLSX`; none → "". */
|
||||||
|
export const extOf = (p: string) => {
|
||||||
|
const m = /\.([A-Za-z0-9]+)$/.exec(baseName(p));
|
||||||
|
return m ? m[1]!.toUpperCase() : "";
|
||||||
|
};
|
||||||
|
/** An attached file's name without the drop dir's random prefix. */
|
||||||
|
export const displayName = (p: string) =>
|
||||||
|
DROP_RE.test(p) ? baseName(p).replace(/^[0-9a-f]{16}-?/, "") || baseName(p) : baseName(p);
|
||||||
|
|
||||||
|
type Content =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "image"; src: string }
|
||||||
|
| { kind: "text"; text: string; truncated: boolean }
|
||||||
|
| { kind: "none" };
|
||||||
|
|
||||||
|
function useContent(path: string, scope: PreviewScope): Content {
|
||||||
|
const [content, setContent] = useState<Content>({ kind: "loading" });
|
||||||
|
useEffect(() => {
|
||||||
|
const drop = DROP_RE.exec(path);
|
||||||
|
if (drop) {
|
||||||
|
setContent(
|
||||||
|
IMAGE_RE.test(path)
|
||||||
|
? { kind: "image", src: `/api/run/drop/${encodeURIComponent(drop[1]!)}` }
|
||||||
|
: { kind: "none" }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
const show = (p: Promise<Content>) => {
|
||||||
|
setContent({ kind: "loading" });
|
||||||
|
p.then((c) => live && setContent(c)).catch(() => live && setContent({ kind: "none" }));
|
||||||
|
};
|
||||||
|
const base = scope.cwd.replace(/\/+$/, "");
|
||||||
|
if (scope.laneId === null || !base || !path.startsWith(`${base}/`)) {
|
||||||
|
// Claude Code's own files (memory): the config pages' guarded read.
|
||||||
|
if (/\/\.claude\//.test(path) && !IMAGE_RE.test(path)) {
|
||||||
|
show(
|
||||||
|
api.ccConfig
|
||||||
|
.file(path)
|
||||||
|
.then((f) => ({ kind: "text", text: f.text, truncated: !!f.truncated }))
|
||||||
|
);
|
||||||
|
} else setContent({ kind: "none" });
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rel = path.slice(base.length + 1);
|
||||||
|
const target =
|
||||||
|
base === scope.laneCwd.replace(/\/+$/, "")
|
||||||
|
? scope.laneId
|
||||||
|
: gitTarget(scope.laneId, scope.sessionId);
|
||||||
|
if (IMAGE_RE.test(path)) {
|
||||||
|
setContent({ kind: "image", src: api.lanes.gitRawUrl(target, rel) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show(
|
||||||
|
api.lanes
|
||||||
|
.gitFile(target, rel)
|
||||||
|
.then((f) =>
|
||||||
|
f.available && !f.binary
|
||||||
|
? { kind: "text", text: f.lines.join("\n"), truncated: f.truncated }
|
||||||
|
: { kind: "none" }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [path, scope.cwd, scope.laneCwd, scope.laneId, scope.sessionId]);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const content = useContent(path, scope);
|
||||||
|
if (content.kind === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (content.kind === "image") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||||
|
<img
|
||||||
|
src={content.src}
|
||||||
|
alt={baseName(path)}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (content.kind === "none") {
|
||||||
|
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>{t("chat.outline.noPreview")}</p>
|
||||||
|
<p className="break-all font-mono text-xs">{path}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
{isMarkdown(path) ? (
|
||||||
|
<MarkdownPreview text={content.text} className="p-4" />
|
||||||
|
) : (
|
||||||
|
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
||||||
|
{content.text}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{content.truncated && (
|
||||||
|
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||||
|
export function FilePreview({
|
||||||
|
path,
|
||||||
|
scope,
|
||||||
|
onClose,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
scope: PreviewScope;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-testid="chat-file-preview"
|
||||||
|
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||||
|
>
|
||||||
|
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||||
|
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
||||||
|
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void copyText(path)}
|
||||||
|
title={t("chat.outline.copyPath", { path })}
|
||||||
|
aria-label={t("chat.outline.copyPath", { path })}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title={t("chat.outline.close")}
|
||||||
|
aria-label={t("chat.outline.close")}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<Body path={path} scope={scope} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||||
|
export function FileListModal({
|
||||||
|
title,
|
||||||
|
paths,
|
||||||
|
scope,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
paths: string[];
|
||||||
|
scope: PreviewScope;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [picked, setPicked] = useState(paths[0] ?? "");
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDialog(true, panelRef, onClose);
|
||||||
|
// Into document.body, like ImageLightbox: a pane's own stacking layer
|
||||||
|
// would otherwise draw over a `fixed` overlay opened from inside it.
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[200] flex items-center justify-center bg-surface-0/70 p-6"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
data-testid="chat-file-modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex h-[80vh] w-full max-w-6xl overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex w-80 flex-shrink-0 flex-col border-r border-border">
|
||||||
|
<h2 className="px-5 pb-3 pt-5 text-lg text-fg-primary">{title}</h2>
|
||||||
|
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||||||
|
{paths.map((p) => (
|
||||||
|
<li key={p}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPicked(p)}
|
||||||
|
title={p}
|
||||||
|
aria-current={p === picked}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm ${p === picked ? "bg-surface-3 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"}`}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||||
|
<span className="min-w-0 truncate">{displayName(p)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{picked && <FilePreview path={picked} scope={scope} onClose={onClose} className="flex-1" />}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatOutline.tsx
|
||||||
|
* @description The right-hand panel of a Chat pane shown alone, laid out like
|
||||||
|
* Claude Desktop's activity panel: the session's Progress (its
|
||||||
|
* TaskCreate/TaskUpdate or TodoWrite plan, only when it has one), the Outputs
|
||||||
|
* it wrote or edited (name + EXT tag; a click previews the file), and "Used in
|
||||||
|
* this session" — the files attached to its prompts, the files it read and the
|
||||||
|
* memory files it used (a click opens the list with a preview), its skills
|
||||||
|
* and MCP servers (a click opens the Claude config page). Read from
|
||||||
|
* `GET /api/sessions/:id/outline` by `useSessionOutline`, which the pane also
|
||||||
|
* uses for the collapsed button's count.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
FileCode,
|
||||||
|
FileImage,
|
||||||
|
FileSearch,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
History,
|
||||||
|
Paperclip,
|
||||||
|
Plug,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api, type SessionOutline } from "../../lib/api";
|
||||||
|
import { displayName, extOf } from "./ChatFilePreview";
|
||||||
|
|
||||||
|
const POLL_MS = 5000;
|
||||||
|
const EMPTY: SessionOutline = {
|
||||||
|
progress: [],
|
||||||
|
outputs: [],
|
||||||
|
files: [],
|
||||||
|
uploads: [],
|
||||||
|
memory: [],
|
||||||
|
skills: [],
|
||||||
|
mcp: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The session's outline, re-read on a visibility-gated poll. */
|
||||||
|
export function useSessionOutline(sessionId: string): SessionOutline {
|
||||||
|
const [outline, setOutline] = useState<SessionOutline>(EMPTY);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
const load = () =>
|
||||||
|
api.sessions
|
||||||
|
.outline(sessionId)
|
||||||
|
.then((o) => live && setOutline({ ...EMPTY, ...o }))
|
||||||
|
.catch(() => {});
|
||||||
|
void load();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") void load();
|
||||||
|
}, POLL_MS);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [sessionId]);
|
||||||
|
return outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutlineList = "uploads" | "files" | "memory";
|
||||||
|
|
||||||
|
function fileIcon(path: string) {
|
||||||
|
const ext = extOf(path).toLowerCase();
|
||||||
|
if (["xlsx", "xls", "csv", "tsv"].includes(ext)) return FileSpreadsheet;
|
||||||
|
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return FileImage;
|
||||||
|
if (
|
||||||
|
/^(c|cc|cpp|cs|css|go|h|html|java|js|jsx|json|kt|mjs|cjs|php|py|rb|rs|sh|sql|swift|ts|tsx|vue|ya?ml|toml)$/.test(
|
||||||
|
ext
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return FileCode;
|
||||||
|
return FileText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
action,
|
||||||
|
grow,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
action?: ReactNode;
|
||||||
|
/** Takes the panel's free height and scrolls inside it. */
|
||||||
|
grow?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<section className={`flex flex-col px-3 pt-3 ${grow && open ? "min-h-0 flex-1" : ""}`}>
|
||||||
|
<div className="flex items-center gap-1.5 px-1 pb-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-1.5 text-sm font-medium text-fg-primary"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{count ? <span className="text-xs font-normal text-fg-muted">{count}</span> : null}
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 text-fg-muted transition-transform ${open ? "" : "-rotate-90"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="ml-auto flex items-center">{action}</div>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
grow
|
||||||
|
? "min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,transparent,black_8px,black_calc(100%-8px),transparent)]"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowClass =
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left text-[13px] text-fg-secondary enabled:hover:bg-surface-3";
|
||||||
|
|
||||||
|
/** A "Used in this session" row: icon, label, the first names, +N. */
|
||||||
|
function UsedRow({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
names,
|
||||||
|
onClick,
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
names: string[];
|
||||||
|
onClick?: () => void;
|
||||||
|
to?: string;
|
||||||
|
}) {
|
||||||
|
const shown = names.slice(0, 2).join(", ");
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||||
|
<span className="flex-shrink-0 text-fg-primary">{label}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs text-fg-muted">{shown}</span>
|
||||||
|
{names.length > 2 && (
|
||||||
|
<span className="flex-shrink-0 text-xs text-fg-muted">+{names.length - 2}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const title = `${label}: ${names.join(", ")}`;
|
||||||
|
return to ? (
|
||||||
|
<Link to={to} title={title} className={`${rowClass} hover:bg-surface-3`}>
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button type="button" title={title} onClick={onClick} className={rowClass}>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stem = (p: string) => displayName(p).replace(/\.[A-Za-z0-9]+$/, "");
|
||||||
|
|
||||||
|
export function ChatOutline({
|
||||||
|
outline: o,
|
||||||
|
onClose,
|
||||||
|
onOpenFile,
|
||||||
|
onOpenList,
|
||||||
|
}: {
|
||||||
|
outline: SessionOutline;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenFile: (path: string) => void;
|
||||||
|
onOpenList: (list: OutlineList) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const used = o.uploads.length + o.files.length + o.memory.length + o.skills.length + o.mcp.length;
|
||||||
|
const close = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
title={t("chat.outline.hide")}
|
||||||
|
aria-label={t("chat.outline.hide")}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
data-testid="chat-outline"
|
||||||
|
aria-label={t("chat.outline.panel")}
|
||||||
|
className="m-3 ml-0 hidden w-80 flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 pb-2 lg:flex"
|
||||||
|
>
|
||||||
|
{o.progress.length > 0 && (
|
||||||
|
<Section title={t("chat.outline.progress")} action={close}>
|
||||||
|
{o.progress.map((task) => {
|
||||||
|
const done = task.status === "completed";
|
||||||
|
return (
|
||||||
|
<div key={task.id} className={rowClass} title={task.subject}>
|
||||||
|
{done ? (
|
||||||
|
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-accent-solid text-white">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`h-4 w-4 flex-shrink-0 rounded-full border-2 ${task.status === "in_progress" ? "animate-pulse border-accent" : "border-border"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 truncate ${done ? "text-fg-muted line-through" : task.status === "in_progress" ? "text-fg-primary" : ""}`}
|
||||||
|
>
|
||||||
|
{task.subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
<Section
|
||||||
|
title={t("chat.outline.outputs")}
|
||||||
|
count={o.outputs.length}
|
||||||
|
action={o.progress.length ? undefined : close}
|
||||||
|
grow
|
||||||
|
>
|
||||||
|
{o.outputs.length ? (
|
||||||
|
o.outputs.map((p) => {
|
||||||
|
const Icon = fileIcon(p);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
title={p}
|
||||||
|
aria-label={`${extOf(p)}: ${stem(p)}`}
|
||||||
|
onClick={() => onOpenFile(p)}
|
||||||
|
className={rowClass}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 flex-shrink-0 text-fg-muted" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{stem(p)}</span>
|
||||||
|
<span className="flex-shrink-0 text-[11px] text-fg-muted">{extOf(p)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="px-1.5 py-1 text-xs text-fg-muted">{t("chat.outline.empty")}</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
{used > 0 && (
|
||||||
|
<Section title={t("chat.outline.used")}>
|
||||||
|
{o.uploads.length > 0 && (
|
||||||
|
<UsedRow
|
||||||
|
icon={Paperclip}
|
||||||
|
label={t("chat.outline.uploads")}
|
||||||
|
names={o.uploads.map(displayName)}
|
||||||
|
onClick={() => onOpenList("uploads")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{o.files.length > 0 && (
|
||||||
|
<UsedRow
|
||||||
|
icon={FileSearch}
|
||||||
|
label={t("chat.outline.files")}
|
||||||
|
names={o.files.map(displayName)}
|
||||||
|
onClick={() => onOpenList("files")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{o.memory.length > 0 && (
|
||||||
|
<UsedRow
|
||||||
|
icon={History}
|
||||||
|
label={t("chat.outline.memory")}
|
||||||
|
names={o.memory.map(stem)}
|
||||||
|
onClick={() => onOpenList("memory")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{o.skills.length > 0 && (
|
||||||
|
<UsedRow
|
||||||
|
icon={BookOpen}
|
||||||
|
label={t("chat.skills")}
|
||||||
|
names={o.skills}
|
||||||
|
to="/settings/agents/claude-code?tab=skills"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{o.mcp.length > 0 && (
|
||||||
|
<UsedRow
|
||||||
|
icon={Plug}
|
||||||
|
label={t("chat.outline.connectors")}
|
||||||
|
names={o.mcp}
|
||||||
|
to="/settings/agents/claude-code?tab=mcp"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||||
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
|
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||||
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
|
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||||
|
* Appearance, `lib/chatUi.ts`) is only what a session shows first.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ import type { Lane } from "../../lib/types";
|
|||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
import { PaneChat } from "./PaneChat";
|
import { PaneChat } from "./PaneChat";
|
||||||
import { useChatUi } from "../../lib/chatUi";
|
import { useSessionChat } from "../../lib/chatUi";
|
||||||
|
|
||||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ export interface LaneConsolePaneProps {
|
|||||||
onRunExit?: () => void;
|
onRunExit?: () => void;
|
||||||
/** The terminal takes the keyboard when it appears (default). */
|
/** The terminal takes the keyboard when it appears (default). */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
/** The only pane shown: its Chat view gets the right panel. */
|
||||||
|
solo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
/** The tmux session a lane's tab owns (see server/lib/pty-run.js). */
|
||||||
@@ -68,6 +71,7 @@ export function LaneConsolePane({
|
|||||||
onNewSession,
|
onNewSession,
|
||||||
onRunExit,
|
onRunExit,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
solo = false,
|
||||||
}: LaneConsolePaneProps) {
|
}: LaneConsolePaneProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const [handle, setHandle] = useState<RunHandle | null>(null);
|
const [handle, setHandle] = useState<RunHandle | null>(null);
|
||||||
@@ -78,11 +82,11 @@ export function LaneConsolePane({
|
|||||||
// The tool of the run on screen — a session's own, which need not be the
|
// The tool of the run on screen — a session's own, which need not be the
|
||||||
// workspace's; the workspace's with no run.
|
// workspace's; the workspace's with no run.
|
||||||
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
||||||
const chat = useChatUi();
|
|
||||||
// A run learns its session id after it starts (Codex): the page's poll has it.
|
// A run learns its session id after it starts (Codex): the page's poll has it.
|
||||||
const chatSession = handle
|
const chatSession = handle
|
||||||
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
||||||
: null;
|
: null;
|
||||||
|
const chat = useSessionChat(chatSession);
|
||||||
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -192,10 +196,12 @@ export function LaneConsolePane({
|
|||||||
{chatable && chat && (
|
{chatable && chat && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
||||||
<PaneChat
|
<PaneChat
|
||||||
runId={handle.id}
|
run={handle}
|
||||||
sessionId={chatSession}
|
sessionId={chatSession}
|
||||||
tool={handle.tool}
|
|
||||||
cwd={handle.cwd || currentLane?.cwd || ""}
|
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||||
|
laneId={laneId}
|
||||||
|
laneCwd={currentLane?.cwd || ""}
|
||||||
|
outline={solo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,84 +5,319 @@
|
|||||||
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
||||||
* attached underneath — a message is sent through that terminal
|
* attached underneath — a message is sent through that terminal
|
||||||
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
|
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
|
||||||
* nothing new reaches the server. A permission prompt or a menu still needs the
|
* nothing new reaches the server. The box carries Claude Desktop's controls:
|
||||||
* terminal; the hint under the box says so.
|
* the `+` menu (`ChatAddMenu`: files — new, the project's, the workspace's
|
||||||
|
* context files, recent uploads —, skills, plugins' skills), and for a
|
||||||
|
* Claude pane the Model/Effort chip — a change is typed into the pane as
|
||||||
|
* `/model <id>` / `/effort <level>` — and the permission mode, which only
|
||||||
|
* Shift+Tab changes in a running session, so the server steps it and reads
|
||||||
|
* every step back off the pane (`POST /api/run/:id/permission-mode`); it sits
|
||||||
|
* in the box beside `+`. A permission prompt or a menu still needs the terminal.
|
||||||
|
* Shown alone (`outline`), it also has Claude Desktop's activity panel
|
||||||
|
* (`ChatOutline`: progress, outputs, what the session used): collapsed into a
|
||||||
|
* file button with the output count until clicked (remembered per browser);
|
||||||
|
* a file clicked there opens its preview in the panel's place
|
||||||
|
* (`FilePreview`) — so does one in a turn's "Edited N files" card outside
|
||||||
|
* the workspace's folder —, a group
|
||||||
|
* of files a list with a preview (`FileListModal`). A file in a turn's
|
||||||
|
* "Edited N files" card opens in the Workspace's Changes panel
|
||||||
|
* (`openInChanges`) when it lies in the pane's folder. A send scrolls the
|
||||||
|
* conversation to its end and keeps it there (`follow`), so the message and
|
||||||
|
* the step running after it come into sight on their own. While the session's
|
||||||
|
* main agent is working (a prompt submitted, no Stop yet) an empty box shows
|
||||||
|
* Stop in Send's place: Esc pressed in the pane (`interruptRun`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUp } from "lucide-react";
|
import { ArrowUp, FileText, Square } from "lucide-react";
|
||||||
import { ConversationView } from "../conversation/ConversationView";
|
import { ConversationView } from "../conversation/ConversationView";
|
||||||
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||||
import { submitToRun } from "../../lib/panePaste";
|
import { ChatAddMenu } from "./ChatAddMenu";
|
||||||
|
import { addAttachments } from "../../lib/promptAttach";
|
||||||
|
import { ChatOutline, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||||
|
import { FileListModal, FilePreview, type PreviewScope } from "./ChatFilePreview";
|
||||||
|
import { Select } from "../Select";
|
||||||
|
import { interruptRun, submitToRun } from "../../lib/panePaste";
|
||||||
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
|
import { openInChanges } from "../../lib/openInChanges";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||||
|
|
||||||
|
const MODES: [PermissionMode, string][] = [
|
||||||
|
["default", "Default"],
|
||||||
|
["acceptEdits", "AcceptEdits"],
|
||||||
|
["plan", "Plan"],
|
||||||
|
["auto", "Auto"],
|
||||||
|
["bypassPermissions", "Bypass"],
|
||||||
|
];
|
||||||
|
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||||
|
const OUTLINE_KEY = "agenthub.chat.outlineOpen";
|
||||||
|
|
||||||
|
/** The pane's permission mode as it shows it; a pick steps it there. */
|
||||||
|
function PermissionChip({ runId, initial }: { runId: string; initial: PermissionMode | null }) {
|
||||||
|
const { t } = useTranslation(["run", "lanes"]);
|
||||||
|
const [mode, setMode] = useState<PermissionMode>(initial ?? "default");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const read = useCallback(
|
||||||
|
() =>
|
||||||
|
api.run
|
||||||
|
.permissionMode(runId)
|
||||||
|
.then((r) => isSettable(r.mode) && setMode(r.mode))
|
||||||
|
.catch(() => {}),
|
||||||
|
[runId]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void read();
|
||||||
|
}, [read]);
|
||||||
|
// Bypass is in a session's cycle only when it was started with it.
|
||||||
|
const offered = MODES.filter(([v]) => v !== "bypassPermissions" || initial === v || mode === v);
|
||||||
|
return (
|
||||||
|
<div data-testid="pane-chat-mode">
|
||||||
|
<Select<PermissionMode>
|
||||||
|
variant="ghost"
|
||||||
|
label={t("run:fields.permissionMode")}
|
||||||
|
value={mode}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(v) => {
|
||||||
|
setBusy(true);
|
||||||
|
api.run
|
||||||
|
.setPermissionMode(runId, v)
|
||||||
|
.then((r) => setMode(r.mode))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
toast.error(
|
||||||
|
t("run:chat.modeFailed", { message: err instanceof Error ? err.message : "" })
|
||||||
|
);
|
||||||
|
void read();
|
||||||
|
})
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}}
|
||||||
|
options={offered.map(([value, key]) => ({
|
||||||
|
value,
|
||||||
|
label: t(`lanes:home.permission${key}`),
|
||||||
|
hint: t(`run:fields.permission${key}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PaneChat({
|
export function PaneChat({
|
||||||
runId,
|
run,
|
||||||
sessionId,
|
sessionId,
|
||||||
tool,
|
|
||||||
cwd,
|
cwd,
|
||||||
|
laneId = null,
|
||||||
|
laneCwd = "",
|
||||||
|
outline = false,
|
||||||
}: {
|
}: {
|
||||||
runId: string;
|
run: Pick<RunHandle, "id" | "tool" | "model" | "effort" | "permissionMode">;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
tool: string;
|
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
/** The workspace and its folder: a file under `cwd` is previewed through it. */
|
||||||
|
laneId?: number | null;
|
||||||
|
laneCwd?: string;
|
||||||
|
/** The pane is shown alone: room for the right panel. */
|
||||||
|
outline?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
|
const runId = run.id;
|
||||||
|
const claude = run.tool === "claude";
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [follow, setFollow] = useState(0);
|
||||||
|
const [model, setModel] = useState(run.model ?? "");
|
||||||
|
const [effort, setEffort] = useState<EffortLevel>(run.effort ?? "");
|
||||||
|
const pickFiles = useRef<(() => void) | null>(null);
|
||||||
|
// Collapsed until asked for, like Claude Desktop's activity panel.
|
||||||
|
const [outlineOpen, setOutlineOpen] = useState(() => localStorage.getItem(OUTLINE_KEY) === "1");
|
||||||
|
const openOutline = (open: boolean) => {
|
||||||
|
localStorage.setItem(OUTLINE_KEY, open ? "1" : "0");
|
||||||
|
setOutlineOpen(open);
|
||||||
|
setPreview(null);
|
||||||
|
};
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [list, setList] = useState<OutlineList | null>(null);
|
||||||
|
const activity = useSessionOutline(sessionId);
|
||||||
|
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
|
||||||
const text = promptWithImages(prompt, images);
|
const text = promptWithImages(prompt, images);
|
||||||
|
|
||||||
|
// The main agent is "working" from a prompt's submit to its Stop hook.
|
||||||
|
const [working, setWorking] = useState(false);
|
||||||
|
const readWorking = useCallback(
|
||||||
|
() =>
|
||||||
|
api.agents
|
||||||
|
.list({ session_id: sessionId, status: "working" })
|
||||||
|
// With session_id the server answers every agent of the session, its
|
||||||
|
// status filter ignored: check the status here.
|
||||||
|
.then((r) => setWorking(r.agents.some((a) => a.type === "main" && a.status === "working")))
|
||||||
|
.catch(() => {}),
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void readWorking();
|
||||||
|
}, [readWorking]);
|
||||||
|
useLiveRefresh(readWorking, {
|
||||||
|
throttleMs: 500,
|
||||||
|
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||||
|
// another dashboard on the same database, or the socket dropped) would
|
||||||
|
// otherwise leave Stop up after the turn ended.
|
||||||
|
intervalMs: working ? 5000 : undefined,
|
||||||
|
match: (msg) =>
|
||||||
|
msg.type === "agent_updated" &&
|
||||||
|
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A control is a command typed into the pane, like the user would.
|
||||||
|
const command = (line: string) => {
|
||||||
|
const ok = submitToRun(runId, line);
|
||||||
|
if (!ok) toast.error(t("chat.notSent"));
|
||||||
|
return ok;
|
||||||
|
};
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
if (!text || uploading) return;
|
if (!text || uploading) return;
|
||||||
if (!submitToRun(runId, text)) {
|
if (!command(text)) return;
|
||||||
toast.error(t("chat.notSent"));
|
setFollow((n) => n + 1);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div data-testid="pane-chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 pb-3">
|
||||||
<ConversationView sessionId={sessionId} fill />
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
</div>
|
<ConversationView
|
||||||
<div className="mx-4">
|
sessionId={sessionId}
|
||||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
fill
|
||||||
<PromptEditor
|
live
|
||||||
bare
|
follow={follow}
|
||||||
images={images}
|
onOpenFile={(path) => {
|
||||||
onImagesChange={setImages}
|
// The workspace's Changes panel when the file is in its folder;
|
||||||
onBusyChange={setUploading}
|
// else this view's own preview, when it has room for one.
|
||||||
value={prompt}
|
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||||
onChange={setPrompt}
|
if (outline) setPreview(path);
|
||||||
onSubmit={send}
|
}}
|
||||||
placeholder={t("chat.placeholder")}
|
|
||||||
rows={2}
|
|
||||||
slash={tool === "claude"}
|
|
||||||
fileCwd={cwd}
|
|
||||||
lane={runId}
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
{outline && !outlineOpen && !preview && (
|
||||||
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="pane-chat-send"
|
data-testid="chat-outline-show"
|
||||||
onClick={send}
|
onClick={() => openOutline(true)}
|
||||||
disabled={uploading || !text}
|
title={t("chat.outline.show")}
|
||||||
aria-label={t("chat.send")}
|
aria-label={t("chat.outline.show")}
|
||||||
title={t("chat.send")}
|
className="absolute right-3 top-2 hidden h-7 items-center gap-1 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary lg:flex"
|
||||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
{activity.outputs.length}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mx-4">
|
||||||
|
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||||
|
<div className="rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||||
|
<PromptEditor
|
||||||
|
bare
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
onBusyChange={setUploading}
|
||||||
|
pickFiles={pickFiles}
|
||||||
|
value={prompt}
|
||||||
|
onChange={setPrompt}
|
||||||
|
onSubmit={send}
|
||||||
|
placeholder={t("chat.placeholder")}
|
||||||
|
rows={2}
|
||||||
|
slash={claude}
|
||||||
|
fileCwd={cwd}
|
||||||
|
lane={runId}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex items-center gap-1">
|
||||||
|
<ChatAddMenu
|
||||||
|
cwd={cwd}
|
||||||
|
laneId={laneId}
|
||||||
|
skills={claude}
|
||||||
|
onUpload={() => pickFiles.current?.()}
|
||||||
|
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
|
||||||
|
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||||
|
/>
|
||||||
|
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{claude && (
|
||||||
|
<ModelMenu
|
||||||
|
value={model}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (command(`/model ${v || "default"}`)) setModel(v);
|
||||||
|
}}
|
||||||
|
tool={run.tool}
|
||||||
|
effort={effort}
|
||||||
|
onEffortChange={(e) => {
|
||||||
|
if (command(`/effort ${e || "auto"}`)) setEffort(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{working && !text ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="pane-chat-stop"
|
||||||
|
onClick={() => {
|
||||||
|
// An interrupted turn fires no Stop hook: clear it here.
|
||||||
|
if (interruptRun(runId)) setWorking(false);
|
||||||
|
else toast.error(t("chat.notStopped"));
|
||||||
|
}}
|
||||||
|
aria-label={t("chat.stop")}
|
||||||
|
title={t("chat.stop")}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3 fill-current" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="pane-chat-send"
|
||||||
|
onClick={send}
|
||||||
|
disabled={uploading || !text}
|
||||||
|
aria-label={t("chat.send")}
|
||||||
|
title={t("chat.send")}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{outline &&
|
||||||
|
(preview ? (
|
||||||
|
<FilePreview
|
||||||
|
path={preview}
|
||||||
|
scope={scope}
|
||||||
|
onClose={() => setPreview(null)}
|
||||||
|
// Beside the chat when there is room, over it when not.
|
||||||
|
className="absolute inset-0 z-30 rounded-xl border border-border lg:static lg:m-3 lg:ml-0 lg:w-[45%] lg:max-w-3xl lg:flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
outlineOpen && (
|
||||||
|
<ChatOutline
|
||||||
|
outline={activity}
|
||||||
|
onClose={() => openOutline(false)}
|
||||||
|
onOpenFile={setPreview}
|
||||||
|
onOpenList={setList}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
{list && (
|
||||||
|
<FileListModal
|
||||||
|
title={t(`chat.outline.${list}`)}
|
||||||
|
paths={activity[list]}
|
||||||
|
scope={scope}
|
||||||
|
onClose={() => setList(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Columns2,
|
Columns2,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
MessageSquare,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Rows2,
|
Rows2,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
} from "../../lib/paneLayout";
|
} from "../../lib/paneLayout";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||||
|
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||||
|
|
||||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||||
@@ -191,9 +193,37 @@ function PaneMenu({
|
|||||||
const ITEM =
|
const ITEM =
|
||||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||||
|
|
||||||
/** A pane's split-right / split-down menus: move a hidden run beside
|
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||||
* `slot`, or start a new terminal or AI tool there. In a pane header when the view is
|
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. */
|
||||||
* split, beside Tools in the tab bar while one pane is shown. */
|
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const chat = useSessionChat(run?.sessionId);
|
||||||
|
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
||||||
|
const sessionId = run.sessionId;
|
||||||
|
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`pane-chat-toggle-${run.slot ?? 0}`}
|
||||||
|
aria-pressed={chat}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => setSessionChat(sessionId, !chat)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{chat ? (
|
||||||
|
<SquareTerminal className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pane's chat/terminal toggle and split-right / split-down menus: move a
|
||||||
|
* hidden run beside `slot`, or start a new terminal or AI tool there. In a
|
||||||
|
* pane header when the view is split, beside Tools in the tab bar while one
|
||||||
|
* pane is shown. */
|
||||||
export function SplitMenus({
|
export function SplitMenus({
|
||||||
slot,
|
slot,
|
||||||
shown,
|
shown,
|
||||||
@@ -222,6 +252,7 @@ export function SplitMenus({
|
|||||||
const full = shown.length >= MAX_PANES;
|
const full = shown.length >= MAX_PANES;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<ChatToggle run={runs.find((x) => (x.slot ?? 0) === slot)} />
|
||||||
{(["right", "bottom"] as const).map((edge) => (
|
{(["right", "bottom"] as const).map((edge) => (
|
||||||
<PaneMenu
|
<PaneMenu
|
||||||
key={edge}
|
key={edge}
|
||||||
@@ -446,6 +477,8 @@ export function PaneGrid({
|
|||||||
key={slot}
|
key={slot}
|
||||||
data-testid={`pane-${slot}`}
|
data-testid={`pane-${slot}`}
|
||||||
data-focused={r && focused ? "true" : undefined}
|
data-focused={r && focused ? "true" : undefined}
|
||||||
|
// A menu opened in this pane stays inside it (`menuRoom`).
|
||||||
|
data-menu-bounds
|
||||||
className={r ? "absolute flex flex-col" : "hidden"}
|
className={r ? "absolute flex flex-col" : "hidden"}
|
||||||
style={r ? pct(r) : undefined}
|
style={r ? pct(r) : undefined}
|
||||||
onPointerDownCapture={() => r && focus(slot)}
|
onPointerDownCapture={() => r && focus(slot)}
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
* box (`WorkspaceHome.tsx`):
|
* box (`WorkspaceHome.tsx`):
|
||||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||||
* newlines, and pasted or dropped files (images as thumbnails, any other
|
* newlines, and pasted, dropped or picked files (`pickFiles`, a composer's
|
||||||
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
* + menu; images as thumbnails, opened full size in `ImageLightbox`, any
|
||||||
|
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||||
|
* remembered for the + menu's Recent uploads), and a Files panel row
|
||||||
|
* dropped on it (`lib/promptAttach.ts`) as an `@path` chip;
|
||||||
|
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||||
@@ -19,7 +23,17 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type Dispatch,
|
||||||
|
type MutableRefObject,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -42,9 +56,16 @@ import {
|
|||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||||
import type { LaneTool } from "../../lib/types";
|
import type { LaneTool } from "../../lib/types";
|
||||||
import { Select } from "../Select";
|
import { Select, menuRoom } from "../Select";
|
||||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { ImageLightbox } from "../ImageLightbox";
|
||||||
|
import {
|
||||||
|
ATTACH_MIME,
|
||||||
|
addAttachments,
|
||||||
|
droppedAttachment,
|
||||||
|
rememberUploads,
|
||||||
|
} from "../../lib/promptAttach";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -158,13 +179,16 @@ interface PromptEditorProps {
|
|||||||
* sends them with the text through `promptWithImages`. */
|
* sends them with the text through `promptWithImages`. */
|
||||||
images: PromptImage[];
|
images: PromptImage[];
|
||||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||||
/** Borderless, for a composer box that draws its own frame. */
|
/** Borderless, for a composer box that draws its own frame — and grows
|
||||||
|
* with its text, from `rows` up to 40% of the screen, then scrolls. */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
/** An image upload started (true) or settled (false): the caller holds its
|
/** An image upload started (true) or settled (false): the caller holds its
|
||||||
* send button meanwhile, or the prompt would go out without the image. */
|
* send button meanwhile, or the prompt would go out without the image. */
|
||||||
onBusyChange?: (busy: boolean) => void;
|
onBusyChange?: (busy: boolean) => void;
|
||||||
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
/** The workspace (id, or a pane's run id) whose panes read the files. */
|
||||||
lane?: number | string;
|
lane?: number | string;
|
||||||
|
/** Set to a function that opens a file picker (a composer's + menu). */
|
||||||
|
pickFiles?: MutableRefObject<(() => void) | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An attached file: its path on the server's machine, and a local preview
|
/** An attached file: its path on the server's machine, and a local preview
|
||||||
@@ -177,6 +201,9 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
|
|||||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
|
||||||
|
export const PASTE_AS_FILE_CHARS = 5000;
|
||||||
|
|
||||||
export function PromptEditor({
|
export function PromptEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -190,9 +217,18 @@ export function PromptEditor({
|
|||||||
bare,
|
bare,
|
||||||
onBusyChange,
|
onBusyChange,
|
||||||
lane,
|
lane,
|
||||||
|
pickFiles,
|
||||||
}: PromptEditorProps) {
|
}: PromptEditorProps) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
// A composer grows with its text: reset to its rows, then take what the
|
||||||
|
// text needs; `max-h-[40vh]` caps it and it scrolls from there.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const ta = taRef.current;
|
||||||
|
if (!bare || !ta) return;
|
||||||
|
ta.style.height = "auto";
|
||||||
|
ta.style.height = `${ta.scrollHeight}px`;
|
||||||
|
}, [bare, value]);
|
||||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [files, setFiles] = useState<string[]>([]);
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
@@ -324,7 +360,7 @@ export function PromptEditor({
|
|||||||
const busyRef = useRef(onBusyChange);
|
const busyRef = useRef(onBusyChange);
|
||||||
busyRef.current = onBusyChange;
|
busyRef.current = onBusyChange;
|
||||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||||
const attachImages = (list: FileList | null | undefined) => {
|
const attachImages = (list: FileList | File[] | null | undefined) => {
|
||||||
const picked = Array.from(list ?? []);
|
const picked = Array.from(list ?? []);
|
||||||
if (picked.length === 0) return false;
|
if (picked.length === 0) return false;
|
||||||
setUpload("uploading");
|
setUpload("uploading");
|
||||||
@@ -336,6 +372,7 @@ export function PromptEditor({
|
|||||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
}));
|
}));
|
||||||
|
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||||
onImagesChange((prev) => [...prev, ...added]);
|
onImagesChange((prev) => [...prev, ...added]);
|
||||||
})
|
})
|
||||||
.catch(() => setUpload("failed"));
|
.catch(() => setUpload("failed"));
|
||||||
@@ -345,12 +382,11 @@ export function PromptEditor({
|
|||||||
if (img.url) URL.revokeObjectURL(img.url);
|
if (img.url) URL.revokeObjectURL(img.url);
|
||||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
const closeViewer = useCallback(() => setViewing(null), []);
|
||||||
if (!viewing) return;
|
// The composer's own "Upload or add files" opens this picker; what it picks
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setViewing(null);
|
// takes the same path as a paste or a drop.
|
||||||
document.addEventListener("keydown", onKey);
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
if (pickFiles) pickFiles.current = () => fileInput.current?.click();
|
||||||
}, [viewing]);
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (kind && count > 0) {
|
if (kind && count > 0) {
|
||||||
@@ -454,6 +490,19 @@ export function PromptEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{pickFiles && (
|
||||||
|
<input
|
||||||
|
ref={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
data-testid="prompt-file-input"
|
||||||
|
onChange={(e) => {
|
||||||
|
attachImages(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
ref={taRef}
|
ref={taRef}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -467,8 +516,28 @@ export function PromptEditor({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||||
|
else {
|
||||||
|
// A very long paste goes in as a text file, like Claude Desktop:
|
||||||
|
// the prompt then carries its path, never the text (#68: a CLI's
|
||||||
|
// argument and a tmux message both have a size limit).
|
||||||
|
const text = e.clipboardData?.getData("text/plain") ?? "";
|
||||||
|
if (text.length > PASTE_AS_FILE_CHARS) {
|
||||||
|
e.preventDefault();
|
||||||
|
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
// A Files panel row is not a file: say a drop is welcome.
|
||||||
|
if (e.dataTransfer?.types.includes(ATTACH_MIME)) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
|
const att = droppedAttachment(e.dataTransfer);
|
||||||
|
if (att) {
|
||||||
|
e.preventDefault();
|
||||||
|
onImagesChange((prev) => addAttachments(prev, [att]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -476,7 +545,7 @@ export function PromptEditor({
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className={
|
className={
|
||||||
bare
|
bare
|
||||||
? "block w-full resize-none bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
? "block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-1 py-1 text-sm text-fg-primary placeholder:text-fg-muted focus:outline-none"
|
||||||
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
: "w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -545,20 +614,7 @@ export function PromptEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{viewing && (
|
{viewing?.url && <ImageLightbox src={viewing.url} alt={viewing.name} onClose={closeViewer} />}
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-label={viewing.name}
|
|
||||||
onClick={() => setViewing(null)}
|
|
||||||
className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={viewing.url}
|
|
||||||
alt={viewing.name}
|
|
||||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -673,7 +729,10 @@ export function CwdAutocomplete({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1">
|
<div
|
||||||
|
data-cwd-suggestions
|
||||||
|
className="absolute z-30 left-0 right-0 mt-1 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1"
|
||||||
|
>
|
||||||
{groups.length === 0 ? (
|
{groups.length === 0 ? (
|
||||||
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
|
<div className="px-3 py-2 text-[11px] text-fg-muted">{t("fields.cwdNoMatches")}</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -856,9 +915,11 @@ export function ModelMenu({
|
|||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
// Below the chip unless the viewport has more room above it.
|
// Below the chip unless its pane (or the viewport) has more room above it.
|
||||||
const r = ref.current?.getBoundingClientRect();
|
if (ref.current) {
|
||||||
if (r) setUp(window.innerHeight - r.bottom < 360 && r.top > window.innerHeight - r.bottom);
|
const { above, below } = menuRoom(ref.current, ref.current.getBoundingClientRect());
|
||||||
|
setUp(below < 360 && above > below);
|
||||||
|
}
|
||||||
const away = (e: MouseEvent) => {
|
const away = (e: MouseEvent) => {
|
||||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -236,6 +236,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||||
|
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
||||||
|
fontWeight: 500,
|
||||||
|
fontWeightBold: 700,
|
||||||
theme: paletteRef.current,
|
theme: paletteRef.current,
|
||||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||||
});
|
});
|
||||||
@@ -556,7 +559,10 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
||||||
// `onData` drops input while the socket is connecting or reconnecting,
|
// `onData` drops input while the socket is connecting or reconnecting,
|
||||||
// so a caller waits or says the paste failed instead.
|
// so a caller waits or says the paste failed instead.
|
||||||
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN
|
() => !!containerRef.current?.offsetWidth && wsRef.current?.readyState === WebSocket.OPEN,
|
||||||
|
(data) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(data);
|
||||||
|
}
|
||||||
),
|
),
|
||||||
[runId]
|
[runId]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatAddMenu.test.tsx
|
||||||
|
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||||
|
* search with folders, attached as `@path`), the workspace's Context files
|
||||||
|
* (files only, none missing) and this browser's Recent uploads.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { ChatAddMenu } from "../ChatAddMenu";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import { rememberUploads } from "../../../lib/promptAttach";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||||
|
lanes: {
|
||||||
|
sources: vi.fn().mockResolvedValue({
|
||||||
|
sources: [
|
||||||
|
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||||
|
{ id: 2, kind: "file", path: "/data/ctx/1/gone.md", name: "gone.md", missing: true },
|
||||||
|
{ id: 3, kind: "folder", path: "/srv/docs", name: "docs", missing: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ccConfig: {
|
||||||
|
skills: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
|
plugins: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderMenu(laneId: number | null = 7) {
|
||||||
|
const onAttach = vi.fn();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ChatAddMenu
|
||||||
|
cwd="/repo"
|
||||||
|
laneId={laneId}
|
||||||
|
skills={false}
|
||||||
|
onUpload={() => {}}
|
||||||
|
onInsert={() => {}}
|
||||||
|
onAttach={onAttach}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add"));
|
||||||
|
return onAttach;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatAddMenu — existing resources", () => {
|
||||||
|
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||||
|
const onAttach = renderMenu();
|
||||||
|
fireEvent.mouseEnter(screen.getByTestId("chat-add-project"));
|
||||||
|
fireEvent.click(await screen.findByTestId("chat-add-project-src/"));
|
||||||
|
expect(api.run.files).toHaveBeenCalledWith("/repo", "", true);
|
||||||
|
expect(onAttach).toHaveBeenCalledWith([{ path: "@src/", name: "src/" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the workspace's context files that are still there, files only", async () => {
|
||||||
|
const onAttach = renderMenu();
|
||||||
|
fireEvent.mouseEnter(screen.getByTestId("chat-add-context"));
|
||||||
|
fireEvent.click(await screen.findByTestId("chat-add-context-brief.md"));
|
||||||
|
expect(api.lanes.sources).toHaveBeenCalledWith(7);
|
||||||
|
expect(screen.queryByTestId("chat-add-context-gone.md")).toBeNull();
|
||||||
|
expect(onAttach).toHaveBeenCalledWith([{ path: "@/data/ctx/1/brief.md", name: "brief.md" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no Context files without a workspace", () => {
|
||||||
|
renderMenu(null);
|
||||||
|
expect(screen.queryByTestId("chat-add-context")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists recent uploads newest first and attaches one as it was sent", () => {
|
||||||
|
rememberUploads([{ path: "/tmp/d/old.pdf", name: "old.pdf", image: false }]);
|
||||||
|
rememberUploads([{ path: "/tmp/d/0123456789abcdef.png", name: "shot.png", image: true }]);
|
||||||
|
const onAttach = renderMenu();
|
||||||
|
fireEvent.mouseEnter(screen.getByTestId("chat-add-recent"));
|
||||||
|
const rows = screen.getAllByTestId(/^chat-add-recent-/);
|
||||||
|
expect(rows.map((r) => r.textContent)).toEqual(["shot.png", "old.pdf"]);
|
||||||
|
fireEvent.click(rows[0]!);
|
||||||
|
expect(onAttach).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
path: "/tmp/d/0123456789abcdef.png",
|
||||||
|
name: "shot.png",
|
||||||
|
url: "/api/run/drop/0123456789abcdef.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatFilePreview.test.tsx
|
||||||
|
* @description Where a Chat pane's file preview reads from: a file under the
|
||||||
|
* pane's folder through the lane's git file read, a memory file under
|
||||||
|
* `~/.claude` through the config pages' read, anything else by path only; and
|
||||||
|
* the list dialog previewing the file picked in it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { FileListModal, FilePreview, displayName } from "../ChatFilePreview";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
lanes: {
|
||||||
|
gitFile: vi.fn().mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
path: "src/a.ts",
|
||||||
|
rev: "work",
|
||||||
|
binary: false,
|
||||||
|
truncated: false,
|
||||||
|
lines: ["export const a = 1;"],
|
||||||
|
}),
|
||||||
|
gitRawUrl: () => "",
|
||||||
|
},
|
||||||
|
ccConfig: {
|
||||||
|
file: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: true, file: "", text: "Launch notes", size: 12, mtime: 0 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
||||||
|
|
||||||
|
describe("FilePreview", () => {
|
||||||
|
it("reads a workspace file through the lane", async () => {
|
||||||
|
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||||
|
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||||
|
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a memory file through the config read", async () => {
|
||||||
|
const mem = "/h/.claude/projects/-w/memory/launch.md";
|
||||||
|
render(<FilePreview path={mem} scope={scope} onClose={() => {}} />);
|
||||||
|
expect(await screen.findByText("Launch notes")).toBeTruthy();
|
||||||
|
expect(vi.mocked(api.ccConfig.file)).toHaveBeenCalledWith(mem);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the path of a file it may not read", () => {
|
||||||
|
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
|
||||||
|
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FileListModal", () => {
|
||||||
|
it("previews the file picked in the list", async () => {
|
||||||
|
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
|
||||||
|
expect(displayName(drop)).toBe("minutes.xlsx");
|
||||||
|
render(
|
||||||
|
<FileListModal
|
||||||
|
title="Uploads"
|
||||||
|
paths={[drop, "/w/src/a.ts"]}
|
||||||
|
scope={scope}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||||
|
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatOutline.test.tsx
|
||||||
|
* @description The Chat pane's activity panel renders a session's outline the
|
||||||
|
* way Claude Desktop does: progress with done/current items, outputs by name
|
||||||
|
* with an EXT tag (a click opens that file), and "Used in this session" rows —
|
||||||
|
* uploads, files read and memory open their list, skills link to the config.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ChatOutline } from "../ChatOutline";
|
||||||
|
|
||||||
|
const outline = {
|
||||||
|
progress: [
|
||||||
|
{ id: "1", subject: "Parse plan", status: "completed" },
|
||||||
|
{ id: "2", subject: "Build report", status: "in_progress" },
|
||||||
|
],
|
||||||
|
outputs: ["/w/out/Progress report.xlsx"],
|
||||||
|
files: ["/w/src/a.ts", "/w/src/b.ts", "/w/src/c.ts"],
|
||||||
|
uploads: ["/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx"],
|
||||||
|
memory: ["/h/.claude/projects/-w/memory/release.md"],
|
||||||
|
skills: ["xlsx"],
|
||||||
|
mcp: ["vercel_vercel"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ChatOutline", () => {
|
||||||
|
it("shows progress, outputs and what the session used", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onOpenFile = vi.fn();
|
||||||
|
const onOpenList = vi.fn();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ChatOutline
|
||||||
|
outline={outline}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onOpenList={onOpenList}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Parse plan").className).toContain("line-through");
|
||||||
|
expect(screen.getByText("Build report").className).not.toContain("line-through");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "XLSX: Progress report" }));
|
||||||
|
expect(onOpenFile).toHaveBeenCalledWith("/w/out/Progress report.xlsx");
|
||||||
|
|
||||||
|
// An upload is named without the drop dir's random prefix.
|
||||||
|
fireEvent.click(screen.getByTitle("Uploads: minutes.xlsx"));
|
||||||
|
expect(onOpenList).toHaveBeenCalledWith("uploads");
|
||||||
|
expect(screen.getByText("+1")).toBeTruthy(); // three files read, two named
|
||||||
|
fireEvent.click(screen.getByTitle("Memory: release"));
|
||||||
|
expect(onOpenList).toHaveBeenCalledWith("memory");
|
||||||
|
expect(screen.getByTitle("Skills: xlsx").getAttribute("href")).toBe(
|
||||||
|
"/settings/agents/claude-code?tab=skills"
|
||||||
|
);
|
||||||
|
expect(screen.getByText("vercel_vercel")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Hide side panel" }));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides progress and the used section when there is none", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ChatOutline
|
||||||
|
outline={{
|
||||||
|
...outline,
|
||||||
|
progress: [],
|
||||||
|
files: [],
|
||||||
|
uploads: [],
|
||||||
|
memory: [],
|
||||||
|
skills: [],
|
||||||
|
mcp: [],
|
||||||
|
}}
|
||||||
|
onClose={() => {}}
|
||||||
|
onOpenFile={() => {}}
|
||||||
|
onOpenList={() => {}}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Progress")).toBeNull();
|
||||||
|
expect(screen.queryByText("Used in this session")).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Hide side panel" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,13 +7,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { LaneConsolePane } from "../LaneConsolePane";
|
import { LaneConsolePane } from "../LaneConsolePane";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import type { Lane } from "../../../lib/types";
|
import type { Lane } from "../../../lib/types";
|
||||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||||
import { __resetChatUi } from "../../../lib/chatUi";
|
import { VIEWS_KEY, __resetChatUi, saveChatUi, setSessionChat } from "../../../lib/chatUi";
|
||||||
|
|
||||||
vi.mock("../TerminalView", () => ({
|
vi.mock("../TerminalView", () => ({
|
||||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||||
@@ -33,7 +33,10 @@ vi.mock("../PaneChat", () => ({
|
|||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
me: {
|
||||||
|
prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }),
|
||||||
|
savePrefs: vi.fn(),
|
||||||
|
},
|
||||||
lanes: {
|
lanes: {
|
||||||
ensure: vi.fn(),
|
ensure: vi.fn(),
|
||||||
action: vi.fn(),
|
action: vi.fn(),
|
||||||
@@ -171,7 +174,41 @@ describe("LaneConsolePane", () => {
|
|||||||
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||||
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
||||||
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
|
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
|
__resetChatUi();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each session's own view: the Chat UI setting is only a new session's default", async () => {
|
||||||
|
vi.mocked(api.me.prefs).mockResolvedValueOnce({
|
||||||
|
prefs: { "agenthub.chatUi": "1" },
|
||||||
|
onboardedAt: null,
|
||||||
|
});
|
||||||
|
__resetChatUi();
|
||||||
|
const live = {
|
||||||
|
id: "agenthub-lane-1",
|
||||||
|
laneId: 1,
|
||||||
|
status: "running" as const,
|
||||||
|
cwd: "/workspace/a",
|
||||||
|
model: null,
|
||||||
|
permissionMode: null,
|
||||||
|
effort: null,
|
||||||
|
resumeSessionId: null,
|
||||||
|
sessionId: "s-1",
|
||||||
|
startedAt: null,
|
||||||
|
promptPreview: null,
|
||||||
|
tool: "claude" as const,
|
||||||
|
};
|
||||||
|
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||||
|
expect(await screen.findByTestId("pane-chat")).toBeInTheDocument();
|
||||||
|
// Seen once, the session keeps chat: turning the default off leaves it alone.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}")).toEqual({ "s-1": true })
|
||||||
|
);
|
||||||
|
vi.mocked(api.me.savePrefs).mockResolvedValueOnce({ prefs: {}, onboardedAt: null });
|
||||||
|
await act(() => saveChatUi(false));
|
||||||
|
expect(screen.getByTestId("pane-chat")).toBeInTheDocument();
|
||||||
|
// Its own toggle switches it.
|
||||||
|
act(() => setSessionChat("s-1", false));
|
||||||
|
expect(screen.queryByTestId("pane-chat")).toBeNull();
|
||||||
__resetChatUi();
|
__resetChatUi();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* @file PaneChat.test.tsx
|
||||||
|
* @description The Chat view's controls: the `+` menu (Upload, Skills, Plugins
|
||||||
|
* → a plugin's skills, each inserted as `/<name>`), the Model/Effort chip typed
|
||||||
|
* into the pane as `/model` / `/effort`, and the permission mode read off the
|
||||||
|
* pane and stepped by the server — none of them for a Codex pane; and the
|
||||||
|
* activity panel, collapsed into a file button with the output count, whose
|
||||||
|
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||||
|
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||||
|
* Stop in Send's place while the main agent works, pressing Esc in the pane.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PaneChat } from "../PaneChat";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import { interruptRun, submitToRun } from "../../../lib/panePaste";
|
||||||
|
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||||
|
|
||||||
|
vi.mock("../../conversation/ConversationView", () => ({
|
||||||
|
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||||
|
ConversationView: ({ onOpenFile }: { onOpenFile?: (path: string) => void }) => (
|
||||||
|
<div data-testid="conversation">
|
||||||
|
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
|
||||||
|
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/panePaste", () => ({
|
||||||
|
submitToRun: vi.fn(() => true),
|
||||||
|
interruptRun: vi.fn(() => true),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
agents: { list: r({ agents: [] }) },
|
||||||
|
aiTools: {
|
||||||
|
models: r({
|
||||||
|
flag: true,
|
||||||
|
models: [
|
||||||
|
{ id: "opus", label: "Opus" },
|
||||||
|
{ id: "sonnet", label: "Sonnet" },
|
||||||
|
],
|
||||||
|
defaultModel: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
run: {
|
||||||
|
files: r({ items: [] }),
|
||||||
|
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||||
|
permissionMode: r({ mode: "acceptEdits" }),
|
||||||
|
setPermissionMode: r({ mode: "plan" }),
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
outline: r({
|
||||||
|
progress: [],
|
||||||
|
outputs: ["/w/notes.md", "/w/report.xlsx"],
|
||||||
|
files: [],
|
||||||
|
uploads: [],
|
||||||
|
memory: [],
|
||||||
|
skills: [],
|
||||||
|
mcp: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
lanes: {
|
||||||
|
gitFile: r({
|
||||||
|
available: true,
|
||||||
|
path: "notes.md",
|
||||||
|
rev: "work",
|
||||||
|
binary: false,
|
||||||
|
truncated: false,
|
||||||
|
lines: ["# Notes", "", "hello"],
|
||||||
|
}),
|
||||||
|
gitRawUrl: () => "",
|
||||||
|
},
|
||||||
|
ccConfig: {
|
||||||
|
commands: r({ items: [] }),
|
||||||
|
skills: r({
|
||||||
|
items: [{ scope: "user", name: "morning", frontmatter: { description: "Plan the day" } }],
|
||||||
|
}),
|
||||||
|
plugins: r({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "doox",
|
||||||
|
enabled: true,
|
||||||
|
contributes: {
|
||||||
|
items: {
|
||||||
|
skills: [{ name: "plan-consolidation", description: "Merge plans" }],
|
||||||
|
commands: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "off",
|
||||||
|
enabled: false,
|
||||||
|
contributes: { items: { skills: [{ name: "x" }], commands: [] } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const RUN = {
|
||||||
|
id: "agenthub-lane-1",
|
||||||
|
tool: "claude" as "claude" | "codex",
|
||||||
|
model: "opus",
|
||||||
|
effort: "high" as const,
|
||||||
|
permissionMode: "auto" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderChat(run: Partial<typeof RUN> = {}) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaneChat run={{ ...RUN, ...run }} sessionId="s1" cwd="/w" />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.mocked(submitToRun).mockClear());
|
||||||
|
|
||||||
|
describe("PaneChat — the + menu", () => {
|
||||||
|
it("puts a skill, or a plugin's skill, at the front of the message", async () => {
|
||||||
|
renderChat();
|
||||||
|
const box = screen.getByPlaceholderText("Write a message…") as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(box, { target: { value: "today" } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add"));
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add-skills"));
|
||||||
|
fireEvent.click(await screen.findByTestId("chat-add-skill-morning"));
|
||||||
|
expect(box.value).toBe("/morning today");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add"));
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add-plugins"));
|
||||||
|
expect(screen.queryByTestId("chat-add-plugin-off")).toBeNull(); // disabled plugin
|
||||||
|
fireEvent.click(await screen.findByTestId("chat-add-plugin-doox"));
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add-skill-doox:plan-consolidation"));
|
||||||
|
expect(box.value).toBe("/doox:plan-consolidation /morning today");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Upload opens the prompt box's file picker", () => {
|
||||||
|
renderChat();
|
||||||
|
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
||||||
|
const clicked = vi.spyOn(input, "click");
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add"));
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add-upload"));
|
||||||
|
expect(clicked).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PaneChat — Stop", () => {
|
||||||
|
it("replaces Send while the main agent works and the box is empty, and presses Esc", async () => {
|
||||||
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||||
|
agents: [{ type: "main", status: "working" }],
|
||||||
|
} as never);
|
||||||
|
renderChat();
|
||||||
|
fireEvent.click(await screen.findByTestId("pane-chat-stop"));
|
||||||
|
expect(interruptRun).toHaveBeenCalledWith("agenthub-lane-1");
|
||||||
|
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||||
|
expect(screen.getByTestId("pane-chat-send")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops Stop once the turn ended even when no live update says so", async () => {
|
||||||
|
// A missed agent_updated (another dashboard on the same database got the
|
||||||
|
// hook, or the socket dropped): the poll while working catches it.
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||||
|
agents: [{ type: "main", status: "working" }],
|
||||||
|
} as never);
|
||||||
|
renderChat();
|
||||||
|
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Send for a session at rest", async () => {
|
||||||
|
// With session_id the server answers EVERY agent of the session (its
|
||||||
|
// status filter does not apply): the main agent is there, waiting.
|
||||||
|
vi.mocked(api.agents.list).mockResolvedValueOnce({
|
||||||
|
agents: [{ type: "main", status: "waiting" }],
|
||||||
|
} as never);
|
||||||
|
renderChat();
|
||||||
|
await waitFor(() => expect(api.agents.list).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PaneChat — model, effort and permission mode", () => {
|
||||||
|
it("types /model into the pane", async () => {
|
||||||
|
renderChat();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
||||||
|
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
|
||||||
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the mode the pane shows, and asks the server to step it", async () => {
|
||||||
|
renderChat();
|
||||||
|
const chip = byId("pane-chat-mode");
|
||||||
|
await waitFor(() => expect(chip()).toHaveTextContent("Accept edits"));
|
||||||
|
fireEvent.click(chip().querySelector("button")!);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: /Plan/ }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.run.setPermissionMode).toHaveBeenCalledWith("agenthub-lane-1", "plan")
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(chip()).toHaveTextContent("Plan"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a Codex pane gets the + menu's Upload only — no skills, model or mode", () => {
|
||||||
|
renderChat({ tool: "codex" });
|
||||||
|
expect(screen.queryByTestId("pane-chat-mode")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /Opus/ })).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add"));
|
||||||
|
expect(screen.getByTestId("chat-add-upload")).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("chat-add-skills")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const byId = (id: string) => () => screen.getByTestId(id);
|
||||||
|
|
||||||
|
describe("PaneChat — the activity panel", () => {
|
||||||
|
it("opens from the file button, previews a file, and closes back to the panel", async () => {
|
||||||
|
localStorage.removeItem("agenthub.chat.outlineOpen");
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
|
const show = screen.getByTestId("chat-outline-show");
|
||||||
|
await waitFor(() => expect(show.textContent).toBe("2"));
|
||||||
|
|
||||||
|
fireEvent.click(show);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "MD: notes" }));
|
||||||
|
expect(screen.queryByTestId("chat-outline")).toBeNull();
|
||||||
|
await screen.findByTestId("markdown-preview");
|
||||||
|
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(7, "notes.md");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(screen.getByTestId("chat-outline")).toBeTruthy();
|
||||||
|
expect(localStorage.getItem("agenthub.chat.outlineOpen")).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PaneChat — an edited file", () => {
|
||||||
|
it("opens in the Changes panel when it is in the pane's folder, else in its own preview", async () => {
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const listen = (e: Event) => seen.push((e as CustomEvent).detail);
|
||||||
|
window.addEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<PaneChat run={RUN} sessionId="s1" cwd="/w" laneId={7} laneCwd="/w" outline />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId("edited-in"));
|
||||||
|
expect(seen).toEqual([{ laneId: 7, path: "src/a.ts" }]);
|
||||||
|
expect(screen.queryByTestId("chat-file-preview")).toBeNull();
|
||||||
|
// Outside the folder the Changes panel cannot show it: the chat's preview does.
|
||||||
|
fireEvent.click(screen.getByTestId("edited-out"));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(await screen.findByTestId("chat-file-preview")).toBeInTheDocument();
|
||||||
|
window.removeEventListener(OPEN_IN_CHANGES_EVENT, listen);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
* @description The run-form pieces that outlived the pane's run form:
|
* @description The run-form pieces that outlived the pane's run form:
|
||||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
* pasted image shown as a thumbnail and sent as its saved path (and
|
||||||
|
* remembered as a recent upload), a Files panel row dropped as an `@path`
|
||||||
|
* chip, a composer
|
||||||
|
* (`bare`) box growing with its text up to a cap, and `ModelPicker` — a tool's own
|
||||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||||
* same list as one chip: aliases up front, pinned versions under More models,
|
* same list as one chip: aliases up front, pinned versions under More models,
|
||||||
@@ -13,17 +16,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import {
|
import {
|
||||||
ModelMenu,
|
ModelMenu,
|
||||||
ModelPicker,
|
ModelPicker,
|
||||||
|
PASTE_AS_FILE_CHARS,
|
||||||
PromptEditor,
|
PromptEditor,
|
||||||
promptWithImages,
|
promptWithImages,
|
||||||
type PromptImage,
|
type PromptImage,
|
||||||
} from "../RunSetup";
|
} from "../RunSetup";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
import { dragProjectPath, recentUploads } from "../../../lib/promptAttach";
|
||||||
|
|
||||||
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>>();
|
||||||
@@ -158,6 +163,34 @@ describe("PromptEditor — / and @ suggestions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PromptEditor — a composer grows with its text", () => {
|
||||||
|
it("takes the height its text needs, capped by max-h, and only when bare", () => {
|
||||||
|
const tall = vi
|
||||||
|
.spyOn(HTMLTextAreaElement.prototype, "scrollHeight", "get")
|
||||||
|
.mockReturnValue(180);
|
||||||
|
const props = {
|
||||||
|
placeholder: "Ask anything…",
|
||||||
|
slash: false,
|
||||||
|
fileCwd: "/Users/tester",
|
||||||
|
images: [],
|
||||||
|
onImagesChange: () => {},
|
||||||
|
onChange: () => {},
|
||||||
|
onSubmit: () => {},
|
||||||
|
};
|
||||||
|
const view = render(<PromptEditor {...props} value={"a\nb\nc\nd\ne"} bare />);
|
||||||
|
expect(box().style.height).toBe("180px");
|
||||||
|
expect(box().className).toContain("max-h-[40vh]");
|
||||||
|
tall.mockReturnValue(60);
|
||||||
|
view.rerender(<PromptEditor {...props} value="a" bare />);
|
||||||
|
expect(box().style.height).toBe("60px");
|
||||||
|
view.unmount();
|
||||||
|
// The framed editor keeps its fixed rows.
|
||||||
|
render(<PromptEditor {...props} value={"a\nb\nc"} />);
|
||||||
|
expect(box().style.height).toBe("");
|
||||||
|
tall.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("PromptEditor — newlines and images", () => {
|
describe("PromptEditor — newlines and images", () => {
|
||||||
it("Alt+Enter inserts a newline and does not send", () => {
|
it("Alt+Enter inserts a newline and does not send", () => {
|
||||||
const spies = renderEditor("ab");
|
const spies = renderEditor("ab");
|
||||||
@@ -202,7 +235,8 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||||
|
|
||||||
fireEvent.click(thumb);
|
fireEvent.click(thumb);
|
||||||
expect(screen.getByRole("dialog", { name: "shot.png" })).toBeTruthy();
|
// On the page, not in the composer's layer: nothing stacked over a pane covers it.
|
||||||
|
expect(screen.getByRole("dialog", { name: "shot.png" }).parentElement).toBe(document.body);
|
||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
@@ -212,6 +246,38 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a composer's Upload opens a picker whose files attach like a paste", async () => {
|
||||||
|
const pick: { current: (() => void) | null } = { current: null };
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
pickFiles={pick}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const input = screen.getByTestId("prompt-file-input") as HTMLInputElement;
|
||||||
|
const clicked = vi.spyOn(input, "click");
|
||||||
|
pick.current?.();
|
||||||
|
expect(clicked).toHaveBeenCalled();
|
||||||
|
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||||
|
fireEvent.change(input, { target: { files: [doc] } });
|
||||||
|
expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined); // no workspace named
|
||||||
|
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||||
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||||
function Harness() {
|
function Harness() {
|
||||||
@@ -237,13 +303,70 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||||
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||||
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||||
|
// Remembered for the + menu's Recent uploads.
|
||||||
|
expect(recentUploads()).toContainEqual({
|
||||||
|
path: "/tmp/agenthub-drops-1/b-spec.pdf",
|
||||||
|
name: "spec.pdf",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptEditor
|
||||||
|
value="read"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
const dataTransfer = {
|
||||||
|
setData: (k: string, v: string) => (data[k] = v),
|
||||||
|
getData: (k: string) => data[k] ?? "",
|
||||||
|
get types() {
|
||||||
|
return Object.keys(data);
|
||||||
|
},
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
// What a Files panel row's onDragStart puts on the drag.
|
||||||
|
dragProjectPath({ dataTransfer } as unknown as React.DragEvent, "src/lib/");
|
||||||
|
fireEvent.drop(box(), { dataTransfer });
|
||||||
|
fireEvent.drop(box(), { dataTransfer });
|
||||||
|
expect(screen.getByText("lib/")).toBeTruthy();
|
||||||
|
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("read @src/lib/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a text paste to the textarea", () => {
|
it("leaves a text paste to the textarea", () => {
|
||||||
renderEditor("");
|
renderEditor("");
|
||||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
fireEvent.paste(box(), {
|
||||||
|
clipboardData: { files: [], getData: () => "a short note" },
|
||||||
|
});
|
||||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a very long text paste as a text file, like a pasted file (#68)", async () => {
|
||||||
|
renderEditor("");
|
||||||
|
const long = "x".repeat(PASTE_AS_FILE_CHARS + 1);
|
||||||
|
fireEvent.paste(box(), { clipboardData: { files: [], getData: () => long } });
|
||||||
|
await waitFor(() => expect(api.run.dropFile).toHaveBeenCalledTimes(1));
|
||||||
|
const file = vi.mocked(api.run.dropFile).mock.calls[0]![0] as File;
|
||||||
|
expect(file.name).toBe("pasted-text.txt");
|
||||||
|
expect(file.type).toBe("text/plain");
|
||||||
|
expect(file.size).toBe(long.length); // the whole paste, nothing cut
|
||||||
|
expect(box()).toHaveValue(""); // not typed into the box
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ModelPicker", () => {
|
describe("ModelPicker", () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.tsx
|
* @file AppearanceSettings.tsx
|
||||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||||
|
* Chat UI default (`ChatUiSettings`, `#chat-ui`), the interface language, the
|
||||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||||
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
|
|
||||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||||||
@@ -20,6 +20,7 @@ import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
|||||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||||
import { PetSettings } from "./PetSettings";
|
import { PetSettings } from "./PetSettings";
|
||||||
import { ChatUiSettings } from "./ChatUiSettings";
|
import { ChatUiSettings } from "./ChatUiSettings";
|
||||||
|
import { Select } from "../Select";
|
||||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { resetOnboarding } from "../../lib/userPrefs";
|
import { resetOnboarding } from "../../lib/userPrefs";
|
||||||
@@ -98,6 +99,8 @@ export function AppearanceSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<ChatUiSettings />
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
id="language"
|
id="language"
|
||||||
title={t("language.title")}
|
title={t("language.title")}
|
||||||
@@ -128,8 +131,9 @@ export function AppearanceSettings() {
|
|||||||
label={t("terminalFont.label")}
|
label={t("terminalFont.label")}
|
||||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||||
control={
|
control={
|
||||||
<SettingsSegmented
|
// A dropdown: five font names overflow a segmented row.
|
||||||
ariaLabel={t("terminalFont.title")}
|
<Select<TerminalFont>
|
||||||
|
label={t("terminalFont.title")}
|
||||||
value={termFont}
|
value={termFont}
|
||||||
onChange={pickTermFont}
|
onChange={pickTermFont}
|
||||||
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||||
@@ -150,8 +154,6 @@ export function AppearanceSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<ChatUiSettings />
|
|
||||||
|
|
||||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
|
|||||||
@@ -88,16 +88,17 @@ describe("AppearanceSettings", () => {
|
|||||||
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AppearanceSettings />);
|
render(<AppearanceSettings />);
|
||||||
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
|
const trigger = screen.getByRole("button", { name: /Terminal font/ });
|
||||||
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
|
expect(trigger).toHaveTextContent("JetBrains Mono");
|
||||||
"aria-checked",
|
const pick = async (name: string) => {
|
||||||
"true"
|
await user.click(screen.getByRole("button", { name: /Terminal font/ }));
|
||||||
);
|
await user.click(screen.getByRole("option", { name }));
|
||||||
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
|
};
|
||||||
|
await pick("Maple Mono (rounded)");
|
||||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
||||||
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
||||||
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
|
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
|
||||||
await user.click(within(group).getByRole("radio", { name: "System font" }));
|
await pick("System font");
|
||||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* frames missed while disconnected are never replayed. The copy is dropped
|
* frames missed while disconnected are never replayed. The copy is dropped
|
||||||
* when the last component unmounts, so a fresh mount always reads fresh rows.
|
* when the last component unmounts, so a fresh mount always reads fresh rows.
|
||||||
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
|
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
|
||||||
* work in (server mode: an analyst's own), for the Workspace page and tree.
|
* work in (server mode: an analyst's own), for the Workspace page; the sidebar
|
||||||
|
* tree lists them all, other users' read only.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -97,10 +98,10 @@ export function useLanes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The workspaces this user WORKS in — the Workspace page and its sidebar tree.
|
* The workspaces this user WORKS in — the Workspace page.
|
||||||
* Server mode: an analyst gets every workspace from the API (to read) but may
|
* Server mode: an analyst gets every workspace from the API (to read) but may
|
||||||
* change only their own, so only those are listed here; other users' work is
|
* change only their own, so only those are listed here; other users' work is
|
||||||
* read on the Sessions, Analytics and Users pages.
|
* read in the sidebar tree (read only), and on Sessions, Analytics and Users.
|
||||||
*/
|
*/
|
||||||
export function useWorkLanes() {
|
export function useWorkLanes() {
|
||||||
const all = useLanes();
|
const all = useLanes();
|
||||||
|
|||||||
@@ -7,11 +7,15 @@
|
|||||||
* themselves are CSS-variable blocks in `index.css`. State is never cached in
|
* themselves are CSS-variable blocks in `index.css`. State is never cached in
|
||||||
* the module — every instance reads storage + `matchMedia` through
|
* the module — every instance reads storage + `matchMedia` through
|
||||||
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
|
* `useSyncExternalStore`, so the Sidebar and Settings can never disagree, and
|
||||||
* another tab's change arrives through the `storage` event.
|
* another tab's change arrives through the `storage` event. Every change is also
|
||||||
|
* saved to the account (`/api/me/prefs`, same keys); a sign-in loads it back
|
||||||
|
* through `lib/userPrefs.ts` (`hydrate`), while the login page wears the OS's
|
||||||
|
* light/dark (`applySystemTheme`) because nobody has chosen yet.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
import { useCallback, useLayoutEffect, useSyncExternalStore } from "react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
export type Theme = "dark" | "light";
|
export type Theme = "dark" | "light";
|
||||||
export type ThemeMode = Theme | "system";
|
export type ThemeMode = Theme | "system";
|
||||||
@@ -19,7 +23,7 @@ export type ThemeMode = Theme | "system";
|
|||||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||||
export const THEMES = {
|
export const THEMES = {
|
||||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||||
light: ["default", "apple-light", "canvas", "orca-light"],
|
light: ["default", "canvas", "apple-light", "orca-light"],
|
||||||
} as const satisfies Record<Theme, readonly string[]>;
|
} as const satisfies Record<Theme, readonly string[]>;
|
||||||
|
|
||||||
const MODE_KEY = "theme";
|
const MODE_KEY = "theme";
|
||||||
@@ -35,13 +39,20 @@ function read(key: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(key: string, value: string): void {
|
function store(key: string, value: string): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore quota / disabled storage */
|
/* ignore quota / disabled storage */
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A user's own choice: this browser, every reader, and the account. */
|
||||||
|
function write(key: string, value: string): void {
|
||||||
|
store(key, value);
|
||||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||||
|
// This browser keeps it even when the save fails (offline, signed out).
|
||||||
|
api.me.savePrefs({ [key]: value }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function systemQuery(): MediaQueryList | undefined {
|
function systemQuery(): MediaQueryList | undefined {
|
||||||
@@ -86,6 +97,17 @@ function apply(resolved: Theme, name: string): void {
|
|||||||
else root.dataset.theme = name;
|
else root.dataset.theme = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paint what this browser stores (index.html's job at page load). */
|
||||||
|
export function applyStoredTheme(): void {
|
||||||
|
const [, resolved, dark, light] = getSnapshot().split("|") as [ThemeMode, Theme, string, string];
|
||||||
|
apply(resolved, resolved === "dark" ? dark : light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The login page: the OS's light/dark in the stock palette. */
|
||||||
|
export function applySystemTheme(): void {
|
||||||
|
apply(systemQuery()?.matches === false ? "light" : "dark", "default");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read/write the dashboard's appearance.
|
* Read/write the dashboard's appearance.
|
||||||
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
|
* @returns `theme` — the RESOLVED mode (`system` already answered); `mode` — what
|
||||||
|
|||||||
@@ -186,14 +186,18 @@
|
|||||||
"keep": "Keep editing"
|
"keep": "Keep editing"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Sign in to AgentHub",
|
"title": "Welcome back",
|
||||||
|
"subtitle": "Sign in to AgentHub with your account",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"showPassword": "Show password",
|
||||||
|
"hidePassword": "Hide password",
|
||||||
"submit": "Sign in",
|
"submit": "Sign in",
|
||||||
"submitting": "Signing in…",
|
"submitting": "Signing in…",
|
||||||
"failed": "Wrong username or password",
|
"failed": "Wrong username or password",
|
||||||
"tooMany": "Too many failed attempts — try again in a few minutes",
|
"tooMany": "Too many failed attempts — try again in a few minutes",
|
||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
|
"noAccount": "No account yet? Ask your administrator to create one.",
|
||||||
"signedInAs": "Signed in as {{name}} ({{role}})"
|
"signedInAs": "Signed in as {{name}} ({{role}})"
|
||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
|
|||||||
@@ -91,10 +91,8 @@
|
|||||||
"changes.range.work": "Uncommitted",
|
"changes.range.work": "Uncommitted",
|
||||||
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
||||||
"changes.refresh": "Refresh",
|
"changes.refresh": "Refresh",
|
||||||
"changes.resize": "Resize the changes panel (next: {{size}})",
|
|
||||||
"changes.size.compact": "Narrow",
|
|
||||||
"changes.size.half": "Medium",
|
|
||||||
"changes.size.full": "Full screen",
|
"changes.size.full": "Full screen",
|
||||||
|
"changes.restore": "Restore size",
|
||||||
"changes.fileCount_one": "{{count}} file",
|
"changes.fileCount_one": "{{count}} file",
|
||||||
"changes.fileCount_other": "{{count}} files",
|
"changes.fileCount_other": "{{count}} files",
|
||||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||||
@@ -102,6 +100,7 @@
|
|||||||
"changes.title": "Changes",
|
"changes.title": "Changes",
|
||||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||||
"changes.unavailable": "This workspace's directory is not a readable git repository.",
|
"changes.unavailable": "This workspace's directory is not a readable git repository.",
|
||||||
|
"changes.unavailableFolder": "This folder is not a readable git repository.",
|
||||||
"changes.unstage": "Unstage this file",
|
"changes.unstage": "Unstage this file",
|
||||||
"changes.changesView": "Changes",
|
"changes.changesView": "Changes",
|
||||||
"changes.wholeFile": "Whole file",
|
"changes.wholeFile": "Whole file",
|
||||||
@@ -134,8 +133,6 @@
|
|||||||
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
|
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
|
||||||
"destructive.reset.title": "Reset managed worktree?",
|
"destructive.reset.title": "Reset managed worktree?",
|
||||||
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
||||||
"empty.body": "A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:",
|
|
||||||
"empty.title": "No workspaces yet",
|
|
||||||
"features.archived": "archived",
|
"features.archived": "archived",
|
||||||
"features.live": "Live",
|
"features.live": "Live",
|
||||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the workspace keeps running; this is a read-only snapshot.",
|
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the workspace keeps running; this is a read-only snapshot.",
|
||||||
@@ -298,6 +295,7 @@
|
|||||||
"menu.remove": "Delete workspace…",
|
"menu.remove": "Delete workspace…",
|
||||||
"panel.changes": "Changes",
|
"panel.changes": "Changes",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
|
"panel.folder": "Folder to show changes for",
|
||||||
"replay.placeholder": "Message to continue this session…",
|
"replay.placeholder": "Message to continue this session…",
|
||||||
"replay.hint": "Sending resumes the session in a free pane.",
|
"replay.hint": "Sending resumes the session in a free pane.",
|
||||||
"replay.send": "Send",
|
"replay.send": "Send",
|
||||||
@@ -316,6 +314,10 @@
|
|||||||
"tree.search": "Search workspaces and sessions",
|
"tree.search": "Search workspaces and sessions",
|
||||||
"tree.toolFilter": "Filter by tool",
|
"tree.toolFilter": "Filter by tool",
|
||||||
"tree.allTools": "All tools",
|
"tree.allTools": "All tools",
|
||||||
|
"tree.ownerYou": "{{name}} (you)",
|
||||||
|
"tree.noOwner": "No owner",
|
||||||
|
"tree.ownerToggle": "Show or hide {{name}}'s workspaces",
|
||||||
|
"tree.readOnly": "{{name}}'s workspace — read only. A session opens its analysis page.",
|
||||||
"tree.newSession": "New session",
|
"tree.newSession": "New session",
|
||||||
"tree.noSessions": "No sessions yet",
|
"tree.noSessions": "No sessions yet",
|
||||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||||
@@ -389,6 +391,8 @@
|
|||||||
"panes.split.bottom": "Split down",
|
"panes.split.bottom": "Split down",
|
||||||
"panes.full": "A tab holds at most 4 panes",
|
"panes.full": "A tab holds at most 4 panes",
|
||||||
"panes.newTerminal": "New terminal",
|
"panes.newTerminal": "New terminal",
|
||||||
|
"panes.showChat": "Show as chat",
|
||||||
|
"panes.showTerminal": "Show the terminal",
|
||||||
"panes.close": "Move to its own tab (the session keeps running)",
|
"panes.close": "Move to its own tab (the session keeps running)",
|
||||||
"panes.resize": "Resize panes",
|
"panes.resize": "Resize panes",
|
||||||
"tabs.home": "Workspace home",
|
"tabs.home": "Workspace home",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
"goWorkspaces": "Go to Workspaces",
|
"goWorkspaces": "Go to Workspaces",
|
||||||
"add": {
|
"add": {
|
||||||
"title": "Create your first workspace",
|
"title": "Create your first workspace",
|
||||||
"body": "A workspace is one project folder where your agents work. Click here to add one."
|
"body": "A workspace is one project folder where your agents work. Click + beside Workspaces in the sidebar to add one."
|
||||||
},
|
},
|
||||||
"folder": {
|
"folder": {
|
||||||
"title": "Choose the project folder",
|
"title": "Choose the project folder",
|
||||||
|
|||||||
@@ -85,7 +85,38 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Write a message…",
|
"placeholder": "Write a message…",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"hint": "Enter sends · permission prompts and menus need the terminal (Settings → Appearance → Chat UI)",
|
"notSent": "The terminal is not connected — the message was not sent.",
|
||||||
"notSent": "The terminal is not connected — the message was not sent."
|
"stop": "Stop",
|
||||||
|
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||||
|
"add": "Add files, skills…",
|
||||||
|
"upload": "Upload or add files",
|
||||||
|
"projectFiles": "Project files",
|
||||||
|
"contextFiles": "Context files",
|
||||||
|
"recentUploads": "Recent uploads",
|
||||||
|
"searchFiles": "Search files and folders…",
|
||||||
|
"skills": "Skills",
|
||||||
|
"plugins": "Plugins",
|
||||||
|
"manageSkills": "Manage skills",
|
||||||
|
"managePlugins": "Manage plugins",
|
||||||
|
"none": "Nothing here yet",
|
||||||
|
"fromPlugin": "{{name}} plugin",
|
||||||
|
"modeFailed": "Could not change the permission mode: {{message}}",
|
||||||
|
"outline": {
|
||||||
|
"progress": "Progress",
|
||||||
|
"outputs": "Outputs",
|
||||||
|
"connectors": "Connectors",
|
||||||
|
"empty": "Nothing yet",
|
||||||
|
"hide": "Hide side panel",
|
||||||
|
"show": "Activity panel",
|
||||||
|
"copyPath": "Copy path: {{path}}",
|
||||||
|
"panel": "Session activity panel",
|
||||||
|
"used": "Used in this session",
|
||||||
|
"uploads": "Uploads",
|
||||||
|
"files": "Files read",
|
||||||
|
"memory": "Memory",
|
||||||
|
"close": "Close",
|
||||||
|
"noPreview": "No preview for this file here",
|
||||||
|
"truncated": "Showing the first part of this file"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,10 @@
|
|||||||
"agents_other": "ran {{count}} agents",
|
"agents_other": "ran {{count}} agents",
|
||||||
"tools_one": "used a tool",
|
"tools_one": "used a tool",
|
||||||
"tools_other": "used {{count}} tools"
|
"tools_other": "used {{count}} tools"
|
||||||
}
|
},
|
||||||
|
"edited_one": "Edited {{count}} file",
|
||||||
|
"edited_other": "Edited {{count}} files",
|
||||||
|
"working": "Thinking…"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -1022,9 +1022,9 @@
|
|||||||
},
|
},
|
||||||
"chatUi": {
|
"chatUi": {
|
||||||
"title": "Chat UI",
|
"title": "Chat UI",
|
||||||
"description": "How a Claude or Codex session opens in its workspace. Saved for your account only.",
|
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||||
"label": "Open sessions as a chat",
|
"label": "Open new sessions as a chat",
|
||||||
"labelDesc": "Show every Claude or Codex session's conversation like Claude Desktop, with a message box, instead of its terminal. Permission prompts and menus still need the terminal: turn this off to answer them.",
|
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
||||||
"saveFailed": "Could not save: {{message}}"
|
"saveFailed": "Could not save: {{message}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,5 +69,7 @@
|
|||||||
"readOnly": "Analysts can view accounts but not change them.",
|
"readOnly": "Analysts can view accounts but not change them.",
|
||||||
"auditTitle": "Audit log",
|
"auditTitle": "Audit log",
|
||||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||||
"auditEmpty": "Nothing recorded yet."
|
"auditEmpty": "Nothing recorded yet.",
|
||||||
|
"activeCount": "{{n}} active",
|
||||||
|
"cost30d": "Cost · 30 days"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,14 +186,18 @@
|
|||||||
"keep": "Tiếp tục sửa"
|
"keep": "Tiếp tục sửa"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"title": "Đăng nhập AgentHub",
|
"title": "Chào mừng trở lại",
|
||||||
|
"subtitle": "Đăng nhập AgentHub bằng tài khoản của bạn",
|
||||||
"username": "Tên đăng nhập",
|
"username": "Tên đăng nhập",
|
||||||
"password": "Mật khẩu",
|
"password": "Mật khẩu",
|
||||||
|
"showPassword": "Hiện mật khẩu",
|
||||||
|
"hidePassword": "Ẩn mật khẩu",
|
||||||
"submit": "Đăng nhập",
|
"submit": "Đăng nhập",
|
||||||
"submitting": "Đang đăng nhập…",
|
"submitting": "Đang đăng nhập…",
|
||||||
"failed": "Sai tên đăng nhập hoặc mật khẩu",
|
"failed": "Sai tên đăng nhập hoặc mật khẩu",
|
||||||
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
|
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
|
||||||
"logout": "Đăng xuất",
|
"logout": "Đăng xuất",
|
||||||
|
"noAccount": "Chưa có tài khoản? Hãy nhờ quản trị viên tạo cho bạn.",
|
||||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
|
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
|
||||||
},
|
},
|
||||||
"insights": {
|
"insights": {
|
||||||
|
|||||||
@@ -91,10 +91,8 @@
|
|||||||
"changes.range.work": "Chưa commit",
|
"changes.range.work": "Chưa commit",
|
||||||
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
||||||
"changes.refresh": "Tải lại",
|
"changes.refresh": "Tải lại",
|
||||||
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
|
|
||||||
"changes.size.compact": "Hẹp",
|
|
||||||
"changes.size.half": "Vừa",
|
|
||||||
"changes.size.full": "Toàn màn hình",
|
"changes.size.full": "Toàn màn hình",
|
||||||
|
"changes.restore": "Thu nhỏ",
|
||||||
"changes.fileCount_one": "{{count}} tệp",
|
"changes.fileCount_one": "{{count}} tệp",
|
||||||
"changes.fileCount_other": "{{count}} tệp",
|
"changes.fileCount_other": "{{count}} tệp",
|
||||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||||
@@ -102,6 +100,7 @@
|
|||||||
"changes.title": "Thay đổi",
|
"changes.title": "Thay đổi",
|
||||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||||
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
|
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
|
||||||
|
"changes.unavailableFolder": "Thư mục này không phải repo git đọc được.",
|
||||||
"changes.unstage": "Bỏ file này khỏi index",
|
"changes.unstage": "Bỏ file này khỏi index",
|
||||||
"changes.changesView": "Thay đổi",
|
"changes.changesView": "Thay đổi",
|
||||||
"changes.wholeFile": "Toàn bộ file",
|
"changes.wholeFile": "Toàn bộ file",
|
||||||
@@ -134,8 +133,6 @@
|
|||||||
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
|
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
|
||||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
||||||
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
||||||
"empty.body": "Mỗi workspace là một thư mục làm việc mà dashboard theo dõi — pipeline, session và terminal của nó. Bấm vào bất kỳ đâu trên ô này để thêm, hoặc từ terminal:",
|
|
||||||
"empty.title": "Chưa có workspace nào",
|
|
||||||
"features.archived": "đã lưu trữ",
|
"features.archived": "đã lưu trữ",
|
||||||
"features.live": "Phiên bản trực tiếp",
|
"features.live": "Phiên bản trực tiếp",
|
||||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — workspace tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — workspace tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||||
@@ -298,6 +295,7 @@
|
|||||||
"menu.remove": "Xóa workspace…",
|
"menu.remove": "Xóa workspace…",
|
||||||
"panel.changes": "Thay đổi",
|
"panel.changes": "Thay đổi",
|
||||||
"panel.analyst": "Analyst",
|
"panel.analyst": "Analyst",
|
||||||
|
"panel.folder": "Thư mục để xem thay đổi",
|
||||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||||
"replay.send": "Gửi",
|
"replay.send": "Gửi",
|
||||||
@@ -316,6 +314,10 @@
|
|||||||
"tree.search": "Tìm workspace và session",
|
"tree.search": "Tìm workspace và session",
|
||||||
"tree.toolFilter": "Lọc theo tool",
|
"tree.toolFilter": "Lọc theo tool",
|
||||||
"tree.allTools": "Mọi tool",
|
"tree.allTools": "Mọi tool",
|
||||||
|
"tree.ownerYou": "{{name}} (bạn)",
|
||||||
|
"tree.noOwner": "Không có chủ",
|
||||||
|
"tree.ownerToggle": "Hiện hoặc ẩn workspace của {{name}}",
|
||||||
|
"tree.readOnly": "Workspace của {{name}} — chỉ xem. Bấm một phiên để mở trang phân tích.",
|
||||||
"tree.newSession": "Session mới",
|
"tree.newSession": "Session mới",
|
||||||
"tree.noSessions": "Chưa có session",
|
"tree.noSessions": "Chưa có session",
|
||||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||||
@@ -389,6 +391,8 @@
|
|||||||
"panes.split.bottom": "Tách xuống dưới",
|
"panes.split.bottom": "Tách xuống dưới",
|
||||||
"panes.full": "Một tab chứa tối đa 4 pane",
|
"panes.full": "Một tab chứa tối đa 4 pane",
|
||||||
"panes.newTerminal": "Terminal mới",
|
"panes.newTerminal": "Terminal mới",
|
||||||
|
"panes.showChat": "Xem dạng chat",
|
||||||
|
"panes.showTerminal": "Xem dạng terminal",
|
||||||
"panes.close": "Tách ra tab riêng (session vẫn chạy)",
|
"panes.close": "Tách ra tab riêng (session vẫn chạy)",
|
||||||
"panes.resize": "Đổi kích thước pane",
|
"panes.resize": "Đổi kích thước pane",
|
||||||
"tabs.home": "Trang chủ workspace",
|
"tabs.home": "Trang chủ workspace",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
"goWorkspaces": "Mở Workspaces",
|
"goWorkspaces": "Mở Workspaces",
|
||||||
"add": {
|
"add": {
|
||||||
"title": "Tạo workspace đầu tiên",
|
"title": "Tạo workspace đầu tiên",
|
||||||
"body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm vào đây để thêm."
|
"body": "Workspace là một thư mục dự án nơi các agent làm việc. Bấm dấu + cạnh mục Không gian làm việc ở sidebar để thêm."
|
||||||
},
|
},
|
||||||
"folder": {
|
"folder": {
|
||||||
"title": "Chọn thư mục dự án",
|
"title": "Chọn thư mục dự án",
|
||||||
|
|||||||
@@ -85,7 +85,38 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"placeholder": "Viết tin nhắn…",
|
"placeholder": "Viết tin nhắn…",
|
||||||
"send": "Gửi",
|
"send": "Gửi",
|
||||||
"hint": "Enter để gửi · hỏi quyền và menu cần terminal (Settings → Appearance → Giao diện chat)",
|
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi."
|
"stop": "Ngưng",
|
||||||
|
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
||||||
|
"add": "Thêm file, skill…",
|
||||||
|
"upload": "Tải lên hoặc thêm file",
|
||||||
|
"projectFiles": "File trong dự án",
|
||||||
|
"contextFiles": "File ngữ cảnh",
|
||||||
|
"recentUploads": "Đã tải lên gần đây",
|
||||||
|
"searchFiles": "Tìm file và thư mục…",
|
||||||
|
"skills": "Skills",
|
||||||
|
"plugins": "Plugins",
|
||||||
|
"manageSkills": "Quản lý skills",
|
||||||
|
"managePlugins": "Quản lý plugins",
|
||||||
|
"none": "Chưa có gì",
|
||||||
|
"fromPlugin": "Plugin {{name}}",
|
||||||
|
"modeFailed": "Không đổi được permission mode: {{message}}",
|
||||||
|
"outline": {
|
||||||
|
"progress": "Tiến độ",
|
||||||
|
"outputs": "Kết quả",
|
||||||
|
"connectors": "Kết nối",
|
||||||
|
"empty": "Chưa có gì",
|
||||||
|
"hide": "Ẩn thanh bên",
|
||||||
|
"show": "Bảng hoạt động",
|
||||||
|
"copyPath": "Sao chép đường dẫn: {{path}}",
|
||||||
|
"panel": "Bảng hoạt động của phiên",
|
||||||
|
"used": "Đã dùng trong phiên",
|
||||||
|
"uploads": "Tệp tải lên",
|
||||||
|
"files": "Tệp đã đọc",
|
||||||
|
"memory": "Bộ nhớ",
|
||||||
|
"close": "Đóng",
|
||||||
|
"noPreview": "Không xem trước được tệp này ở đây",
|
||||||
|
"truncated": "Chỉ hiện phần đầu của tệp"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,10 @@
|
|||||||
"agents_other": "chạy {{count}} agent",
|
"agents_other": "chạy {{count}} agent",
|
||||||
"tools_one": "dùng 1 công cụ",
|
"tools_one": "dùng 1 công cụ",
|
||||||
"tools_other": "dùng {{count}} công cụ"
|
"tools_other": "dùng {{count}} công cụ"
|
||||||
}
|
},
|
||||||
|
"edited_one": "Đã sửa {{count}} tệp",
|
||||||
|
"edited_other": "Đã sửa {{count}} tệp",
|
||||||
|
"working": "Đang suy nghĩ…"
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -1022,9 +1022,9 @@
|
|||||||
},
|
},
|
||||||
"chatUi": {
|
"chatUi": {
|
||||||
"title": "Giao diện chat",
|
"title": "Giao diện chat",
|
||||||
"description": "Cách một session Claude hoặc Codex mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||||
"label": "Mở session dạng chat",
|
"label": "Mở session mới dạng chat",
|
||||||
"labelDesc": "Hiện hội thoại của mọi session Claude hoặc Codex như Claude Desktop, kèm ô nhập tin, thay cho terminal. Hỏi quyền và menu vẫn cần terminal: tắt mục này để trả lời chúng.",
|
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
|
||||||
"saveFailed": "Không lưu được: {{message}}"
|
"saveFailed": "Không lưu được: {{message}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,5 +69,7 @@
|
|||||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||||
"auditTitle": "Nhật ký audit",
|
"auditTitle": "Nhật ký audit",
|
||||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
||||||
"auditEmpty": "Chưa ghi nhận gì."
|
"auditEmpty": "Chưa ghi nhận gì.",
|
||||||
|
"activeCount": "{{n}} đang hoạt động",
|
||||||
|
"cost30d": "Chi phí · 30 ngày"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,9 +351,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
body {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgb(var(--border)) rgb(var(--surface-2));
|
/* Claude's: a faint thumb on no track. */
|
||||||
|
scrollbar-color: rgb(var(--fg-muted) / 0.35) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
@@ -362,7 +367,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
background: rgb(var(--surface-2));
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
* @description Pins where a pasted diff comment lands: the workspace's pane
|
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||||||
* is not on screen (a hidden console behind a full-width Changes panel).
|
* is not on screen (a hidden console behind a full-width Changes panel); and
|
||||||
|
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||||
|
|
||||||
describe("panePaste", () => {
|
describe("panePaste", () => {
|
||||||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||||
@@ -48,4 +49,20 @@ describe("panePaste", () => {
|
|||||||
expect(got).toEqual(["y"]);
|
expect(got).toEqual(["y"]);
|
||||||
off();
|
off();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("interruptRun presses Esc in a visible pane only", () => {
|
||||||
|
const keys: string[] = [];
|
||||||
|
let shown = false;
|
||||||
|
const off = registerPane(
|
||||||
|
"agenthub-lane-6",
|
||||||
|
() => {},
|
||||||
|
() => shown,
|
||||||
|
(d) => keys.push(d)
|
||||||
|
);
|
||||||
|
expect(interruptRun("agenthub-lane-6")).toBe(false);
|
||||||
|
shown = true;
|
||||||
|
expect(interruptRun("agenthub-lane-6")).toBe(true);
|
||||||
|
expect(keys).toEqual(["\x1b"]);
|
||||||
|
off();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+50
-8
@@ -644,22 +644,30 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* What a workspace git call works on: the workspace (its id) or, with
|
* What a workspace git call works on: the workspace (its id) or, with
|
||||||
* `session`, that session's own worktree (`claude --worktree`) — the server
|
* `session`, that session's own worktree (`claude --worktree`), or, with
|
||||||
* resolves the tree from the session row (`?session=`), never from a path.
|
* `source`, one of the workspace's extra folders — the server resolves the
|
||||||
|
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
|
||||||
|
* never from a path.
|
||||||
*/
|
*/
|
||||||
export type GitTarget = number | { lane: number; session?: string };
|
export type GitTarget = number | { lane: number; session?: string; source?: number };
|
||||||
|
|
||||||
/** `/lanes/<id>/git<sub>` plus `q` and, for a session target, `session=`. */
|
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
|
||||||
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||||
const params = new URLSearchParams(q);
|
const params = new URLSearchParams(q);
|
||||||
const lane = typeof target === "number" ? target : target.lane;
|
const lane = typeof target === "number" ? target : target.lane;
|
||||||
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
if (typeof target !== "number" && target.session) params.set("session", target.session);
|
||||||
|
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The target for `laneId`, in `session`'s worktree when one is given. */
|
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||||
export function gitTarget(laneId: number, session?: string | null): GitTarget {
|
export function gitTarget(
|
||||||
|
laneId: number,
|
||||||
|
session?: string | null,
|
||||||
|
source?: number | null
|
||||||
|
): GitTarget {
|
||||||
|
if (source) return { lane: laneId, source };
|
||||||
return session ? { lane: laneId, session } : laneId;
|
return session ? { lane: laneId, session } : laneId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +919,9 @@ export const api = {
|
|||||||
* @returns {@link SessionStats} for that one session.
|
* @returns {@link SessionStats} for that one session.
|
||||||
*/
|
*/
|
||||||
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
|
stats: (id: string) => request<SessionStats>(`/sessions/${encodeURIComponent(id)}/stats`),
|
||||||
|
/** GET /api/sessions/:id/outline — the session's plan, the files it wrote
|
||||||
|
* and the skills / MCP servers it used (a Chat pane's side panel). */
|
||||||
|
outline: (id: string) => request<SessionOutline>(`/sessions/${encodeURIComponent(id)}/outline`),
|
||||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||||
* workspace, deletes nothing). */
|
* workspace, deletes nothing). */
|
||||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||||
@@ -2437,10 +2448,12 @@ export const api = {
|
|||||||
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
|
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
|
||||||
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
||||||
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
||||||
/** GET /api/run/files - path-completion suggestions under `cwd`. */
|
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
|
||||||
files: (cwd: string, q?: string) => {
|
* adds the folders too, as `dir/`. */
|
||||||
|
files: (cwd: string, q?: string, dirs = false) => {
|
||||||
const qs = new URLSearchParams({ cwd });
|
const qs = new URLSearchParams({ cwd });
|
||||||
if (q) qs.set("q", q);
|
if (q) qs.set("q", q);
|
||||||
|
if (dirs) qs.set("dirs", "1");
|
||||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||||
@@ -2470,6 +2483,19 @@ export const api = {
|
|||||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||||
/** GET /api/run/:id - one run's current handle. */
|
/** GET /api/run/:id - one run's current handle. */
|
||||||
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
|
get: (id: string) => request<RunHandle>(`/run/${encodeURIComponent(id)}`),
|
||||||
|
/** GET /api/run/:id/permission-mode - the mode a live claude pane shows now
|
||||||
|
* (null while a menu hides it). */
|
||||||
|
permissionMode: (id: string) =>
|
||||||
|
request<{ mode: PermissionMode | "dontAsk" | null }>(
|
||||||
|
`/run/${encodeURIComponent(id)}/permission-mode`
|
||||||
|
),
|
||||||
|
/** POST /api/run/:id/permission-mode - step the pane's mode (Shift+Tab, each
|
||||||
|
* step read back) to `mode`; 409 when it cannot be reached. */
|
||||||
|
setPermissionMode: (id: string, mode: PermissionMode) =>
|
||||||
|
request<{ mode: PermissionMode }>(`/run/${encodeURIComponent(id)}/permission-mode`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ mode }),
|
||||||
|
}),
|
||||||
/** DELETE /api/run/:id - kill the tmux session. */
|
/** DELETE /api/run/:id - kill the tmux session. */
|
||||||
kill: (id: string) =>
|
kill: (id: string) =>
|
||||||
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||||
@@ -4294,6 +4320,22 @@ export interface CodexMarketplaceActionArgs {
|
|||||||
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
|
/** Maps 1:1 to the `claude --permission-mode` CLI flag. */
|
||||||
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
export type PermissionMode = "auto" | "acceptEdits" | "default" | "plan" | "bypassPermissions";
|
||||||
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
|
/** Maps 1:1 to the `claude --effort` CLI flag; "" omits the flag (model default). */
|
||||||
|
/** `GET /api/sessions/:id/outline`. `status` is the agent's own word
|
||||||
|
* (`pending` / `in_progress` / `completed`). */
|
||||||
|
export interface SessionOutline {
|
||||||
|
progress: { id: string; subject: string; status: string }[];
|
||||||
|
/** Files it wrote or edited, newest first. */
|
||||||
|
outputs: string[];
|
||||||
|
/** Files it only read, newest first. */
|
||||||
|
files: string[];
|
||||||
|
/** Files attached to its prompts (the composer's drop dir), newest first. */
|
||||||
|
uploads: string[];
|
||||||
|
/** Claude Code memory files it read or wrote, newest first. */
|
||||||
|
memory: string[];
|
||||||
|
skills: string[];
|
||||||
|
mcp: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
|
export type EffortLevel = "" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||||
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
|
/** "running" — a live tmux session exists; "gone" — it doesn't (killed,
|
||||||
* crashed, claude exited and closed the pane). Computed fresh from tmux
|
* crashed, claude exited and closed the pane). Computed fresh from tmux
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @file brand.ts
|
||||||
|
* @description SmartGift logo URLs shared by the sidebar and the login page.
|
||||||
|
* Remote URLs: the logo is owned by the brand, not vendored here — a machine
|
||||||
|
* with no network shows the alt text instead.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BRAND_LOGO =
|
||||||
|
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||||
|
export const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||||
+64
-15
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file chatUi.ts — the viewer's Chat UI choice: whether a Claude/Codex pane
|
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
|
||||||
* opens as a chat (`PaneChat`, like Claude Desktop) instead of its terminal.
|
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
|
||||||
* Kept by the server per ACCOUNT (`GET/PUT /api/me/prefs`, key
|
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
|
||||||
* `agenthub.chatUi` = "1"; local mode is one account), so it follows a user to
|
* chat/terminal button), kept in this browser's localStorage
|
||||||
* any browser and differs between users of one dashboard. Read once per page
|
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
|
||||||
* load and shared through `useChatUi`; `saveChatUi` updates every reader at
|
* setting is only the DEFAULT: kept by the server per account
|
||||||
* once and puts the old value back if the save fails.
|
* (`GET/PUT /api/me/prefs`, key `agenthub.chatUi` = "1"; local mode is one
|
||||||
|
* account), it is written onto a session the first time that session is
|
||||||
|
* shown, so changing it later leaves every session already seen alone. Read
|
||||||
|
* once per page load and shared through `useChatUi`; `saveChatUi` updates
|
||||||
|
* every reader at once and puts the old value back if the save fails.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -14,21 +18,44 @@ import { api } from "./api";
|
|||||||
|
|
||||||
export const CHAT_UI_KEY = "agenthub.chatUi";
|
export const CHAT_UI_KEY = "agenthub.chatUi";
|
||||||
|
|
||||||
|
export const VIEWS_KEY = "agenthub.chatUi.sessions";
|
||||||
|
const MAX_VIEWS = 200;
|
||||||
|
|
||||||
let chatUi = false;
|
let chatUi = false;
|
||||||
|
let loaded = false;
|
||||||
let loading: Promise<void> | null = null;
|
let loading: Promise<void> | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
const subscribe = (fn: () => void) => {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const emit = (next: boolean) => {
|
const emit = (next: boolean) => {
|
||||||
chatUi = next;
|
chatUi = next;
|
||||||
listeners.forEach((fn) => fn());
|
listeners.forEach((fn) => fn());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function readViews(): Record<string, boolean> {
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(localStorage.getItem(VIEWS_KEY) || "{}");
|
||||||
|
return v && typeof v === "object" ? (v as Record<string, boolean>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let views = readViews();
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
// A failed read (logged out, offline) must never take a page down with it.
|
// A failed read (logged out, offline) must never take a page down with it.
|
||||||
loading ??= Promise.resolve()
|
loading ??= Promise.resolve()
|
||||||
.then(() => api.me.prefs())
|
.then(() => api.me.prefs())
|
||||||
.then(
|
.then(
|
||||||
(r) => emit(r.prefs[CHAT_UI_KEY] === "1"),
|
(r) => {
|
||||||
|
loaded = true;
|
||||||
|
emit(r.prefs[CHAT_UI_KEY] === "1");
|
||||||
|
},
|
||||||
() => {
|
() => {
|
||||||
loading = null; // the next reader asks again
|
loading = null; // the next reader asks again
|
||||||
}
|
}
|
||||||
@@ -37,13 +64,32 @@ function load() {
|
|||||||
|
|
||||||
export function useChatUi(): boolean {
|
export function useChatUi(): boolean {
|
||||||
useEffect(load, []);
|
useEffect(load, []);
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(subscribe, () => chatUi);
|
||||||
(fn) => {
|
}
|
||||||
listeners.add(fn);
|
|
||||||
return () => listeners.delete(fn);
|
/** Shows `sessionId` as a chat (`true`) or its terminal, for this browser. */
|
||||||
},
|
export function setSessionChat(sessionId: string, on: boolean) {
|
||||||
() => chatUi
|
const { [sessionId]: _old, ...rest } = views;
|
||||||
);
|
// Newest last, so the cap drops the sessions touched longest ago.
|
||||||
|
views = Object.fromEntries(Object.entries({ ...rest, [sessionId]: on }).slice(-MAX_VIEWS));
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VIEWS_KEY, JSON.stringify(views));
|
||||||
|
} catch {
|
||||||
|
// Storage full or blocked: the choice still holds for this page load.
|
||||||
|
}
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own view; a session seen for the first time takes the
|
||||||
|
* account's default (once it is read) and keeps it. */
|
||||||
|
export function useSessionChat(sessionId: string | null | undefined): boolean {
|
||||||
|
const fallback = useChatUi();
|
||||||
|
const own = useSyncExternalStore(subscribe, () => (sessionId ? views[sessionId] : undefined));
|
||||||
|
const ready = useSyncExternalStore(subscribe, () => loaded);
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId && own === undefined && ready) setSessionChat(sessionId, chatUi);
|
||||||
|
}, [sessionId, own, ready]);
|
||||||
|
return own ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveChatUi(on: boolean): Promise<void> {
|
export async function saveChatUi(on: boolean): Promise<void> {
|
||||||
@@ -61,5 +107,8 @@ export async function saveChatUi(on: boolean): Promise<void> {
|
|||||||
/** Tests: forget what was read. */
|
/** Tests: forget what was read. */
|
||||||
export function __resetChatUi() {
|
export function __resetChatUi() {
|
||||||
chatUi = false;
|
chatUi = false;
|
||||||
|
loaded = false;
|
||||||
loading = null;
|
loading = null;
|
||||||
|
views = {};
|
||||||
|
localStorage.removeItem(VIEWS_KEY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @file openInChanges.ts — asks the Workspace page to open a file in its
|
||||||
|
* Changes panel: a chat's "Edited N files" card names absolute paths deep in
|
||||||
|
* a pane, the panel lives on the page, so the request travels as a window
|
||||||
|
* event (like the sidebar's `LIVE_RUNS_EVENT`). The page opens the panel and
|
||||||
|
* that file there, if the panel shows the same workspace; else nothing.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const OPEN_IN_CHANGES_EVENT = "agenthub:open-in-changes";
|
||||||
|
|
||||||
|
/** A file to open: its workspace, and its path relative to the panel's folder. */
|
||||||
|
export type OpenInChanges = { laneId: number; path: string };
|
||||||
|
|
||||||
|
/** Opens `path` (absolute) when it lies under `cwd`, the pane's folder — the
|
||||||
|
* folder the Changes panel reads for it. False when it does not. */
|
||||||
|
export function openInChanges(laneId: number, cwd: string, path: string): boolean {
|
||||||
|
const base = cwd.replace(/\/+$/, "");
|
||||||
|
if (!base || !path.startsWith(`${base}/`)) return false;
|
||||||
|
const detail: OpenInChanges = { laneId, path: path.slice(base.length + 1) };
|
||||||
|
window.dispatchEvent(new CustomEvent(OPEN_IN_CHANGES_EVENT, { detail }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -9,25 +9,28 @@
|
|||||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||||
* unseen); the caller says where it went, or that it went nowhere. The one
|
* unseen); the caller says where it went, or that it went nowhere. The one
|
||||||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||||||
* what the user typed there, so it pastes AND presses Enter.
|
* what the user typed there, so it pastes AND presses Enter — and its Stop
|
||||||
|
* button presses Esc through `interruptRun`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Pane {
|
interface Pane {
|
||||||
paste: (text: string, submit?: boolean) => void;
|
paste: (text: string, submit?: boolean) => void;
|
||||||
visible: () => boolean;
|
visible: () => boolean;
|
||||||
|
key?: (data: string) => void;
|
||||||
}
|
}
|
||||||
const panes = new Map<string, Pane>();
|
const panes = new Map<string, Pane>();
|
||||||
let lastFocused: string | null = null;
|
let lastFocused: string | null = null;
|
||||||
|
|
||||||
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
|
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||||||
* unregister. */
|
* raw input, as a key press); returns its unregister. */
|
||||||
export function registerPane(
|
export function registerPane(
|
||||||
runId: string,
|
runId: string,
|
||||||
paste: (text: string, submit?: boolean) => void,
|
paste: (text: string, submit?: boolean) => void,
|
||||||
visible: () => boolean = () => true
|
visible: () => boolean = () => true,
|
||||||
|
key?: (data: string) => void
|
||||||
): () => void {
|
): () => void {
|
||||||
const entry = { paste, visible };
|
const entry = { paste, visible, key };
|
||||||
panes.set(runId, entry);
|
panes.set(runId, entry);
|
||||||
return () => {
|
return () => {
|
||||||
if (panes.get(runId) === entry) panes.delete(runId);
|
if (panes.get(runId) === entry) panes.delete(runId);
|
||||||
@@ -63,6 +66,15 @@ export function submitToRun(runId: string, text: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
|
||||||
|
* they are running. False when the pane is not mounted or not attached. */
|
||||||
|
export function interruptRun(runId: string): boolean {
|
||||||
|
const p = panes.get(runId);
|
||||||
|
if (!p?.visible() || !p.key) return false;
|
||||||
|
p.key("\x1b");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||||||
* last when it is — and returns its run id; null when none is on screen. */
|
* last when it is — and returns its run id; null when none is on screen. */
|
||||||
export function pasteToLane(laneId: number, text: string): string | null {
|
export function pasteToLane(laneId: number, text: string): string | null {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
|
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
|
||||||
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
|
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
|
||||||
* expanded to their sessions (by lane id), the tool the list is filtered to,
|
* expanded to their sessions (by lane id), the tool the list is filtered to,
|
||||||
* and — Settings → Worktrees —
|
* which owner groups an admin/analyst folded, and — Settings → Worktrees —
|
||||||
* whether a workspace home's Add folder suggests the repo's untracked
|
* whether a workspace home's Add folder suggests the repo's untracked
|
||||||
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
|
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
|
||||||
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
|
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
|
||||||
@@ -20,6 +20,8 @@ export interface ProjectTreePrefs {
|
|||||||
expanded: Record<string, boolean>;
|
expanded: Record<string, boolean>;
|
||||||
/** Show only this tool's workspaces; null = every tool. */
|
/** Show only this tool's workspaces; null = every tool. */
|
||||||
tool: string | null;
|
tool: string | null;
|
||||||
|
/** Server mode, admin/analyst: owner key (user id, or "none") → its group is folded. */
|
||||||
|
collapsedOwners: Record<string, boolean>;
|
||||||
showUntracked: boolean;
|
showUntracked: boolean;
|
||||||
hideSources: string[];
|
hideSources: string[];
|
||||||
}
|
}
|
||||||
@@ -27,6 +29,7 @@ export interface ProjectTreePrefs {
|
|||||||
const DEFAULTS: ProjectTreePrefs = {
|
const DEFAULTS: ProjectTreePrefs = {
|
||||||
expanded: {},
|
expanded: {},
|
||||||
tool: null,
|
tool: null,
|
||||||
|
collapsedOwners: {},
|
||||||
showUntracked: true,
|
showUntracked: true,
|
||||||
hideSources: [],
|
hideSources: [],
|
||||||
};
|
};
|
||||||
@@ -50,6 +53,10 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
|
|||||||
value = {
|
value = {
|
||||||
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
|
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
|
||||||
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
|
tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null,
|
||||||
|
collapsedOwners:
|
||||||
|
parsed.collapsedOwners && typeof parsed.collapsedOwners === "object"
|
||||||
|
? parsed.collapsedOwners
|
||||||
|
: {},
|
||||||
showUntracked: parsed.showUntracked !== false,
|
showUntracked: parsed.showUntracked !== false,
|
||||||
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* @file promptAttach.ts
|
||||||
|
* @description What a prompt box can attach besides a fresh upload: a project
|
||||||
|
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
||||||
|
* `PromptEditor`'s drop) and the files uploaded through a prompt box in the
|
||||||
|
* last day (`recentUploads`, kept in this browser). An attachment is a path
|
||||||
|
* sent with the prompt, so nothing here touches the server.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DragEvent } from "react";
|
||||||
|
|
||||||
|
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||||
|
export interface Attachment {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ATTACH_MIME = "application/x-agenthub-attach";
|
||||||
|
|
||||||
|
/** A project path (relative to the box's folder) as Claude Code's `@` mention. */
|
||||||
|
export function projectAttachment(rel: string): Attachment {
|
||||||
|
const name = rel.replace(/\/$/, "").split("/").pop() || rel;
|
||||||
|
return { path: `@${rel}`, name: rel.endsWith("/") ? `${name}/` : name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start a drag carrying `rel` (a Files panel row) to a prompt box; plain
|
||||||
|
* text too, so a drop elsewhere (a terminal, an editor) gets the path. */
|
||||||
|
export function dragProjectPath(e: DragEvent, rel: string) {
|
||||||
|
e.dataTransfer.setData(ATTACH_MIME, JSON.stringify(projectAttachment(rel)));
|
||||||
|
e.dataTransfer.setData("text/plain", rel);
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The attachment a drop carries, if it is one of ours. */
|
||||||
|
export function droppedAttachment(dt: DataTransfer | null | undefined): Attachment | null {
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(dt?.getData(ATTACH_MIME) || "null");
|
||||||
|
const a = v as Attachment | null;
|
||||||
|
return a && typeof a.path === "string" && typeof a.name === "string"
|
||||||
|
? { path: a.path, name: a.name }
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `prev` plus whichever of `items` it does not hold yet. */
|
||||||
|
export function addAttachments<T extends Attachment>(prev: T[], items: T[]): T[] {
|
||||||
|
const fresh = items.filter(
|
||||||
|
(a, i) =>
|
||||||
|
!prev.some((p) => p.path === a.path) && items.findIndex((b) => b.path === a.path) === i
|
||||||
|
);
|
||||||
|
return fresh.length ? [...prev, ...fresh] : prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECENT_KEY = "agenthub.prompt.recentUploads";
|
||||||
|
const RECENT_MAX = 20;
|
||||||
|
// The server prunes an upload a day after it was written (`saveDrop`).
|
||||||
|
// ponytail: a client-side guess of that TTL, a few minutes short; a listing
|
||||||
|
// route instead if uploads must outlive it or follow the user to another browser.
|
||||||
|
const RECENT_TTL_MS = 23.9 * 60 * 60_000;
|
||||||
|
|
||||||
|
interface Recent extends Attachment {
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecent(): Recent[] {
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) || "[]");
|
||||||
|
const cutoff = Date.now() - RECENT_TTL_MS;
|
||||||
|
return (Array.isArray(v) ? (v as Recent[]) : []).filter(
|
||||||
|
(r) => typeof r?.path === "string" && typeof r.name === "string" && r.at > cutoff
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentUploads(): Attachment[] {
|
||||||
|
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remember uploads just saved, newest first; an image keeps a thumbnail
|
||||||
|
* through `GET /api/run/drop/:name` (its local blob URL dies with the page). */
|
||||||
|
export function rememberUploads(items: { path: string; name: string; image: boolean }[]) {
|
||||||
|
const at = Date.now();
|
||||||
|
const added: Recent[] = items.map(({ path, name, image }) => ({
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
at,
|
||||||
|
...(image ? { url: `/api/run/drop/${encodeURIComponent(path.split("/").pop() || "")}` } : {}),
|
||||||
|
}));
|
||||||
|
const kept = readRecent().filter((r) => !added.some((a) => a.path === r.path));
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify([...added, ...kept].slice(0, RECENT_MAX)));
|
||||||
|
} catch {
|
||||||
|
/* storage full or blocked: the list is a convenience */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file terminalFont — the lane terminal's font, a viewer preference kept in
|
* @file terminalFont — the lane terminal's font, a viewer preference kept in
|
||||||
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
|
* `localStorage` (`agenthub.terminal.font`) and read by `TerminalView` when a
|
||||||
* terminal opens. `jetbrains` (the default) and `maple` (rounded) are bundled
|
* terminal opens. `jetbrains` (the default) and the rounded `maple` are
|
||||||
* webfonts that cover every Vietnamese letter; `system` is the browser's own
|
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||||
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
|
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
|
||||||
* and those glyphs then fall back to a face of another width).
|
* and those glyphs then fall back to a face of another width).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
|||||||
@@ -2661,6 +2661,8 @@ export interface Lane {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
/** Server mode: the account that owns this workspace (null = nobody yet). */
|
/** Server mode: the account that owns this workspace (null = nobody yet). */
|
||||||
owner_id?: number | null;
|
owner_id?: number | null;
|
||||||
|
/** The owner's username (`users.username`); null with no owner. */
|
||||||
|
owner_name?: string | null;
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
kind: "managed" | "adopted";
|
kind: "managed" | "adopted";
|
||||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||||
@@ -3205,6 +3207,9 @@ export interface TranscriptContent {
|
|||||||
output?: string;
|
output?: string;
|
||||||
/** Present for "tool_result" blocks: whether the tool call errored. */
|
/** Present for "tool_result" blocks: whether the tool call errored. */
|
||||||
is_error?: boolean;
|
is_error?: boolean;
|
||||||
|
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
|
||||||
|
* removed (from Claude Code's `structuredPatch`). */
|
||||||
|
diff?: { added: number; removed: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
||||||
@@ -3239,6 +3244,9 @@ export interface TranscriptMessage {
|
|||||||
* Example: `"claude-opus-4-8"`. */
|
* Example: `"claude-opus-4-8"`. */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Token accounting reported alongside an assistant message; absent otherwise. */
|
/** Token accounting reported alongside an assistant message; absent otherwise. */
|
||||||
|
/** Assistant lines: why the model stopped — "end_turn" closes the turn,
|
||||||
|
* "tool_use" means more work follows. Absent from an older server. */
|
||||||
|
stop_reason?: string;
|
||||||
usage?: {
|
usage?: {
|
||||||
/** Fresh (non-cached) input tokens for this message. */
|
/** Fresh (non-cached) input tokens for this message. */
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import i18n from "../i18n";
|
import i18n from "../i18n";
|
||||||
|
import { applyStoredTheme } from "../hooks/useTheme";
|
||||||
|
|
||||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||||
export const PREF_KEYS = [
|
export const PREF_KEYS = [
|
||||||
@@ -100,6 +101,8 @@ function readLocal(key: string): string | null {
|
|||||||
|
|
||||||
/** Make what reads these keys read them again. */
|
/** Make what reads these keys read them again. */
|
||||||
function reapply(prefs: Record<string, string>) {
|
function reapply(prefs: Record<string, string>) {
|
||||||
|
// Painted now, not only on the next useTheme render: the login page wore the OS's.
|
||||||
|
applyStoredTheme();
|
||||||
window.dispatchEvent(new Event("agenthub-theme"));
|
window.dispatchEvent(new Event("agenthub-theme"));
|
||||||
window.dispatchEvent(new Event("sagi:prefs"));
|
window.dispatchEvent(new Event("sagi:prefs"));
|
||||||
const lng = prefs.i18nextLng;
|
const lng = prefs.i18nextLng;
|
||||||
|
|||||||
@@ -57,15 +57,18 @@ import "@fontsource/inter/latin-600.css";
|
|||||||
import "@fontsource/inter/latin-700.css";
|
import "@fontsource/inter/latin-700.css";
|
||||||
import "@fontsource/jetbrains-mono/latin-400.css";
|
import "@fontsource/jetbrains-mono/latin-400.css";
|
||||||
import "@fontsource/jetbrains-mono/latin-500.css";
|
import "@fontsource/jetbrains-mono/latin-500.css";
|
||||||
|
import "@fontsource/jetbrains-mono/latin-700.css";
|
||||||
// The lane terminal renders live Vietnamese output in this font (TerminalView),
|
// The lane terminal renders live Vietnamese output in this font (TerminalView),
|
||||||
// which the latin subset above doesn't cover — without it, missing glyphs fall
|
// which the latin subset above doesn't cover — without it, missing glyphs fall
|
||||||
// back to another installed face with a different advance width and get
|
// back to another installed face with a different advance width and get
|
||||||
// visually mangled inside xterm's fixed-width cells.
|
// visually mangled inside xterm's fixed-width cells.
|
||||||
import "@fontsource/jetbrains-mono/vietnamese-400.css";
|
import "@fontsource/jetbrains-mono/vietnamese-400.css";
|
||||||
import "@fontsource/jetbrains-mono/vietnamese-500.css";
|
import "@fontsource/jetbrains-mono/vietnamese-500.css";
|
||||||
|
import "@fontsource/jetbrains-mono/vietnamese-700.css";
|
||||||
// The rounded terminal font (Settings → Appearance). Its one file already holds
|
// The rounded terminal font (Settings → Appearance). Its one file already holds
|
||||||
// the Vietnamese glyphs; a face is only downloaded once something uses it.
|
// the Vietnamese glyphs; a face is only downloaded once something uses it.
|
||||||
import "@fontsource/maple-mono/400.css";
|
import "@fontsource/maple-mono/400.css";
|
||||||
|
import "@fontsource/maple-mono/500.css";
|
||||||
import "@fontsource/maple-mono/700.css";
|
import "@fontsource/maple-mono/700.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AuthGate } from "./components/AuthGate";
|
import { AuthGate } from "./components/AuthGate";
|
||||||
|
|||||||
+242
-106
@@ -7,13 +7,30 @@
|
|||||||
* an admin — the account changes: add a user, change a role, disable/enable,
|
* an admin — the account changes: add a user, change a role, disable/enable,
|
||||||
* reset a password, give a workspace. Every change asks for the admin's OWN
|
* reset a password, give a workspace. Every change asks for the admin's OWN
|
||||||
* password again (the server refuses without it); an analyst only reads.
|
* password again (the server refuses without it); an analyst only reads.
|
||||||
|
* Laid out like the other list pages: icon header, a row of totals
|
||||||
|
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
DollarSign,
|
||||||
|
Eye,
|
||||||
|
FolderInput,
|
||||||
|
FolderOpen,
|
||||||
|
KeyRound,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
MessagesSquare,
|
||||||
|
RefreshCw,
|
||||||
|
ScrollText,
|
||||||
|
Shield,
|
||||||
|
UserPlus,
|
||||||
|
Users as UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||||
@@ -22,7 +39,8 @@ import { isSubmitEnter } from "../lib/keys";
|
|||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { useAuth } from "../components/AuthGate";
|
import { useAuth } from "../components/AuthGate";
|
||||||
import { ConfirmModal } from "../components/ConfirmModal";
|
import { ConfirmModal } from "../components/ConfirmModal";
|
||||||
import { SettingsSection } from "../components/settings/SettingsRow";
|
import { StatCard } from "../components/StatCard";
|
||||||
|
import { TableRowSkeleton } from "../components/Skeleton";
|
||||||
|
|
||||||
type Role = AuthUser["role"];
|
type Role = AuthUser["role"];
|
||||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||||
@@ -67,12 +85,22 @@ export function Users() {
|
|||||||
// Local mode, or a developer: there is no Users page for them.
|
// Local mode, or a developer: there is no Users page for them.
|
||||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||||
|
|
||||||
|
const sum = (pick: (u: ServerUserUsage) => number) =>
|
||||||
|
(users ?? []).reduce((n, u) => n + pick(u), 0);
|
||||||
|
const active = (users ?? []).filter((u) => !u.disabled).length;
|
||||||
|
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-6">
|
<div className="animate-fade-in space-y-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||||
|
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -97,115 +125,206 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection title={t("title")}>
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{error && (
|
<StatCard
|
||||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
label={t("title")}
|
||||||
{error}
|
value={users?.length ?? 0}
|
||||||
</p>
|
icon={UsersIcon}
|
||||||
)}
|
trend={users ? t("activeCount", { n: active }) : undefined}
|
||||||
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
|
loading={!users && !error}
|
||||||
{users && (
|
/>
|
||||||
<div className="overflow-x-auto">
|
<StatCard
|
||||||
<table className="w-full text-sm" data-testid="users-table">
|
label={t("workspaces")}
|
||||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
value={sum((u) => u.workspaces)}
|
||||||
<tr>
|
icon={FolderOpen}
|
||||||
<th className="py-2 pr-3 font-medium">{t("name")}</th>
|
loading={!users && !error}
|
||||||
<th className="py-2 pr-3 font-medium">{t("role")}</th>
|
/>
|
||||||
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
|
<StatCard
|
||||||
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
|
label={t("sessions")}
|
||||||
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
|
value={sum((u) => u.sessions)}
|
||||||
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
|
icon={MessagesSquare}
|
||||||
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
|
loading={!users && !error}
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
<StatCard
|
||||||
<tbody className="divide-y divide-border/60">
|
label={t("cost30d")}
|
||||||
{users.map((u) => {
|
value={fmtCost(sum((u) => u.last30d.cost))}
|
||||||
const self = u.id === me.id;
|
icon={DollarSign}
|
||||||
return (
|
trend={users ? `${t("last7d")}: ${fmtCost(sum((u) => u.last7d.cost))}` : undefined}
|
||||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
raw={t("tokens", { n: fmt(sum((u) => u.last30d.tokens)) })}
|
||||||
<td className="py-2 pr-3">
|
loading={!users && !error}
|
||||||
{u.username}
|
/>
|
||||||
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
|
</div>
|
||||||
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
|
|
||||||
{t(`roles.${u.role}`)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
|
||||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
|
||||||
<td
|
|
||||||
className="py-2 pr-3 text-right tabular-nums"
|
|
||||||
title={t("tokens", { n: fmt(u.last7d.tokens) })}
|
|
||||||
>
|
|
||||||
{fmtCost(u.last7d.cost)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="py-2 pr-3 text-right tabular-nums"
|
|
||||||
title={t("tokens", { n: fmt(u.last30d.tokens) })}
|
|
||||||
>
|
|
||||||
{fmtCost(u.last30d.cost)}
|
|
||||||
</td>
|
|
||||||
{isAdmin && (
|
|
||||||
<td className="py-1 text-right whitespace-nowrap">
|
|
||||||
<RowAction
|
|
||||||
label={t("changeRole")}
|
|
||||||
disabled={self}
|
|
||||||
onClick={() => setDialog({ kind: "role", user: u })}
|
|
||||||
>
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
</RowAction>
|
|
||||||
<RowAction
|
|
||||||
label={t("resetPassword")}
|
|
||||||
onClick={() => setDialog({ kind: "password", user: u })}
|
|
||||||
>
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
</RowAction>
|
|
||||||
<RowAction
|
|
||||||
label={t("assignWorkspace")}
|
|
||||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
|
||||||
>
|
|
||||||
<FolderInput className="h-4 w-4" />
|
|
||||||
</RowAction>
|
|
||||||
<RowAction
|
|
||||||
label={u.disabled ? t("enable") : t("disable")}
|
|
||||||
disabled={self}
|
|
||||||
onClick={() =>
|
|
||||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{u.disabled ? (
|
|
||||||
<LockOpen className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Lock className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</RowAction>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
|
{error && (
|
||||||
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0">{error}</span>
|
||||||
|
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||||
|
{t("common:retry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isAdmin && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||||
|
<Eye className="w-3.5 h-3.5" /> {t("readOnly")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[760px]" data-testid="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className={th}>{t("name")}</th>
|
||||||
|
<th className={th}>{t("role")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||||
|
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{!users &&
|
||||||
|
!error &&
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<TableRowSkeleton
|
||||||
|
key={i}
|
||||||
|
columns={isAdmin ? 7 : 6}
|
||||||
|
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{users?.map((u) => {
|
||||||
|
const self = u.id === me.id;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
|
||||||
|
>
|
||||||
|
{u.username.slice(0, 2)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium text-fg-primary">
|
||||||
|
{u.username}
|
||||||
|
</span>
|
||||||
|
{self && (
|
||||||
|
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||||
|
{t("you")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{u.disabled && (
|
||||||
|
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||||
|
{t("disabled")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span
|
||||||
|
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
|
||||||
|
title={t(`roleHint.${u.role}`)}
|
||||||
|
>
|
||||||
|
{t(`roles.${u.role}`)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||||
|
{u.workspaces}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||||
|
{u.sessions}
|
||||||
|
</td>
|
||||||
|
<UsageCell usage={u.last7d} />
|
||||||
|
<UsageCell usage={u.last30d} />
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||||
|
<RowAction
|
||||||
|
label={t("changeRole")}
|
||||||
|
disabled={self}
|
||||||
|
onClick={() => setDialog({ kind: "role", user: u })}
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
</RowAction>
|
||||||
|
<RowAction
|
||||||
|
label={t("resetPassword")}
|
||||||
|
onClick={() => setDialog({ kind: "password", user: u })}
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</RowAction>
|
||||||
|
<RowAction
|
||||||
|
label={t("assignWorkspace")}
|
||||||
|
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||||
|
>
|
||||||
|
<FolderInput className="h-4 w-4" />
|
||||||
|
</RowAction>
|
||||||
|
<RowAction
|
||||||
|
label={u.disabled ? t("enable") : t("disable")}
|
||||||
|
disabled={self}
|
||||||
|
onClick={() =>
|
||||||
|
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{u.disabled ? (
|
||||||
|
<LockOpen className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</RowAction>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
|
<ScrollText className="w-4 h-4 text-fg-muted" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-fg-primary">{t("auditTitle")}</h2>
|
||||||
|
<p className="text-xs text-fg-muted">{t("auditDescription")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{audit?.length === 0 && (
|
||||||
|
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("auditEmpty")}</p>
|
||||||
|
)}
|
||||||
{audit && audit.length > 0 && (
|
{audit && audit.length > 0 && (
|
||||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
<ul
|
||||||
|
className="divide-y divide-border text-xs max-h-[28rem] overflow-y-auto"
|
||||||
|
data-testid="audit-list"
|
||||||
|
>
|
||||||
{audit.map((a) => (
|
{audit.map((a) => (
|
||||||
<li key={a.id} className="flex gap-3 py-1.5">
|
<li
|
||||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
key={a.id}
|
||||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
className="flex items-center gap-3 px-5 py-2 hover:bg-surface-4/40 transition-colors"
|
||||||
{a.reader} → {a.owner}
|
>
|
||||||
|
<span className="w-36 flex-shrink-0 tabular-nums text-fg-muted">
|
||||||
|
{formatDateTime(a.at)}
|
||||||
|
</span>
|
||||||
|
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
|
||||||
|
<span className="font-medium text-fg-primary">{a.reader}</span>
|
||||||
|
<span className="mx-1.5 text-fg-muted">→</span>
|
||||||
|
{a.owner}
|
||||||
|
</span>
|
||||||
|
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
|
||||||
|
{a.method}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate font-mono text-fg-muted" title={a.path}>
|
||||||
|
{a.path}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</section>
|
||||||
|
|
||||||
{dialog && (
|
{dialog && (
|
||||||
<AccountDialog
|
<AccountDialog
|
||||||
@@ -223,6 +342,23 @@ export function Users() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLE_BADGE: Record<Role, string> = {
|
||||||
|
admin: "bg-accent/15 text-accent border-accent/20",
|
||||||
|
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
|
||||||
|
developer: "bg-surface-4 text-fg-secondary border-border",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A period's cost, with its tokens underneath. */
|
||||||
|
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
return (
|
||||||
|
<td className="px-5 py-3 text-right tabular-nums">
|
||||||
|
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
|
||||||
|
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RowAction({
|
function RowAction({
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
|
|||||||
+166
-105
@@ -15,8 +15,9 @@
|
|||||||
* a tab click replaces the history entry (Back leaves the session view, not
|
* a tab click replaces the history entry (Back leaves the session view, not
|
||||||
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
|
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
|
||||||
* splits the focused one right with a new terminal, Alt+W hides it. The
|
* splits the focused one right with a new terminal, Alt+W hides it. The
|
||||||
* Changes panel resizes by its button's presets or by dragging (or arrow keys
|
* Changes panel has Full screen (Restore at full screen) and Collapse
|
||||||
* on) its splitter; an unsaved edit in it is asked about before the panel
|
* buttons, and resizes by dragging (or arrow keys on) its splitter; a file in
|
||||||
|
* a chat's "Edited N files" card opens in it (`OPEN_IN_CHANGES_EVENT`); an unsaved edit in it is asked about before the panel
|
||||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||||
* the old one until saved or discarded). Errors are toasts.
|
* the old one until saved or discarded). Errors are toasts.
|
||||||
*
|
*
|
||||||
@@ -54,17 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
|
||||||
Plus,
|
|
||||||
Columns2,
|
|
||||||
Layers,
|
|
||||||
PanelLeftClose,
|
|
||||||
PanelRightClose,
|
|
||||||
Maximize2,
|
|
||||||
ChartColumn,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
import type {
|
||||||
|
CwdSuggestion,
|
||||||
|
LaneSource,
|
||||||
|
LaneSourcesView,
|
||||||
|
RunHandle,
|
||||||
|
RunListResponse,
|
||||||
|
} from "../lib/api";
|
||||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||||
@@ -109,6 +108,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
|
|||||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||||
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
import { AddLaneModal } from "../components/lanes/AddLaneModal";
|
||||||
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||||
|
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||||
@@ -118,39 +118,37 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
|||||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||||
/** localStorage key for how wide the Changes panel is. */
|
/** localStorage key for how wide the Changes panel is. */
|
||||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||||
|
/** localStorage key for the last width the user dragged the panel to — what
|
||||||
|
* it reopens at after the step control closes it. */
|
||||||
|
const CHANGES_DRAGGED_KEY = "agenthub.workspace.changesDragged";
|
||||||
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
/** localStorage key for the last workspace shown, restored on a bare /workspace. */
|
||||||
const SELECTED_KEY = "agenthub.workspace.selected";
|
const SELECTED_KEY = "agenthub.workspace.selected";
|
||||||
|
|
||||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
/** The Changes panel's two preset widths. Its header has two controls, like a
|
||||||
* diff and watching a console want opposite amounts of room, and which one is
|
* window's: Full screen (Restore once at full screen, back to the last
|
||||||
* "work" changes several times an hour — hence ONE control that steps
|
* dragged width) and Collapse (to the rail). */
|
||||||
* 30/70 → 50/50 → full → closed, rather than a width button beside a
|
const CHANGES_SIZES = ["compact", "full"] as const;
|
||||||
* collapse button that both live in the panel's tightest corner. */
|
|
||||||
const CHANGES_SIZES = ["compact", "half", "full"] as const;
|
|
||||||
type ChangesPreset = (typeof CHANGES_SIZES)[number];
|
type ChangesPreset = (typeof CHANGES_SIZES)[number];
|
||||||
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
|
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
|
||||||
type ChangesSize = ChangesPreset | number;
|
type ChangesSize = ChangesPreset | number;
|
||||||
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
|
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
|
||||||
compact: "w-[30%]",
|
compact: "w-[25%]",
|
||||||
half: "w-1/2",
|
|
||||||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||||||
// and a half-typed prompt must survive a round trip through full width.
|
// and a half-typed prompt must survive a round trip through full width.
|
||||||
full: "w-full",
|
full: "w-full",
|
||||||
};
|
};
|
||||||
/** The icon the one resize control shows AT each size: what the next click
|
|
||||||
* gives you — an even split, the full width, then closed. */
|
|
||||||
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
|
|
||||||
compact: Columns2,
|
|
||||||
half: Maximize2,
|
|
||||||
full: PanelRightClose,
|
|
||||||
};
|
|
||||||
/** The panel's share of the row, for the splitter. */
|
/** The panel's share of the row, for the splitter. */
|
||||||
const changesRatio = (s: ChangesSize) =>
|
const changesRatio = (s: ChangesSize) => (typeof s === "number" ? s : s === "compact" ? 0.25 : 1);
|
||||||
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
|
|
||||||
/** The preset a size counts as for the button: under ~45% is narrow. */
|
|
||||||
const changesStep = (s: ChangesSize): ChangesPreset =>
|
|
||||||
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
|
|
||||||
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
|
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
|
||||||
|
/** The width the user last dragged the panel to, else `compact`. */
|
||||||
|
function lastDraggedChanges(): ChangesSize {
|
||||||
|
try {
|
||||||
|
const n = Number(window.localStorage.getItem(CHANGES_DRAGGED_KEY) || NaN);
|
||||||
|
return Number.isFinite(n) ? clampChanges(n) : "compact";
|
||||||
|
} catch {
|
||||||
|
return "compact";
|
||||||
|
}
|
||||||
|
}
|
||||||
/** The panel's own minimum (`min-w-[18rem]`), in px. */
|
/** The panel's own minimum (`min-w-[18rem]`), in px. */
|
||||||
const CHANGES_MIN_PX = 288;
|
const CHANGES_MIN_PX = 288;
|
||||||
|
|
||||||
@@ -502,6 +500,7 @@ function ConsoleArea({
|
|||||||
onRunExit={() => ended(slot)}
|
onRunExit={() => ended(slot)}
|
||||||
// Only the focused pane takes the keyboard when it appears.
|
// Only the focused pane takes the keyboard when it appears.
|
||||||
autoFocus={slot === focusSlot}
|
autoFocus={slot === focusSlot}
|
||||||
|
solo={shown.length === 1}
|
||||||
attachRequest={
|
attachRequest={
|
||||||
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
attachRequest && slotOf(attachRequest.runId) === slot ? attachRequest : null
|
||||||
}
|
}
|
||||||
@@ -646,17 +645,14 @@ export function Workspace() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
|
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
|
||||||
|
if (typeof changesSize === "number")
|
||||||
|
window.localStorage.setItem(CHANGES_DRAGGED_KEY, String(changesSize));
|
||||||
} catch {
|
} catch {
|
||||||
/* same as above: a forgotten width, not an error */
|
/* same as above: a forgotten width, not an error */
|
||||||
}
|
}
|
||||||
}, [changesSize]);
|
}, [changesSize]);
|
||||||
const changesFull = changesSize === "full";
|
const changesFull = changesSize === "full";
|
||||||
const changesPreset = changesStep(changesSize);
|
const changesSizeLabel = changesFull ? tLanes("changes.restore") : tLanes("changes.size.full");
|
||||||
const nextChangesSize =
|
|
||||||
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
|
|
||||||
const changesNextLabel = changesFull
|
|
||||||
? tLanes("changes.collapse")
|
|
||||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
|
||||||
const changesRowRef = useRef<HTMLDivElement>(null);
|
const changesRowRef = useRef<HTMLDivElement>(null);
|
||||||
// The row's width: the panel never gets narrower than its 18rem minimum,
|
// The row's width: the panel never gets narrower than its 18rem minimum,
|
||||||
// so the splitter is drawn — and dragged — no further than that edge.
|
// so the splitter is drawn — and dragged — no further than that edge.
|
||||||
@@ -713,16 +709,49 @@ export function Workspace() {
|
|||||||
? sessionParam
|
? sessionParam
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// The workspace's extra folders: each is its own repo the Changes panel can
|
||||||
|
// show instead (`?source=`), picked in its header. Read per workspace, and
|
||||||
|
// again from the home's Folder list whenever it adds or removes one.
|
||||||
|
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const takeGitFolders = useCallback(
|
||||||
|
(laneId: number, view: LaneSourcesView) =>
|
||||||
|
setGitFolders({
|
||||||
|
laneId,
|
||||||
|
items: view.sources.filter((s) => s.kind === "folder" && !s.missing),
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedLaneId === null) return;
|
||||||
|
api.lanes.sources(selectedLaneId).then(
|
||||||
|
(v) => takeGitFolders(selectedLaneId, v),
|
||||||
|
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
||||||
|
);
|
||||||
|
}, [selectedLaneId, takeGitFolders]);
|
||||||
|
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
||||||
|
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
||||||
|
// A pick holds for its own workspace, while that folder is still listed.
|
||||||
|
const gitSource =
|
||||||
|
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
||||||
|
? gitSourcePick.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
// An unsaved whole-file edit in the Changes panel. While it is open the
|
||||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
// panel stays on the tree it was opened in — moving it (another workspace,
|
||||||
// session or worktree) would remount it and drop the edit — until the user
|
// session, worktree or folder) would remount it and drop the edit — until
|
||||||
// says to discard it, or saves.
|
// the user says to discard it, or saves.
|
||||||
const [changesDirty, setChangesDirty] = useState(false);
|
const [changesDirty, setChangesDirty] = useState(false);
|
||||||
const wantPanel =
|
const wantPanel =
|
||||||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
selectedLaneId === null
|
||||||
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
|
? null
|
||||||
|
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
||||||
|
const keyOf = (p: typeof wantPanel) =>
|
||||||
|
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
||||||
|
const wantPanelKey = keyOf(wantPanel);
|
||||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||||
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
const panelKey = keyOf(panelAt);
|
||||||
const asking = useRef(false);
|
const asking = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wantPanelKey === panelKey) return;
|
if (wantPanelKey === panelKey) return;
|
||||||
@@ -740,6 +769,33 @@ export function Workspace() {
|
|||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||||
}, [wantPanelKey, panelKey, changesDirty]);
|
}, [wantPanelKey, panelKey, changesDirty]);
|
||||||
|
// A chat's "Edited N files" card asks for a file: open the panel on it, if
|
||||||
|
// the panel shows that workspace.
|
||||||
|
const [changesRequest, setChangesRequest] = useState<{ path: string; nonce: number } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const panelLaneRef = useRef<number | null>(null);
|
||||||
|
panelLaneRef.current = panelAt?.laneId ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpen = (e: Event) => {
|
||||||
|
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||||
|
if (laneId !== panelLaneRef.current) return;
|
||||||
|
// Its path is the workspace folder's: back from an extra folder first.
|
||||||
|
setGitSourcePick(null);
|
||||||
|
setChangesOpen(true);
|
||||||
|
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||||
|
};
|
||||||
|
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||||
|
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||||
|
}, []);
|
||||||
|
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!changesOpen) setChangesRequest(null);
|
||||||
|
}, [changesOpen]);
|
||||||
|
// Not on a folder pick: the request above makes one, to land in the panel
|
||||||
|
// it remounts; the picker drops the request itself.
|
||||||
|
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||||
|
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
||||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||||
const afterDrop = (fn: () => void) => {
|
const afterDrop = (fn: () => void) => {
|
||||||
if (!changesDirty) return fn();
|
if (!changesDirty) return fn();
|
||||||
@@ -958,41 +1014,9 @@ export function Workspace() {
|
|||||||
: "space-y-5"
|
: "space-y-5"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Workspaces are picked in the sidebar's project tree; this page shows
|
{/* Workspaces are picked — and added, with the + — in the sidebar's
|
||||||
the selected one. With none at all, one banner offers to create it. */}
|
project tree; this page shows the selected one. */}
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
{lanesLoaded && lanes.length === 0 && (
|
|
||||||
/* With no lanes the strip was a dashed tile beside a stray sentence,
|
|
||||||
which reads as a half-rendered row rather than a state. One banner
|
|
||||||
instead — and a compact one on purpose: the run setup form below
|
|
||||||
is what the user actually needs the height for. */
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="lane-empty"
|
|
||||||
onClick={() => setAddLaneOpen(true)}
|
|
||||||
className="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted">
|
|
||||||
<Layers className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<span className="block text-sm font-medium text-fg-primary">
|
|
||||||
{tLanes("empty.title")}
|
|
||||||
</span>
|
|
||||||
<p className="mt-0.5 text-xs text-fg-muted">
|
|
||||||
{tLanes("empty.body")}{" "}
|
|
||||||
<code className="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary">
|
|
||||||
agenthub workspace add --cwd $(pwd)
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
{tLanes("add")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* The selected lane's pipeline — the thing you actually come to this
|
{/* The selected lane's pipeline — the thing you actually come to this
|
||||||
page to read, beside the list rather than stacked under it.
|
page to read, beside the list rather than stacked under it.
|
||||||
The section itself is ALWAYS rendered, even with no lane selected:
|
The section itself is ALWAYS rendered, even with no lane selected:
|
||||||
@@ -1011,6 +1035,7 @@ export function Workspace() {
|
|||||||
<WorkspaceHome
|
<WorkspaceHome
|
||||||
lane={currentLane}
|
lane={currentLane}
|
||||||
onRenamed={() => void refreshLanes()}
|
onRenamed={() => void refreshLanes()}
|
||||||
|
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||||
onMenuAction={(action) => {
|
onMenuAction={(action) => {
|
||||||
if (action === "new-here") {
|
if (action === "new-here") {
|
||||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||||
@@ -1232,7 +1257,7 @@ export function Workspace() {
|
|||||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||||
>
|
>
|
||||||
{/* Header: the title, Analyst, then the resize control — the
|
{/* Header: the title, Analyst, then Full screen and Collapse — the
|
||||||
same height as the console's tab bar, so the two read as
|
same height as the console's tab bar, so the two read as
|
||||||
one header strip. Files and Changes are ONE panel now; its
|
one header strip. Files and Changes are ONE panel now; its
|
||||||
own mode switcher moves between them. */}
|
own mode switcher moves between them. */}
|
||||||
@@ -1243,6 +1268,34 @@ export function Workspace() {
|
|||||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||||
{tLanes("panel.changes")}
|
{tLanes("panel.changes")}
|
||||||
</span>
|
</span>
|
||||||
|
{/* Which repo the panel shows: the workspace's folder or
|
||||||
|
one of its extra folders. Only when it has any. */}
|
||||||
|
{folders.length > 0 && (
|
||||||
|
<select
|
||||||
|
data-testid="panel-git-folder"
|
||||||
|
value={gitSource ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChangesRequest(null);
|
||||||
|
setGitSourcePick(
|
||||||
|
e.target.value
|
||||||
|
? { laneId: currentLane.id, id: Number(e.target.value) }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
aria-label={tLanes("panel.folder")}
|
||||||
|
title={tLanes("panel.folder")}
|
||||||
|
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
|
||||||
|
</option>
|
||||||
|
{folders.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
{/* The opened session's analysis page — a page of its own. */}
|
{/* The opened session's analysis page — a page of its own. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1258,37 +1311,42 @@ export function Workspace() {
|
|||||||
>
|
>
|
||||||
<ChartColumn className="h-4 w-4" />
|
<ChartColumn className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{/* A window's two controls: Full screen (Restore at full
|
||||||
|
screen, to the last dragged width) and Collapse. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-changes-resize"
|
data-testid="lane-changes-resize"
|
||||||
onClick={() => {
|
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
|
||||||
// Past the widest step the next state is "gone", which
|
aria-label={changesSizeLabel}
|
||||||
// is what the rail button reopens at `compact` — and
|
title={changesSizeLabel}
|
||||||
// what drops an unsaved edit, so that asks first.
|
|
||||||
if (changesFull) {
|
|
||||||
afterDrop(() => {
|
|
||||||
setChangesOpen(false);
|
|
||||||
setChangesSize("compact");
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setChangesSize(nextChangesSize);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
// The control names the same region the rail button does,
|
|
||||||
// so a screen reader reads one panel that is open or
|
|
||||||
// closed rather than two unrelated buttons.
|
|
||||||
aria-expanded
|
|
||||||
aria-controls="lane-changes-panel"
|
|
||||||
aria-label={changesNextLabel}
|
|
||||||
title={changesNextLabel}
|
|
||||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
{/* Icon-only, and it draws the width the click LANDS on,
|
{changesFull ? (
|
||||||
not the current one — the label the tooltip carries. */}
|
<Minimize2 className="h-4 w-4" />
|
||||||
{(() => {
|
) : (
|
||||||
const Next = CHANGES_NEXT_ICON[changesPreset];
|
<Maximize2 className="h-4 w-4" />
|
||||||
return <Next className="h-4 w-4" />;
|
)}
|
||||||
})()}
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-changes-collapse"
|
||||||
|
// Closing drops an unsaved edit, so that asks first; it
|
||||||
|
// reopens from the rail at the last dragged width.
|
||||||
|
onClick={() =>
|
||||||
|
afterDrop(() => {
|
||||||
|
setChangesOpen(false);
|
||||||
|
setChangesSize(lastDraggedChanges());
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// The same region the rail button names, so a screen
|
||||||
|
// reader reads one panel that is open or closed.
|
||||||
|
aria-expanded
|
||||||
|
aria-controls="lane-changes-panel"
|
||||||
|
aria-label={tLanes("changes.collapse")}
|
||||||
|
title={tLanes("changes.collapse")}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<PanelRightClose className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||||
@@ -1300,9 +1358,11 @@ export function Workspace() {
|
|||||||
key={panelKey}
|
key={panelKey}
|
||||||
laneId={panelAt.laneId}
|
laneId={panelAt.laneId}
|
||||||
session={panelAt.session}
|
session={panelAt.session}
|
||||||
|
source={panelAt.source}
|
||||||
full={changesFull}
|
full={changesFull}
|
||||||
wide={changesPreset !== "compact"}
|
wide={changesRatio(changesSize) >= 0.45}
|
||||||
onDirtyChange={setChangesDirty}
|
onDirtyChange={setChangesDirty}
|
||||||
|
openRequest={panelAt.source ? null : changesRequest}
|
||||||
// A sibling worktree forks from the lane's SOURCE repo when
|
// A sibling worktree forks from the lane's SOURCE repo when
|
||||||
// it has one (this lane is itself a worktree of it); an
|
// it has one (this lane is itself a worktree of it); an
|
||||||
// adopted lane is its own repo.
|
// adopted lane is its own repo.
|
||||||
@@ -1341,6 +1401,7 @@ export function Workspace() {
|
|||||||
<LaneGitRailCounts
|
<LaneGitRailCounts
|
||||||
laneId={panelAt.laneId}
|
laneId={panelAt.laneId}
|
||||||
session={panelAt.session}
|
session={panelAt.session}
|
||||||
|
source={panelAt.source}
|
||||||
onShowAll={() => setChangesOpen(true)}
|
onShowAll={() => setChangesOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file Workspace.changesPanel.test.tsx
|
* @file Workspace.changesPanel.test.tsx
|
||||||
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
|
* @description The Changes panel has two controls: Full screen (Restore at
|
||||||
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
|
* full screen, back to the last dragged width) and Collapse; the rail reopens
|
||||||
|
* it at the last dragged width, else 25/75. The width survives a reload.
|
||||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||||
* lane strip's order.
|
* lane strip's order.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -82,7 +83,22 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
pipelines: r({ pipelines: [] }),
|
pipelines: r({ pipelines: [] }),
|
||||||
git: r({ available: false }),
|
git: r({ available: false }),
|
||||||
|
// Workspace 1 has one extra folder, its own repo (`?source=`).
|
||||||
|
sources: vi.fn((id: number) =>
|
||||||
|
Promise.resolve({
|
||||||
|
sources:
|
||||||
|
id === 1
|
||||||
|
? [
|
||||||
|
{ id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false },
|
||||||
|
{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false },
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
})
|
||||||
|
),
|
||||||
gitTree: r({ entries: [] }),
|
gitTree: r({ entries: [] }),
|
||||||
|
// A file opened in the panel on a chat's request.
|
||||||
|
changes: r({ available: false }),
|
||||||
|
gitFile: r({ available: false }),
|
||||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||||
@@ -104,6 +120,8 @@ vi.mock("../../lib/eventBus", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { Workspace } from "../Workspace";
|
import { Workspace } from "../Workspace";
|
||||||
|
import { openInChanges } from "../../lib/openInChanges";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
class ObserverStub {
|
class ObserverStub {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -150,43 +168,115 @@ describe("Workspace — the Changes panel's width", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
|
it("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => {
|
||||||
await openPanel();
|
await openPanel();
|
||||||
const card = () => screen.getByTestId("lane-changes-card");
|
const card = () => screen.getByTestId("lane-changes-card");
|
||||||
expect(card().className).toContain("w-[30%]");
|
const size = () => screen.getByTestId("lane-changes-resize");
|
||||||
|
expect(card().className).toContain("w-[25%]");
|
||||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
||||||
expect(card().className).toContain("w-1/2");
|
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||||
|
|
||||||
// Full width HIDES the console beside it — hidden, still mounted.
|
// Full width HIDES the console beside it — hidden, still mounted.
|
||||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
fireEvent.click(size());
|
||||||
expect(card().className).toContain("w-full");
|
expect(card().className).toContain("w-full");
|
||||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
||||||
|
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
||||||
|
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||||
|
|
||||||
// Past the widest step the panel closes — and reopens at 30/70, not back
|
// Restore brings the console back beside it.
|
||||||
// at full width, which is why closing resets the width.
|
fireEvent.click(size());
|
||||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
expect(card().className).toContain("w-[25%]");
|
||||||
|
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
|
||||||
|
|
||||||
|
// Collapsing from full screen reopens at 25/75, not back at full width.
|
||||||
|
fireEvent.click(size());
|
||||||
|
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||||
await settle();
|
await settle();
|
||||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||||
await settle();
|
await settle();
|
||||||
expect(card().className).toContain("w-[30%]");
|
expect(card().className).toContain("w-[25%]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remembers the width across a reload, and never asks the server", async () => {
|
it("remembers the width across a reload, and never asks the server", async () => {
|
||||||
await openPanel();
|
await openPanel();
|
||||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
|
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("full");
|
||||||
|
|
||||||
cleanup();
|
cleanup();
|
||||||
await openPanel();
|
await openPanel();
|
||||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
|
expect(screen.getByTestId("lane-changes-card").className).toContain("w-full");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the rail as the only way back in", async () => {
|
it("restores, and reopens, at the width last dragged to, not the preset", async () => {
|
||||||
|
window.localStorage.setItem("agenthub.workspace.changesSize", "full");
|
||||||
|
window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4");
|
||||||
await openPanel();
|
await openPanel();
|
||||||
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
|
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||||
|
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||||
|
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||||
|
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||||
|
await settle();
|
||||||
|
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||||
|
await settle();
|
||||||
|
expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens on a chat's request for a file of the workspace it shows, not another's", async () => {
|
||||||
|
window.localStorage.setItem("agenthub.workspace.changesOpen", "0");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=1"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||||
|
act(() => {
|
||||||
|
openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts");
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||||
|
act(() => {
|
||||||
|
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts");
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
|
||||||
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an extra folder's repo when picked, and only a workspace's own folders", async () => {
|
||||||
|
await openPanel();
|
||||||
|
const pick = screen.getByTestId("panel-git-folder") as HTMLSelectElement;
|
||||||
|
// The workspace folder, then its folders — never a context file.
|
||||||
|
expect([...pick.options].map((o) => o.text)).toEqual(["alpha", "shop"]);
|
||||||
|
vi.mocked(api.lanes.git).mockClear();
|
||||||
|
fireEvent.change(pick, { target: { value: "7" } });
|
||||||
|
await settle();
|
||||||
|
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||||
|
// A chat's file is the workspace folder's: the panel goes back there.
|
||||||
|
act(() => {
|
||||||
|
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(pick.value).toBe("");
|
||||||
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no folder picker for a workspace with no extra folders", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
|
||||||
|
<Workspace />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByTestId("panel-git-folder")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses from its narrow width too, back to the rail", async () => {
|
||||||
|
await openPanel();
|
||||||
|
fireEvent.click(screen.getByTestId("lane-changes-collapse"));
|
||||||
|
await settle();
|
||||||
|
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||||
|
expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||||
lanes: {
|
lanes: {
|
||||||
|
// The Changes panel's folder picker reads the workspace's extra folders.
|
||||||
|
sources: vi.fn().mockResolvedValue({ sources: [] }),
|
||||||
list: vi.fn().mockImplementation(async () => {
|
list: vi.fn().mockImplementation(async () => {
|
||||||
recordCall("GET", "/api/lanes");
|
recordCall("GET", "/api/lanes");
|
||||||
return { lanes: lanesToReturn, counts: countsToReturn };
|
return { lanes: lanesToReturn, counts: countsToReturn };
|
||||||
@@ -434,15 +436,10 @@ describe("Workspace page — lane integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Workspace layout", () => {
|
describe("Workspace layout", () => {
|
||||||
it("opens the add-workspace modal from the empty banner when there is none", async () => {
|
it("shows no empty banner when there is no workspace (the sidebar's + adds one)", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
lanesToReturn = [];
|
lanesToReturn = [];
|
||||||
await renderWorkspace();
|
await renderWorkspace();
|
||||||
|
expect(screen.queryByTestId("lane-empty")).toBeNull();
|
||||||
await user.click(screen.getByTestId("lane-empty"));
|
|
||||||
expect(
|
|
||||||
await screen.findByText("Create a workspace from a working directory")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
||||||
|
|||||||
@@ -5563,82 +5563,6 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
class="card flex w-full shrink-0 flex-wrap items-center gap-4 border-dashed px-5 py-4 text-left transition-colors hover:border-accent/50 hover:bg-surface-2"
|
|
||||||
data-testid="lane-empty"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-surface-4 text-fg-muted"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-layers h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="min-w-0 flex-1"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="block text-sm font-medium text-fg-primary"
|
|
||||||
>
|
|
||||||
No workspaces yet
|
|
||||||
</span>
|
|
||||||
<p
|
|
||||||
class="mt-0.5 text-xs text-fg-muted"
|
|
||||||
>
|
|
||||||
A workspace is one working directory the dashboard follows — its pipeline, sessions and terminal. Click anywhere on this card to add one, or from a terminal:
|
|
||||||
|
|
||||||
<code
|
|
||||||
class="rounded bg-surface-2 px-1.5 py-0.5 text-[11px] text-fg-secondary"
|
|
||||||
>
|
|
||||||
agenthub workspace add --cwd $(pwd)
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="flex shrink-0 items-center gap-1.5 text-xs font-medium text-accent"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="lucide lucide-plus h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
height="24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M5 12h14"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 5v14"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Add workspace
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
class="relative flex min-h-0 flex-1"
|
class="relative flex min-h-0 flex-1"
|
||||||
>
|
>
|
||||||
@@ -5662,6 +5586,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="absolute flex flex-col"
|
class="absolute flex flex-col"
|
||||||
data-focused="true"
|
data-focused="true"
|
||||||
|
data-menu-bounds="true"
|
||||||
data-testid="pane-0"
|
data-testid="pane-0"
|
||||||
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
style="left: 0%; top: 0%; width: 100%; height: 100%;"
|
||||||
>
|
>
|
||||||
@@ -7249,7 +7174,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.5.13 (rebuild client to match)
|
UI build v2.6.0 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export default {
|
|||||||
},
|
},
|
||||||
backgroundColor: tinted,
|
backgroundColor: tinted,
|
||||||
borderColor: tinted,
|
borderColor: tinted,
|
||||||
|
// Text a step heavier than stock: regular (and `font-normal`) is 500.
|
||||||
|
fontWeight: { normal: "500" },
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
sans: ["Inter", "-apple-system", "BlinkMacSystemFont", "Segoe UI", "sans-serif"],
|
||||||
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
mono: ["JetBrains Mono", "Fira Code", "Consolas", "monospace"],
|
||||||
|
|||||||
+44
-1
@@ -694,6 +694,20 @@ folder, and `git worktree list` of the workspace's repo must register it —
|
|||||||
otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
|
otherwise `400 ENOWORKTREE`, never a fall back to the main folder. With it,
|
||||||
`GET /git` also answers `worktree: {path, branch}`.
|
`GET /git` also answers `worktree: {path, branch}`.
|
||||||
|
|
||||||
|
#### An extra folder's own repo (`?source=`)
|
||||||
|
|
||||||
|
Every `/api/lanes/:id/git…` route also takes `?source=<id>`: the read or write
|
||||||
|
then works in one of the workspace's extra folders (Context → Folder), each
|
||||||
|
its own repo — the Changes panel's folder picker. The tree is resolved from the
|
||||||
|
`lane_sources` ROW (`sourceFolder` in `server/lib/lane-sources.js`): a `folder`
|
||||||
|
row of THIS workspace that is still a directory — otherwise `404 ENOSOURCE`
|
||||||
|
(another workspace's row and a context-file row included), never a fall back
|
||||||
|
to the main folder. The folder is its own boundary: every guard above
|
||||||
|
(`resolvePaths`, the secret check, the `confirm: true` gates, `ESUBDIR` for a
|
||||||
|
folder below its repo root) applies to it as to a workspace folder, and
|
||||||
|
`GET /git` reports `base` as that repo's own default branch. A folder that is
|
||||||
|
not a git repo answers `{available: false}`.
|
||||||
|
|
||||||
#### Remove a session's worktree
|
#### Remove a session's worktree
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -1551,6 +1565,32 @@ curl http://localhost:4820/api/sessions/sess_abc123
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### Get Session Outline
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sessions/:id/outline
|
||||||
|
```
|
||||||
|
|
||||||
|
The session's plan, the files it changed and what it used, read off its successful tool calls (`PostToolUse` events) and its prompts (`UserPromptSubmit`) — the activity panel of a Workspace Chat pane shown alone. `progress` follows `TaskCreate`/`TaskUpdate` by task id (a `TodoWrite` replaces the list wholesale); `outputs` are the paths written or edited with `Write`/`Edit`/`MultiEdit`/`NotebookEdit`; `files` the paths it only `Read` (a file it later changed moves to `outputs`); `uploads` the files attached to its prompts through the composer (paths under the `agenthub-drops-<uid>` dir that `POST /api/run/drop-image|drop-file` made); `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote, kept out of the other lists. The path lists are newest first. `skills` are `Skill` calls; `mcp` are MCP server names (`mcp__<server>__<tool>`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:4820/api/sessions/sess_abc123/outline
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
|
||||||
|
"outputs": ["/home/me/work/Progress report.xlsx"],
|
||||||
|
"files": ["/home/me/work/data/kpi.csv"],
|
||||||
|
"uploads": ["/tmp/agenthub-drops-1000/3f9a0c1d2e4b5a6c-minutes.xlsx"],
|
||||||
|
"memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
|
||||||
|
"skills": ["xlsx"],
|
||||||
|
"mcp": ["vercel_vercel"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### Get Session Stats
|
#### Get Session Stats
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -2540,9 +2580,12 @@ GET /api/run/tmux { available: bool } — whether tmux is on
|
|||||||
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
||||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||||
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
||||||
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
||||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
||||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
|
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal, the prompt box and its paste use for every dropped file or image
|
||||||
|
GET /api/run/drop/:name A dropped png/jpg/gif/webp served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked); only a name drop-image/drop-file made → 404 otherwise
|
||||||
|
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||||
|
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
|
||||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
|
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
|
||||||
GET /api/run/:id Run handle (returns live run state)
|
GET /api/run/:id Run handle (returns live run state)
|
||||||
|
|||||||
+13
-14
File diff suppressed because one or more lines are too long
+4
-2
@@ -85,8 +85,10 @@ Internal build — all rights reserved.
|
|||||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||||
It also picks the workspace terminals' font: JetBrains Mono (default), the
|
It also picks the workspace terminals' font: JetBrains Mono (default), the
|
||||||
rounded Maple Mono, or the browser's own fixed-width font (which may lack some
|
rounded Maple Mono, or the browser's own fixed-width
|
||||||
Vietnamese letters). Per-browser only.
|
font (which may lack some Vietnamese letters). Per-browser only. Text across
|
||||||
|
the UI and the terminals is one step heavier than stock (regular 500, bold
|
||||||
|
700), and the Chat UI default sits at the top of the page.
|
||||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||||
Pop, Alert — off by default) played with every notification shown, and each
|
Pop, Alert — off by default) played with every notification shown, and each
|
||||||
notification type can pick its own.
|
notification type can pick its own.
|
||||||
|
|||||||
+8
-1
@@ -113,7 +113,11 @@ check; the AI proxy page shows them only their personal profiles' Edit. An
|
|||||||
analyst's Workspace page and sidebar list only
|
analyst's Workspace page and sidebar list only
|
||||||
their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
|
their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
|
||||||
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
|
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
|
||||||
hidden for a session that is not theirs. An analyst's alert feed
|
hidden for a session that is not theirs. The sidebar's workspace list shows an
|
||||||
|
admin or analyst EVERY workspace, grouped under its owner (each lane carries
|
||||||
|
`owner_name`; yours first, no owner last, each group folds): for an analyst
|
||||||
|
another user's workspace there is read only — no menu, rename or new session,
|
||||||
|
no live tabs, and a session opens its analysis page (`/sessions/<id>`). An analyst's alert feed
|
||||||
(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
|
(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
|
||||||
sessions' — and the feed offers Acknowledge only there; Ack all acknowledges
|
sessions' — and the feed offers Acknowledge only there; Ack all acknowledges
|
||||||
only theirs.
|
only theirs.
|
||||||
@@ -200,6 +204,9 @@ user makes the folder (it is the Linux account's home).
|
|||||||
under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the
|
under `/api` answers `401 EAUTH` until someone logs in, and the UI shows the
|
||||||
login page. Paths are checked the way Express routes them: decoded, with
|
login page. Paths are checked the way Express routes them: decoded, with
|
||||||
repeated slashes collapsed, and case-insensitive.
|
repeated slashes collapsed, and case-insensitive.
|
||||||
|
- The login page follows the OS's light/dark. After a login the dashboard
|
||||||
|
wears that account's own appearance (Settings → Appearance, saved in
|
||||||
|
`/api/me/prefs`); an account that never chose one follows the OS.
|
||||||
- Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket
|
- Hooks (`/api/hooks/*`) are accepted only from this machine: a loopback socket
|
||||||
with no forwarding header. A request through the proxy gets
|
with no forwarding header. A request through the proxy gets
|
||||||
`403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token.
|
`403 ELOCALONLY`. `POST /api/hooks/ingest-batch` keeps its own bearer token.
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
|
|||||||
3. Pet: none or an installed pet, and its size.
|
3. Pet: none or an installed pet, and its size.
|
||||||
4. Notifications: enable, sound and preview, send a test.
|
4. Notifications: enable, sound and preview, send a test.
|
||||||
|
|
||||||
The footer has Skip, Back and Continue (Ctrl+Enter). The last step's button is "Add your first workspace", which starts the tour.
|
The footer has Skip, Back and Continue (Ctrl+Enter). The last step's button is "Add your first workspace", which starts the tour on the Dashboard (`/`).
|
||||||
- [x] `components/onboarding/Tour.tsx`: a spotlight on `data-tour` targets. Each step moves on when its condition holds, and the step reached is saved in prefs (`agenthub.tour`). The steps:
|
- [x] `components/onboarding/Tour.tsx`: a spotlight on `data-tour` targets. Each step moves on when its condition holds, and the step reached is saved in prefs (`agenthub.tour`). The steps:
|
||||||
- Add workspace
|
- Add workspace: the sidebar's **+** (the rail's tree icon while the sidebar is folded)
|
||||||
- the folder field
|
- the folder field (its suggestions list included while it is open) — or the folder browser while it is open over it
|
||||||
- Create
|
- Create
|
||||||
- the prompt box
|
- the prompt box
|
||||||
- sending the prompt
|
- sending the prompt
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Remote clients — work runs on each user's machine, the hub only manages
|
||||||
|
|
||||||
|
Status: **PLAN, not built.** Decided 2026-10-09. Branch `server-s3` (one Linux
|
||||||
|
user per account on the hub) stays as it is until this plan is proven or dropped.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Everyone logging into one machine (server mode, S3) does not scale past ~10
|
||||||
|
concurrent users: every pane, build and test runs on the hub. Here the compute
|
||||||
|
moves to each user's machine; the hub keeps identity, the shared accounts, and
|
||||||
|
every workspace/session/conversation/token record.
|
||||||
|
|
||||||
|
## Decisions (user, 2026-10-09)
|
||||||
|
|
||||||
|
1. Each user installs **AgentHub local** on their own machine.
|
||||||
|
2. The **full conversation** is uploaded to the hub — only for sessions started
|
||||||
|
through AgentHub.
|
||||||
|
3. Shared accounts (the Claude pool, shared AI Proxies) are usable **only through
|
||||||
|
AgentHub**. The company has at least as many seats as people; a seat may be
|
||||||
|
used by more than one person.
|
||||||
|
4. Use without AgentHub is **detected and warned about**, and **blocked when an
|
||||||
|
admin turns blocking on**.
|
||||||
|
5. The hub is reached over the company LAN at `http://10.1.13.7:4820` — **no
|
||||||
|
HTTPS**.
|
||||||
|
6. An admin sees, in one click from the Users page, which machines a user has,
|
||||||
|
which workspaces they used, and what each session did.
|
||||||
|
7. `server-s3` is kept until this architecture is settled.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
```
|
||||||
|
user machine — AgentHub LOCAL hub 10.1.13.7 — AgentHub SERVER mode
|
||||||
|
┌───────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ pane: claude │─hooks +▶│ /api/remote/ingest (device token)│
|
||||||
|
│ ANTHROPIC_BASE_URL = hub gateway│ transcr.│ → sessions/events/tokens, owned │
|
||||||
|
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ by the device's user │
|
||||||
|
│ │─/v1/… ─▶│ /api/proxy-gw → pool token added │
|
||||||
|
└───────────────────────────────────┘ └──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
No pool token or proxy key ever reaches a user's machine; it holds one device
|
||||||
|
token. Plain HTTP means anyone sniffing the LAN can read that token and the
|
||||||
|
conversations — accepted for an internal network. Moving to HTTPS later is a
|
||||||
|
reverse proxy in front of the hub (Caddy `tls internal`), no code change.
|
||||||
|
|
||||||
|
## What the hub can see (per tool, checked 2026-10-09)
|
||||||
|
|
||||||
|
Uploads cover every session the machine's AgentHub started, in ANY of its
|
||||||
|
workspaces — company repo or the user's own project alike. A session run
|
||||||
|
outside AgentHub on the user's own login is invisible; only pool use is gated.
|
||||||
|
|
||||||
|
| Tool | Conversation | Tokens / cost | Pool / gate | Reader today |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Claude Code | yes | yes | yes (R3) | hooks + transcript |
|
||||||
|
| Codex | yes | yes | not yet (Codex pool = later) | `codex-usage.js` rollouts |
|
||||||
|
| Antigravity | no — needs a renderer for `transcript_full.jsonl` | yes | no (own login) | `antigravity-usage.js` |
|
||||||
|
| Oh My Pi, other catalog tools | no | no | no | none: only the pane run (workspace, time) |
|
||||||
|
| DeepSeek CLI / not in catalog | no | no | no | not launchable as a tool; a shell pane records nothing |
|
||||||
|
|
||||||
|
R2 therefore uploads Claude transcripts, Codex rollouts and agy conversation
|
||||||
|
files (`remote-sync.js` today mirrors only the first two). Any tool beyond that
|
||||||
|
needs its own reader first — one per tool, on demand.
|
||||||
|
|
||||||
|
## Gating
|
||||||
|
|
||||||
|
Verified 2026-10-09 against Claude Code 2.1.295 with a capture server: every
|
||||||
|
`/v1/messages` call carries `x-claude-code-session-id: <id>` (same id in
|
||||||
|
`metadata.user_id`), and `--session-id <uuid>` sets it — `spawnRun` already
|
||||||
|
starts Claude panes that way.
|
||||||
|
|
||||||
|
The gateway checks each request:
|
||||||
|
- bearer = a device token that is not revoked, of a user who is not disabled —
|
||||||
|
else **always 401**;
|
||||||
|
- `x-claude-code-session-id` = a session the hub received uploads for, from
|
||||||
|
THAT device, within `REMOTE_LIVE_SEC` (300 s; 60 s grace for a new session).
|
||||||
|
|
||||||
|
A request failing the second check is a session AgentHub is not uploading
|
||||||
|
(token copied out of AgentHub, or uploads switched off). Setting
|
||||||
|
`remote_gate` decides: `warn` (default) forwards it and records the session
|
||||||
|
as **unmonitored** with its token count from the response's `usage`, and
|
||||||
|
fires an alert (existing `fireAlert`); `block` refuses it with 403. Ceiling:
|
||||||
|
someone who also fakes the upload stream passes — detection, not proof.
|
||||||
|
|
||||||
|
Relay spike (2026-10-09, Claude Code 2.1.295, script kept in
|
||||||
|
`~/.cache/agenthub-spike/relay.js`): a client with NO login and
|
||||||
|
`ANTHROPIC_AUTH_TOKEN=<fake>` worked through a relay that swapped in a team
|
||||||
|
subscription's OAuth token (+ `anthropic-beta: oauth-2025-04-20`). Checked:
|
||||||
|
answers, `--resume`, a local Bash tool call, prompt cache reads on every turn
|
||||||
|
(18–26k tokens), `anthropic-ratelimit-unified-*` headers passed back. Three
|
||||||
|
findings for R3:
|
||||||
|
- Through a base URL Claude Code writes **5-minute** cache entries; set
|
||||||
|
`ENABLE_PROMPT_CACHING_1H=1` in the pane (verified: 1h entries) to keep the
|
||||||
|
subscription's 1-hour TTL.
|
||||||
|
- The cache is per ACCOUNT (a fresh client read 18k cached tokens on its first
|
||||||
|
call), so the gateway must keep a session on ONE pool account — switching
|
||||||
|
accounts mid-session re-writes the whole cache.
|
||||||
|
- A repo's `.claude/settings*.json` `env` overrides the pane's
|
||||||
|
`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (seen in this repo) — give the
|
||||||
|
gateway through `--settings=<file>`, the layer that outranks project settings,
|
||||||
|
as AI Proxy panes already do.
|
||||||
|
Not checked yet (interactive features that call claude.ai with the user's own
|
||||||
|
login, likely unavailable): `/usage` quota view, claude.ai MCP connectors,
|
||||||
|
Remote Control / web sessions, org plugins from claude.ai.
|
||||||
|
|
||||||
|
What is and is not guaranteed:
|
||||||
|
- **Guaranteed:** no hub account (or a disabled user / revoked device) → no
|
||||||
|
issued credential, because the credential never leaves the hub. This holds
|
||||||
|
only while the ADMIN alone holds each shared seat's login (setup-tokens
|
||||||
|
made by the admin). A seat whose login (email/SSO) an employee knows can
|
||||||
|
always be used directly at claude.ai or in a plain `claude` — AgentHub cannot
|
||||||
|
stop that.
|
||||||
|
- **Not guaranteed:** that the AgentHub APP was used. The device token sits on
|
||||||
|
a machine the user controls; `block` stops a plain `claude` using it, unless
|
||||||
|
the user also fakes the upload stream. Hard enforcement needs managed,
|
||||||
|
locked-down machines.
|
||||||
|
- **Closes the visibility gap (R3b):** the gateway sees every request body, so
|
||||||
|
it can record the conversation of every pool request ITSELF — new messages
|
||||||
|
per request by session id, plus the streamed answer and its `usage` — no
|
||||||
|
matter which client sent it. Then nothing that uses an issued credential is
|
||||||
|
invisible to the admin, AgentHub app or not.
|
||||||
|
- **Plain HTTP:** a LAN sniffer can take a device token and act as that user.
|
||||||
|
If that matters, HTTPS (reverse proxy + an internal CA every machine trusts,
|
||||||
|
`NODE_EXTRA_CA_CERTS` for Claude Code).
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
Each one ships alone and changes nothing for a local-only install.
|
||||||
|
|
||||||
|
**R0 — user activity view (hub, useful today).** Users page row → a user
|
||||||
|
page: their devices (once R1 exists), workspaces, and sessions by day with
|
||||||
|
tokens/cost; a session opens the existing session page (conversation, tool
|
||||||
|
calls). Backend: an `owner=<user id>` filter for staff on `GET /api/sessions`
|
||||||
|
and `GET /api/lanes`, reusing `sessionWhere`/`laneWhere` for that user — no
|
||||||
|
new query logic. Admin reads are audited as they already are.
|
||||||
|
Verify: server test for the filter (developer cannot use it); client snapshot.
|
||||||
|
|
||||||
|
**R1 — devices (hub + CLI).** Table `devices` (id, user_id, name, token_hash,
|
||||||
|
created_at, last_seen_at, revoked_at). `agenthub remote login
|
||||||
|
http://10.1.13.7:4820` signs in with the user's hub password (throttled like
|
||||||
|
`/api/auth/login`), receives an `ahdev_` token once, stores it 0600 in the
|
||||||
|
runtime dir. The user page lists devices; an admin revokes one (re-auth +
|
||||||
|
`audit_log`).
|
||||||
|
Verify: mint/hash/revoke tests; a revoked token gets 401.
|
||||||
|
|
||||||
|
**R2 — upload (both sides).** An enrolled local AgentHub uploads each of ITS
|
||||||
|
sessions (`dashboard_runs`): hook events as they arrive, the session files of
|
||||||
|
the table above (Claude transcript, Codex rollout, agy conversation)
|
||||||
|
incrementally (byte offset, retry queue on disk, never blocking a hook). The
|
||||||
|
hub stores transcripts under `<data dir>/remote/<user id>/<device id>/projects/`
|
||||||
|
and imports them with the SAME `importFromDirectory` `remote-sync.js` uses, so
|
||||||
|
tokens and cost match local sessions. Sessions are owned by the device's user
|
||||||
|
(`source = device:<id>`); workspaces are mirrored as records (title, folder,
|
||||||
|
tool) — shown, not driven. The existing `ingest-batch` stays as it is.
|
||||||
|
Verify: two local instances → one hub; R0's page shows both users' work.
|
||||||
|
|
||||||
|
**R3 — remote gateway.** The local `spawnRun` points an enrolled machine's
|
||||||
|
Claude pane at the hub (`ANTHROPIC_BASE_URL`, device token as
|
||||||
|
`ANTHROPIC_AUTH_TOKEN`, through tmux `-e`). `proxy-gateway.js` (from
|
||||||
|
origin/main since 84e2a9f0) gains the remote path and checks above; for the pool it picks an
|
||||||
|
account with `pickAccount` and sends its OAuth token. **Spike first** on ONE
|
||||||
|
real pool account: which headers a setup-token needs through a relay.
|
||||||
|
Verify: a pane gets "pong"; a curl with the device token and a session id
|
||||||
|
nobody uploads is logged (`warn`) or 403 (`block`).
|
||||||
|
|
||||||
|
**R4 — shared skills/plugins.** No AgentHub code: the company marketplace (git)
|
||||||
|
plus a `managed-settings.json` template (`extraKnownMarketplaces`,
|
||||||
|
`enabledPlugins`) and an install script for user machines.
|
||||||
|
|
||||||
|
Codex comes after R3 works for Claude: ChatGPT-account auth differs from an API
|
||||||
|
key, and its session-id header still has to be checked.
|
||||||
|
|
||||||
|
## Open risks
|
||||||
|
|
||||||
|
- A hub outage stops every pool session; a machine can still use its own login.
|
||||||
|
- The hub stores every conversation of an AgentHub session; tell users.
|
||||||
+77
-3
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.5.13
|
version: 2.6.0
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -504,12 +504,23 @@ components:
|
|||||||
description: Tool output text when type === tool_result.
|
description: Tool output text when type === tool_result.
|
||||||
is_error:
|
is_error:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
diff:
|
||||||
|
type: object
|
||||||
|
description: 'On the tool_result of a Write/Edit/MultiEdit: the lines it added and removed (Claude Code''s structuredPatch; every line of a file it created).'
|
||||||
|
properties:
|
||||||
|
added:
|
||||||
|
type: integer
|
||||||
|
removed:
|
||||||
|
type: integer
|
||||||
TranscriptMessage:
|
TranscriptMessage:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- type
|
- type
|
||||||
- content
|
- content
|
||||||
properties:
|
properties:
|
||||||
|
stop_reason:
|
||||||
|
type: string
|
||||||
|
description: 'Assistant lines only: why the model stopped — `end_turn` closes the turn, `tool_use` means a tool call (and more work) follows.'
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -5203,6 +5214,64 @@ paths:
|
|||||||
error:
|
error:
|
||||||
code: NOT_FOUND
|
code: NOT_FOUND
|
||||||
message: Session not found
|
message: Session not found
|
||||||
|
/api/sessions/{id}/outline:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Sessions
|
||||||
|
summary: Get a session's plan, outputs and what it used
|
||||||
|
description: 'Read off the session''s successful tool calls (PostToolUse events) and its prompts: `progress` is its TaskCreate/TaskUpdate plan (a TodoWrite replaces it wholesale), `outputs` the paths it wrote or edited with Write/Edit/MultiEdit/NotebookEdit, `files` the paths it only read, `uploads` the files attached to its prompts through the composer (`/api/run/drop-*` paths), `memory` the Claude Code memory files (`.claude/**/memory/`) it read or wrote — the path lists newest first, a memory file in `memory` only —, `skills` the skills it invoked and `mcp` the MCP servers it called. Powers the activity panel of a Chat pane shown alone.'
|
||||||
|
operationId: getSessionOutline
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/SessionIdPath'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The session's outline
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
progress:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
subject:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
outputs:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
files:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
uploads:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
memory:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
skills:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
mcp:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
'404':
|
||||||
|
description: Session not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/sessions/{id}/transcripts:
|
/api/sessions/{id}/transcripts:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -11735,7 +11804,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- Lanes
|
- Lanes
|
||||||
summary: Suggest a commit message for the staged changes
|
summary: Suggest a commit message for the staged changes
|
||||||
description: Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools "" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.
|
description: Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools "" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree, or `?source=` extra folder), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.
|
||||||
operationId: suggestLaneCommitMessage
|
operationId: suggestLaneCommitMessage
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
@@ -11748,6 +11817,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: source
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: The suggested message
|
description: The suggested message
|
||||||
@@ -11765,7 +11839,7 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
description: Cross-origin request refused
|
description: Cross-origin request refused
|
||||||
'404':
|
'404':
|
||||||
description: No such workspace
|
description: No such workspace, or ENOSOURCE
|
||||||
'502':
|
'502':
|
||||||
description: EAI — the claude CLI failed
|
description: EAI — the claude CLI failed
|
||||||
/api/lanes/{id}/git/{action}:
|
/api/lanes/{id}/git/{action}:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.13",
|
"version": "2.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.13",
|
"version": "2.6.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.13",
|
"version": "2.6.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -577,7 +577,9 @@ async function parseSubagentFile(filePath) {
|
|||||||
const metaPath = filePath.replace(/\.jsonl$/, ".meta.json");
|
const metaPath = filePath.replace(/\.jsonl$/, ".meta.json");
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(metaPath)) {
|
if (fs.existsSync(metaPath)) {
|
||||||
const meta = JSON.parse(require("../server/lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 }));
|
const meta = JSON.parse(
|
||||||
|
require("../server/lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })
|
||||||
|
);
|
||||||
if (meta.agentType) agentType = meta.agentType;
|
if (meta.agentType) agentType = meta.agentType;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -764,6 +764,9 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
|
|||||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<data dir>/drops/` (`0700`, never a guessable name in the shared tmp; S3: the member's own `~/.agenthub/handoff/drops/`) with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||||
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
|
| `POST` | `/api/run/drop-file?name=…` | Any file (or image) dropped on the Workspace terminal or pasted/dropped into a prompt box. Raw body of any type, ≤20 MB, same directory (S3: `?laneId=` saves it AS the account that workspace's panes run as, so the pane can read it), `0600` and day-old pruning as `drop-image`. The name is the file's own, reduced to a `[A-Za-z0-9._-]` basename behind a random 16-hex prefix (so it can neither leave the directory nor collide, and space-joined paths stay parseable). Answers `{ path }`; 400 for an empty body or a name that sanitizes to nothing. The client uses this for every drop; `drop-image` is kept for API callers |
|
||||||
|
| `GET` | `/api/run/drop/:name` | A dropped IMAGE served back, so a chat turn shows the picture instead of its path. Only a name `drop-image`/`drop-file` made (16-hex prefix, no separator) with a png/jpg/gif/webp extension, only a regular file in the REQUESTER's drop dir (S3: their member account's, owned by that account — an admin viewing a member's session gets the name chip, not the image); under `nosniff` and a `sandbox` CSP like `/git/raw`. 404 otherwise, and once the day-old prune removed it |
|
||||||
|
| `GET` | `/api/run/:id/permission-mode` | A live **claude** pane's permission mode, read off the mode line Claude Code prints under its prompt (`server/lib/pane-mode.js`): `{ mode }` — `default`, `acceptEdits`, `plan`, `auto`, `bypassPermissions`, `dontAsk`, or `null` while a menu hides it. 404 for a pane that is not running, 400 `EUNSUPPORTED` for a non-claude pane |
|
||||||
|
| `POST` | `/api/run/:id/permission-mode` | `{ mode }` → steps the pane there with Shift+Tab, the only way a running session changes mode, reading every step back before the next key. `{ mode }` on success; 400 `EBADMODE` outside the five settable modes; 409 `EMODEUNKNOWN` (no mode line — nothing is pressed — or a step did not change it), `EMODEUNAVAILABLE` (not in this session's cycle, e.g. Bypass in a session not started with it; the pane is back on its starting mode), `EBUSY` (a change is already running on that pane) |
|
||||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||||
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||||
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
||||||
|
|||||||
@@ -3035,3 +3035,88 @@ describe("Nested Agent Spawning", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/sessions/:id/outline", () => {
|
||||||
|
it("reads progress, outputs and context off the session's tool events", async () => {
|
||||||
|
const sid = "outline-" + Date.now();
|
||||||
|
const pre = (tool_name, tool_input, tool_response) =>
|
||||||
|
post("/api/hooks/event", {
|
||||||
|
hook_type: "PostToolUse",
|
||||||
|
data: { session_id: sid, tool_name, tool_input, tool_response },
|
||||||
|
});
|
||||||
|
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
|
||||||
|
await pre(
|
||||||
|
"TaskCreate",
|
||||||
|
{ subject: "Parse plan" },
|
||||||
|
{ task: { id: "1", subject: "Parse plan" } }
|
||||||
|
);
|
||||||
|
await pre(
|
||||||
|
"TaskCreate",
|
||||||
|
{ subject: "Parse plan" },
|
||||||
|
{ task: { id: "1", subject: "Parse plan" } }
|
||||||
|
);
|
||||||
|
await pre(
|
||||||
|
"TaskCreate",
|
||||||
|
{ subject: "Build report" },
|
||||||
|
{ task: { id: "2", subject: "Build report" } }
|
||||||
|
);
|
||||||
|
await pre("TaskUpdate", { taskId: "1", status: "completed" });
|
||||||
|
await pre("TaskUpdate", { taskId: "2", status: "in_progress" });
|
||||||
|
await pre("Write", { file_path: "/w/a.xlsx", content: "x" });
|
||||||
|
await pre("Write", { file_path: "/w/b.md", content: "x" });
|
||||||
|
await pre("Write", { file_path: "/w/a.xlsx", content: "y" });
|
||||||
|
await pre("Skill", { skill: "xlsx" });
|
||||||
|
await pre("Skill", { skill: "xlsx" });
|
||||||
|
await pre("mcp__plugin_vercel_vercel__list_projects", {});
|
||||||
|
|
||||||
|
const res = await fetch(`/api/sessions/${sid}/outline`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.deepEqual(res.body.progress, [
|
||||||
|
{ id: "1", subject: "Parse plan", status: "completed" },
|
||||||
|
{ id: "2", subject: "Build report", status: "in_progress" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(res.body.outputs, ["/w/a.xlsx", "/w/b.md"]);
|
||||||
|
assert.deepEqual(res.body.skills, ["xlsx"]);
|
||||||
|
assert.deepEqual(res.body.mcp, ["vercel_vercel"]);
|
||||||
|
|
||||||
|
// A TodoWrite replaces the list wholesale.
|
||||||
|
await pre("TodoWrite", { todos: [{ content: "Verify", status: "pending" }] });
|
||||||
|
const again = await fetch(`/api/sessions/${sid}/outline`);
|
||||||
|
assert.deepEqual(again.body.progress, [{ id: "0", subject: "Verify", status: "pending" }]);
|
||||||
|
|
||||||
|
assert.equal((await fetch("/api/sessions/nope-outline/outline")).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits what a session used (uploads, files, memory) from what it changed", async () => {
|
||||||
|
const sid = "outline-used-" + Date.now();
|
||||||
|
const pre = (tool_name, tool_input) =>
|
||||||
|
post("/api/hooks/event", {
|
||||||
|
hook_type: "PostToolUse",
|
||||||
|
data: { session_id: sid, tool_name, tool_input, tool_response: {} },
|
||||||
|
});
|
||||||
|
const drop = "/tmp/agenthub-drops-1000/0123456789abcdef-minutes.xlsx";
|
||||||
|
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
|
||||||
|
await post("/api/hooks/event", {
|
||||||
|
hook_type: "UserPromptSubmit",
|
||||||
|
data: {
|
||||||
|
session_id: sid,
|
||||||
|
prompt: `compare these ${drop}\n/tmp/agenthub-drops-1000/fedcba9876543210.png`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mem = "/home/u/.claude/projects/-w/memory/release.md";
|
||||||
|
await pre("Read", { file_path: drop });
|
||||||
|
await pre("Read", { file_path: "/w/src/a.ts" });
|
||||||
|
await pre("Read", { file_path: "/w/src/c.ts" });
|
||||||
|
await pre("Read", { file_path: mem });
|
||||||
|
await pre("Edit", { file_path: "/w/src/a.ts", old_string: "a", new_string: "b" });
|
||||||
|
await pre("MultiEdit", { file_path: "/w/src/b.ts", edits: [] });
|
||||||
|
await pre("Write", { file_path: mem, content: "x" });
|
||||||
|
|
||||||
|
const { body } = await fetch(`/api/sessions/${sid}/outline`);
|
||||||
|
assert.deepEqual(body.uploads, ["/tmp/agenthub-drops-1000/fedcba9876543210.png", drop]);
|
||||||
|
// a.ts was read, then edited: an output, no longer a file it only used.
|
||||||
|
assert.deepEqual(body.files, ["/w/src/c.ts"]);
|
||||||
|
assert.deepEqual(body.memory, [mem]);
|
||||||
|
assert.deepEqual(body.outputs, ["/w/src/b.ts", "/w/src/a.ts"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2017,6 +2017,70 @@ describe("a session's own worktree (claude --worktree)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("an extra folder's own repo (?source=)", () => {
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
const gitEnv = Object.fromEntries(
|
||||||
|
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
|
||||||
|
);
|
||||||
|
const gitIn = (dir, ...args) =>
|
||||||
|
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: gitEnv,
|
||||||
|
});
|
||||||
|
let ws, extra, laneId, otherLaneId, sourceId, fileSourceId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-src-git-")));
|
||||||
|
ws = path.join(root, "ws");
|
||||||
|
extra = path.join(root, "extra");
|
||||||
|
for (const dir of [ws, extra]) {
|
||||||
|
fs.mkdirSync(dir);
|
||||||
|
gitIn(dir, "init", "-q", "-b", "main");
|
||||||
|
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
|
||||||
|
gitIn(dir, "add", ".");
|
||||||
|
gitIn(dir, "commit", "-qm", "init");
|
||||||
|
}
|
||||||
|
laneId = (await request("POST", "/api/lanes", { cwd: ws })).body.lane.id;
|
||||||
|
otherLaneId = (await request("POST", "/api/lanes", { cwd: extra })).body.lane.id;
|
||||||
|
const src = require("../lib/lane-sources");
|
||||||
|
sourceId = src.addFolder(laneId, extra).id;
|
||||||
|
fileSourceId = require("../db")
|
||||||
|
.db.prepare(
|
||||||
|
"INSERT INTO lane_sources (lane_id, kind, path, name, added_at) VALUES (?, 'file', ?, 'f', '2026-10-09T00:00:00Z')"
|
||||||
|
)
|
||||||
|
.run(laneId, path.join(extra, "a.txt")).lastInsertRowid;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads and writes git in the extra folder, never falling back", async () => {
|
||||||
|
fs.writeFileSync(path.join(extra, "only-in-extra.txt"), "x\n");
|
||||||
|
const inExtra = await request("GET", `/api/lanes/${laneId}/git/changes?source=${sourceId}`);
|
||||||
|
assert.ok(inExtra.body.files.some((f) => f.path === "only-in-extra.txt"));
|
||||||
|
const facts = await request("GET", `/api/lanes/${laneId}/git?source=${sourceId}`);
|
||||||
|
assert.equal(facts.body.base, "main");
|
||||||
|
const main = await request("GET", `/api/lanes/${laneId}/git/changes`);
|
||||||
|
assert.ok(!main.body.files.some((f) => f.path === "only-in-extra.txt"));
|
||||||
|
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?source=${sourceId}`, {
|
||||||
|
paths: ["only-in-extra.txt"],
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.match(gitIn(extra, "diff", "--cached", "--name-only"), /only-in-extra\.txt/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers ENOSOURCE for a source that is not this workspace's folder", async () => {
|
||||||
|
for (const id of ["nope", "999999", String(fileSourceId)]) {
|
||||||
|
const r = await request("POST", `/api/lanes/${laneId}/git/stage?source=${id}`, {
|
||||||
|
paths: ["only-in-extra.txt"],
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 404, id);
|
||||||
|
assert.equal(r.body.error.code, "ENOSOURCE", id);
|
||||||
|
}
|
||||||
|
// Another workspace's request may not borrow this one's source id.
|
||||||
|
const r = await request("GET", `/api/lanes/${otherLaneId}/git/changes?source=${sourceId}`);
|
||||||
|
assert.equal(r.status, 404);
|
||||||
|
assert.equal(r.body.error.code, "ENOSOURCE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The server, not a stale client list, picks the tab and refuses a second pane
|
// The server, not a stale client list, picks the tab and refuses a second pane
|
||||||
// on a session one already holds.
|
// on a session one already holds.
|
||||||
describe("start: slot auto and a session already live", () => {
|
describe("start: slot auto and a session already live", () => {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file run.test.js
|
* @file run.test.js
|
||||||
* @description Route tests for the terminal-run feature: same-origin guard,
|
* @description Route tests for the terminal-run feature: same-origin guard,
|
||||||
* laneId/cwd validation, spawn/kill/list against a mocked tmux backend.
|
* laneId/cwd validation, spawn/kill/list against a mocked tmux backend, a
|
||||||
|
* dropped image served back, and a live pane's permission mode.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||||
@@ -55,7 +56,7 @@ function fetchJson(p, opts = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
parsed = raw;
|
parsed = raw;
|
||||||
}
|
}
|
||||||
resolve({ status: res.statusCode, body: parsed });
|
resolve({ status: res.statusCode, body: parsed, headers: res.headers });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -209,6 +210,27 @@ describe("/api/run", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/run/files", () => {
|
||||||
|
let srv;
|
||||||
|
const DIR = path.join(TMP, "files-proj");
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(path.join(DIR, "src", "lib"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(DIR, "src", "lib", "a.js"), "");
|
||||||
|
srv = http.createServer(createApp());
|
||||||
|
await new Promise((r) => srv.listen(0, r));
|
||||||
|
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => new Promise((r) => srv.close(r)));
|
||||||
|
const list = async (qs) =>
|
||||||
|
(await fetchJson(`/api/run/files?cwd=${encodeURIComponent(DIR)}${qs}`)).body.items;
|
||||||
|
|
||||||
|
it("lists files only, unless dirs=1 adds the folders with a trailing slash", async () => {
|
||||||
|
assert.deepEqual(await list(""), ["src/lib/a.js"]);
|
||||||
|
assert.deepEqual(await list("&dirs=1"), ["src/", "src/lib/", "src/lib/a.js"]);
|
||||||
|
assert.deepEqual(await list("&dirs=1&q=lib"), ["src/lib/", "src/lib/a.js"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("POST /api/run/drop-image", () => {
|
describe("POST /api/run/drop-image", () => {
|
||||||
let srv;
|
let srv;
|
||||||
before(async () => {
|
before(async () => {
|
||||||
@@ -274,3 +296,154 @@ describe("POST /api/run/drop-image", () => {
|
|||||||
assert.equal(r.status, 400);
|
assert.equal(r.status, 400);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/run/drop/:name", () => {
|
||||||
|
let srv;
|
||||||
|
before(async () => {
|
||||||
|
srv = http.createServer(createApp());
|
||||||
|
await new Promise((r) => srv.listen(0, r));
|
||||||
|
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => new Promise((r) => srv.close(r)));
|
||||||
|
|
||||||
|
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
|
||||||
|
|
||||||
|
it("serves a dropped image back as an image that runs nothing", async () => {
|
||||||
|
const saved = await fetchJson("/api/run/drop-image", {
|
||||||
|
method: "POST",
|
||||||
|
raw: { type: "image/png", buf: PNG },
|
||||||
|
});
|
||||||
|
const r = await fetchJson(`/api/run/drop/${path.basename(saved.body.path)}`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.headers["content-type"], "image/png");
|
||||||
|
assert.equal(r.headers["x-content-type-options"], "nosniff");
|
||||||
|
assert.match(r.headers["content-security-policy"], /sandbox/);
|
||||||
|
fs.unlinkSync(saved.body.path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves no file that is not a dropped image", async () => {
|
||||||
|
const link = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`agenthub-drops-${os.userInfo().uid}`,
|
||||||
|
"fedcba9876543210.png"
|
||||||
|
);
|
||||||
|
fs.rmSync(link, { force: true });
|
||||||
|
fs.symlinkSync("/etc/passwd", link);
|
||||||
|
const pdf = await fetchJson("/api/run/drop-file?name=a.pdf", {
|
||||||
|
method: "POST",
|
||||||
|
raw: { type: "application/pdf", buf: Buffer.from("%PDF") },
|
||||||
|
});
|
||||||
|
for (const name of [
|
||||||
|
path.basename(pdf.body.path), // a drop, but no image
|
||||||
|
"0123456789abcdef.png", // the right shape, never dropped
|
||||||
|
"..%2F..%2Fetc%2Fpasswd",
|
||||||
|
"passwd.png", // no random prefix
|
||||||
|
"fedcba9876543210.png", // a link planted under a drop's name
|
||||||
|
]) {
|
||||||
|
const r = await fetchJson(`/api/run/drop/${name}`);
|
||||||
|
assert.equal(r.status, 404, name);
|
||||||
|
}
|
||||||
|
fs.unlinkSync(pdf.body.path);
|
||||||
|
fs.unlinkSync(link);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/run/:id/permission-mode", () => {
|
||||||
|
let srv;
|
||||||
|
before(async () => {
|
||||||
|
srv = http.createServer(createApp());
|
||||||
|
await new Promise((r) => srv.listen(0, r));
|
||||||
|
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => new Promise((r) => srv.close(r)));
|
||||||
|
beforeEach(() => tmux.__reset());
|
||||||
|
|
||||||
|
// A live claude pane whose mode line follows Shift+Tab around Claude Code's cycle.
|
||||||
|
const LINES = {
|
||||||
|
default: " ⏸ manual mode on · ← for agents",
|
||||||
|
acceptEdits: " ⏵⏵ accept edits on (shift+tab to cycle) · ← for agents",
|
||||||
|
plan: " ⏸ plan mode on (shift+tab to cycle) · ← for agents",
|
||||||
|
auto: " ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents",
|
||||||
|
};
|
||||||
|
function fakePane(start) {
|
||||||
|
const cycle = ["default", "acceptEdits", "plan", "auto"];
|
||||||
|
const state = { mode: start, presses: 0 };
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "capture-pane") return `❯ \n${"─".repeat(20)}\n${LINES[state.mode]}\n`;
|
||||||
|
if (args[0] === "send-keys" && args.at(-1) === "BTab") {
|
||||||
|
state.presses++;
|
||||||
|
state.mode = cycle[(cycle.indexOf(state.mode) + 1) % cycle.length];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("GET reads the mode off the pane", async () => {
|
||||||
|
fakePane("plan");
|
||||||
|
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode");
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.mode, "plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST presses Shift+Tab until the pane shows the mode asked for", async () => {
|
||||||
|
const state = fakePane("default");
|
||||||
|
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
|
||||||
|
method: "POST",
|
||||||
|
body: { mode: "auto" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.mode, "auto");
|
||||||
|
assert.equal(state.presses, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST of a mode not in the cycle ends where it started, and says so", async () => {
|
||||||
|
const state = fakePane("acceptEdits");
|
||||||
|
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
|
||||||
|
method: "POST",
|
||||||
|
body: { mode: "bypassPermissions" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
assert.equal(r.body.error.code, "EMODEUNAVAILABLE");
|
||||||
|
assert.equal(state.mode, "acceptEdits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST presses nothing when the mode line cannot be read", async () => {
|
||||||
|
let presses = 0;
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "capture-pane") return "Select model\n❯ 1. Default\n";
|
||||||
|
if (args[0] === "send-keys") presses++;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
|
||||||
|
method: "POST",
|
||||||
|
body: { mode: "plan" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 409);
|
||||||
|
assert.equal(r.body.error.code, "EMODEUNKNOWN");
|
||||||
|
assert.equal(presses, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST refuses a mode outside the allowlist", async () => {
|
||||||
|
fakePane("default");
|
||||||
|
const r = await fetchJson("/api/run/agenthub-lane-9/permission-mode", {
|
||||||
|
method: "POST",
|
||||||
|
body: { mode: "dontAsk; rm -rf ~" },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 400);
|
||||||
|
assert.equal(r.body.error.code, "EBADMODE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 404 for a pane that is not running", async () => {
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "has-session") {
|
||||||
|
const e = new Error("gone");
|
||||||
|
e.status = 1;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const r = await fetchJson("/api/run/agenthub-lane-999/permission-mode");
|
||||||
|
assert.equal(r.status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -185,6 +185,15 @@ describe("server mode", () => {
|
|||||||
body: { prefs: { "agenthub.chatUi": null } },
|
body: { prefs: { "agenthub.chatUi": null } },
|
||||||
});
|
});
|
||||||
assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {});
|
assert.deepEqual((await call("GET", "/api/me/prefs", { cookie: boss })).body.prefs, {});
|
||||||
|
// The appearance (useTheme's storage keys) follows the account too.
|
||||||
|
const look = {
|
||||||
|
theme: "light",
|
||||||
|
"agenthub.theme.light": "canvas",
|
||||||
|
"agenthub.theme.dark": "darcula",
|
||||||
|
};
|
||||||
|
const saved = await call("PUT", "/api/me/prefs", { cookie: ana, body: { prefs: look } });
|
||||||
|
assert.equal(saved.status, 200);
|
||||||
|
assert.deepEqual(saved.body.prefs, look);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks a WebSocket upgrade's login and Origin", async () => {
|
it("checks a WebSocket upgrade's login and Origin", async () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user