feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall

- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
2026-10-08 23:04:57 +07:00
parent a9c71cd905
commit b7f3735b29
147 changed files with 5644 additions and 2561 deletions
@@ -93,6 +93,7 @@ import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import { useAiTools } from "../hooks/useAiTools";
import { useSpin } from "../hooks/useSpin";
import { usePermissions } from "./AuthGate";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -284,7 +285,9 @@ export function AlertsNotifications() {
const { t } = useTranslation("alerts");
const { t: ts } = useTranslation("settings");
const [tab, setTab] = useState<TabKey>("rules");
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
const { isAdmin } = usePermissions();
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
// Rules
const [rules, setRules] = useState<AlertRule[]>([]);
@@ -361,8 +364,8 @@ export function AlertsNotifications() {
}, [unackedOnly, alerts.length, t]);
useEffect(() => {
loadRules();
}, [loadRules]);
if (isAdmin) loadRules();
}, [loadRules, isAdmin]);
useEffect(() => {
loadAlerts();
@@ -504,7 +507,7 @@ export function AlertsNotifications() {
(form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{
key: "rules",
label: ts("alertsHub.tabRules"),
@@ -519,6 +522,7 @@ export function AlertsNotifications() {
badge: unacked || undefined,
},
];
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
return (
<div className="space-y-4">
@@ -1086,7 +1090,7 @@ export function AlertsNotifications() {
)}
</p>
</div>
{!alert.acknowledged_at && (
{!alert.acknowledged_at && alert.can_ack !== false && (
<button
onClick={() => onAck(alert.id)}
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
+25 -1
View File
@@ -6,7 +6,8 @@
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
* someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
* `usePermissions()` says what that user's role may do, so pages hide the rest.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -28,6 +29,29 @@ const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout:
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
export const useAuth = () => useContext(AuthContext);
/**
* What the logged-in user may do — the server's rules (server/lib/auth.js,
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
* only their own — the one that matters is an analyst looking at another
* user's.
*/
export function usePermissions() {
const { state } = useAuth();
const user = state.mode === "server" ? state.user : null;
const local = state.mode !== "server";
const isAdmin = local || user?.role === "admin";
return {
isAdmin,
seesAll: local || (!!user && user.role !== "developer"),
// A developer only ever sees their own rows, so everything shown is theirs.
canEditLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
};
}
/** The mode this browser last saw, so a local dashboard paints at once. */
const MODE_KEY = "agenthub.authMode";
const RETRY_MS = 2000;
+49 -39
View File
@@ -28,6 +28,7 @@ import {
} from "lucide-react";
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
import { usePets } from "./usePets";
import { usePermissions } from "../AuthGate";
import { toast } from "../../lib/toast";
const ITEM =
@@ -50,6 +51,8 @@ export function PetMenu({
const bundleInput = useRef<HTMLInputElement>(null);
const [choosing, setChoosing] = useState(false);
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Adding or deleting a pet changes the whole install: an admin's.
const { isAdmin } = usePermissions();
// Closes on a press outside the menu, or Escape.
useEffect(() => {
@@ -133,6 +136,7 @@ export function PetMenu({
</button>
{list?.featured.map((f) => {
const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return (
<button
key={f.id}
@@ -173,48 +177,54 @@ export function PetMenu({
<span className="min-w-0 truncate">{p.displayName}</span>
{mark(p.id)}
</button>
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{isAdmin && (
<button
type="button"
data-testid={`pet-delete-${p.id}`}
aria-label={t("sagi.pet.delete", { name: p.displayName })}
title={t("sagi.pet.delete", { name: p.displayName })}
disabled={busy !== null}
onClick={() => void remove(p)}
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
<div className="my-1.5 border-t border-border" />
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
{isAdmin && (
<>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
role="menuitem"
disabled={busy !== null}
className={ITEM}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PackageOpen className="h-4 w-4" />
)}
{t("sagi.pet.import")}
</button>
</>
)}
<a
href="https://codex-pets.net/"
target="_blank"
+23 -16
View File
@@ -74,7 +74,7 @@ import {
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "./AuthGate";
import { useAuth, usePermissions } from "./AuthGate";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -192,6 +192,8 @@ export function Sidebar({
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
// Checking for (and applying) an update acts on the host: an admin's.
const canUpdate = usePermissions().isAdmin;
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -468,7 +470,9 @@ export function Sidebar({
{[
...NAV_KEYS,
// Server mode: the Users page, for an admin or analyst.
...(auth.state.mode === "server" && auth.state.user && auth.state.user.role !== "member"
...(auth.state.mode === "server" &&
auth.state.user &&
auth.state.user.role !== "developer"
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
: []),
].map(({ to, icon: Icon, key }) => {
@@ -591,7 +595,7 @@ export function Sidebar({
checking,
available: updateAvailable,
failed: checkError,
onCheck: onCheckUpdates,
onCheck: canUpdate ? onCheckUpdates : undefined,
}}
onResetStats={() => {
eventCountRef.current = 0;
@@ -633,7 +637,8 @@ interface ConnectionStatusModalProps {
checking: boolean;
available: boolean;
failed: boolean;
onCheck: () => void;
/** Absent for a user who may not update (server mode, not an admin). */
onCheck?: () => void;
};
onResetStats: () => void;
}
@@ -775,18 +780,20 @@ function ConnectionStatusModal({
{update.title}
</p>
</div>
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
{update.onCheck && (
<button
type="button"
onClick={update.onCheck}
disabled={update.checking}
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
aria-hidden
/>
{t("nav:checkForUpdates")}
</button>
)}
</div>
</Section>
-19
View File
@@ -27,7 +27,6 @@
* ## Public surface
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
@@ -52,11 +51,6 @@
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TextSkeleton**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* **TableRowSkeleton**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
}
/**
* Single-line text placeholder. Good for trend/sub labels.
*/
export function TextSkeleton({
className = "",
width = "w-16",
}: {
className?: string;
width?: string;
}) {
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
}
/**
* Full table-row skeleton for tabular pages. Caller passes the column count so
* the skeleton lines up under existing headers.
+4
View File
@@ -88,6 +88,7 @@ import { copyText } from "../lib/clipboard";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { usePermissions } from "./AuthGate";
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
@@ -125,6 +126,7 @@ function loadDismissedSha(): string | null {
* @returns `null` when no update is available or this release was dismissed.
*/
export function UpdateNotifier() {
const { isAdmin } = usePermissions();
const { t } = useTranslation("updates");
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
@@ -190,7 +192,9 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
+3 -3
View File
@@ -5,7 +5,7 @@
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking 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
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
* lane list (`useWorkLanes`), 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
* 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
@@ -28,7 +28,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
import { useWorkLanes } from "../hooks/useLanes";
import { useAiTools } from "../hooks/useAiTools";
import { useThrottledValue } from "../hooks/useThrottledValue";
import {
@@ -70,7 +70,7 @@ export function WorkspaceTree() {
const navigate = useNavigate();
const location = useLocation();
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes();
const { lanes, refresh: refreshLanes } = useWorkLanes();
const prefs = useProjectTreePrefs();
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
const [query, setQuery] = useState("");
@@ -0,0 +1,60 @@
/**
* @file AlertsNotifications.roles.test.tsx
* @description Server mode, not an admin: alert rules and channels fire for
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
* Acknowledge only on alerts the server marks theirs (`can_ack`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const rulesList = vi.fn(async () => ({ rules: [] }));
const alert = (id: number, can_ack: boolean) => ({
id,
rule_id: "r1",
rule_name: `rule ${id}`,
rule_type: "error_rate",
session_id: null,
agent_id: null,
message: `boom ${id}`,
details: null,
triggered_at: "2026-10-08T00:00:00.000Z",
acknowledged_at: null,
can_ack,
});
vi.mock("../../lib/api", () => ({
api: {
alerts: {
rules: { list: () => rulesList() },
list: vi.fn(async () => ({
alerts: [alert(1, true), alert(2, false)],
total: 2,
unacked: 2,
})),
ack: vi.fn(),
ackAll: vi.fn(),
},
},
}));
vi.mock("../AuthGate", () => ({
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
}));
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications for a non-admin", () => {
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
expect(await screen.findByText("boom 2")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
expect(rulesList).not.toHaveBeenCalled();
});
});
@@ -2,25 +2,52 @@
* @file AuthGate.test.tsx
* @description The login gate: local mode renders the app untouched, server
* mode shows the login page until a login succeeds, a wrong password says so,
* and a later 401 `EAUTH` brings the login page back.
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
* analyst reads everything but works only in their own workspaces, a
* developer only ever sees (and so changes) their own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
const me = vi.fn();
const login = vi.fn();
const LANES = [
{ id: 1, owner_id: 5 },
{ id: 2, owner_id: 6 },
];
vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
},
}));
import { AuthGate } from "../AuthGate";
import { AuthGate, usePermissions } from "../AuthGate";
import { useWorkLanes } from "../../hooks/useLanes";
const user = { id: 1, username: "mia", role: "member" };
function Probe() {
const p = usePermissions();
const { lanes } = useWorkLanes();
return (
<p>
{[
`admin:${p.isAdmin}`,
`all:${p.seesAll}`,
`mine:${p.canEditLane({ owner_id: 5 })}`,
`theirs:${p.canEditLane({ owner_id: 6 })}`,
`work:${lanes.map((l) => l.id).join(",")}`,
].join(" ")}
</p>
);
}
const user = { id: 1, username: "mia", role: "developer" };
beforeEach(() => {
me.mockReset();
@@ -98,3 +125,37 @@ describe("AuthGate", () => {
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
});
});
describe("usePermissions / useWorkLanes", () => {
const as = async (state: unknown) => {
cleanup();
me.mockResolvedValue(state);
render(
<AuthGate>
<Probe />
</AuthGate>
);
return (await screen.findByText(/work:\d/)).textContent;
};
it("lets an admin and local mode do everything", async () => {
for (const state of [
{ mode: "local" },
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
]) {
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
}
});
it("lets an analyst read everything but work only in their own workspaces", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
"admin:false all:true mine:true theirs:false work:1"
);
});
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
"admin:false all:false mine:true theirs:true work:1,2"
);
});
});
@@ -178,6 +178,10 @@ describe("WorkspaceTree", () => {
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
await screen.findByText("Session 8");
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
await user.click(screen.getByTestId("workspace-sessions-less-1"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
});
it("renames a session on double-click + Enter, and archives one from its row", async () => {
@@ -76,9 +76,16 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
interface ConversationViewProps {
sessionId: string;
initialTranscriptId?: string | null;
/** 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. */
fill?: boolean;
}
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
export function ConversationView({
sessionId,
initialTranscriptId,
fill = false,
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [total, setTotal] = useState(0);
@@ -91,6 +98,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
// Scrolled up from the end: a chat (`fill`) offers the way back down.
const [awayFromEnd, setAwayFromEnd] = useState(false);
// Track JSONL line numbers for incremental requests and history loading
const lastLineRef = useRef(0);
@@ -370,6 +379,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Detect if at bottom
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
isAtBottomRef.current = atBottom;
setAwayFromEnd(!atBottom);
// Hide "new messages" indicator when scrolled to bottom
if (atBottom) {
@@ -390,10 +400,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
{!loading && (
{!loading && !fill && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && (
<div className="relative">
@@ -441,7 +451,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto"
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
>
{/* History loading indicator */}
{loadingHistory && (
@@ -474,8 +484,28 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
)}
</div>
{/* Claude Desktop's round "back to the end" button */}
{fill && awayFromEnd && (
<button
type="button"
data-testid="conversation-to-end"
onClick={() => {
scrollToBottom();
setShowNewMsg(false);
}}
aria-label={t("conversation.newMessages")}
title={t("conversation.newMessages")}
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
>
<ArrowDown className="h-4 w-4" />
{showNewMsg && (
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
)}
</button>
)}
{/* New messages indicator */}
{showNewMsg && (
{showNewMsg && !fill && (
<button
onClick={() => {
scrollToBottom();
@@ -381,6 +381,8 @@ interface MarkdownContentProps {
text: string;
/** Tighter spacing for nested contexts (list items, quotes). */
dense?: boolean;
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
prose?: boolean;
}
const HEADING_STYLES = [
@@ -392,12 +394,16 @@ const HEADING_STYLES = [
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
];
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
const blocks = parseBlocks(text);
const gap = dense ? "space-y-1.5" : "space-y-2.5";
// A paragraph or list item takes the root's size and colour in prose.
const bodyText = prose ? "" : "text-sm text-fg-secondary";
return (
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
<div
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
>
{blocks.map((b, idx) => {
switch (b.kind) {
case "code":
@@ -420,7 +426,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
@@ -433,7 +439,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
<li key={i} className={bodyText}>
{renderListItem(item, `li${idx}-${i}`)}
</li>
))}
@@ -503,7 +509,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
case "para":
return (
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
{renderInline(b.text, `p${idx}`)}
</p>
);
@@ -111,7 +111,7 @@ const SENDER_STYLES: Record<
headerText: "text-status-warning",
},
};
import { ToolCallBlock } from "./ToolCallBlock";
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
import { MarkdownContent } from "./MarkdownContent";
import { fmt, formatModelName } from "../../lib/format";
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
@@ -264,6 +264,60 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
}
}
/** A text block of any sender: a task notification or skill body collapsed,
* TUI markup as segments, anything else as markdown. */
function renderTextBlock(text: string, key: number): React.ReactNode {
// Detect task notifications, collapsed by default
if (isTaskNotification(text)) {
return (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />}
title={i18n.t("sessions:conversation.taskNotification")}
borderClass="border-syntax-property/20"
bgClass="bg-syntax-property/5"
textClass="text-syntax-property"
/>
);
}
// Detect skill content, collapsed by default
if (isSkillContent(text)) {
const pathMatch = text.match(/^Base directory for this skill:\s*(\S+)/);
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
return (
<CollapsibleBlock
key={key}
text={text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
title={skillPath}
borderClass="border-syntax-builtin/20"
bgClass="bg-syntax-builtin/5"
textClass="text-syntax-builtin"
/>
);
}
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
// can appear inline (sometimes interleaved with prose). Parse the text
// into segments and render each with the appropriate visual treatment.
if (hasTuiTags(text)) {
const segments = parseTuiSegments(text);
return (
<div key={key} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={key} className="min-w-0">
<MarkdownContent text={stripAnsi(text)} />
</div>
);
}
/** Generic collapsible content block */
function CollapsibleBlock({
text,
@@ -326,10 +380,46 @@ export function messageKeys(messages: TranscriptMessage[]): string[] {
});
}
/** One thing on screen, in the Claude Desktop layout: a human message as a
* bubble, the assistant's prose bare, and everything it did between two
* pieces of prose — tool calls and thinking, across messages (Claude Code
* writes each as its own line) — as ONE collapsed line; any other sender
* keeps its attributed row. */
type Item =
| { kind: "event"; key: string; msg: TranscriptMessage }
| { kind: "row"; key: string; msg: TranscriptMessage }
| { kind: "user"; key: string; msg: TranscriptMessage }
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
| { kind: "work"; key: string; blocks: TranscriptContent[] };
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
const items: Item[] = [];
messages.forEach((msg, idx) => {
const key = keys[idx]!;
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
// Purely tool_result: shown inside its call's block.
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
if (sender === "user") return items.push({ kind: "user", key, msg });
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
if (last?.kind === "work") last.blocks.push(block);
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
} else if (block.type === "text" && block.text?.trim()) {
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
}
});
});
return items;
}
export function MessageList({ messages, loading }: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
const keys = useMemo(() => messageKeys(messages), [messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
if (loading) {
return (
@@ -344,23 +434,280 @@ export function MessageList({ messages, loading }: MessageListProps) {
}
return (
<div className="space-y-3">
{messages.map((msg, idx) => {
// Session lifecycle markers (e.g. /rename) render as a centered chip,
// not as a user/assistant row.
if (msg.type === "session_event") {
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
{items.map((item) => {
switch (item.kind) {
case "event":
return (
<SessionEventRow
key={item.key}
title={item.msg.title}
timestamp={item.msg.timestamp}
/>
);
case "row":
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
case "user":
return <UserBubble key={item.key} msg={item.msg} />;
case "text":
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
case "work":
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
}
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
return null;
}
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
})}
</div>
);
}
const meta = "text-[10px] font-mono text-fg-muted";
/** 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. */
const LONG_BUBBLE = 800;
const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage }) {
const { t } = useTranslation("sessions");
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
const [open, setOpen] = useState(false);
return (
<div
data-testid="chat-user"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
className="group flex flex-col items-end gap-1"
>
<div
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 ${
long && !open
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
: ""
}`}
>
{msg.content.map((block, b) => {
if (block.type !== "text" || !block.text) return null;
// Plain typing reads as typed; anything structured keeps its rendering.
const plain =
!hasTuiTags(block.text) &&
!isSkillContent(block.text) &&
!isTaskNotification(block.text);
return plain ? (
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
{stripAnsi(block.text).trim()}
</p>
) : (
renderTextBlock(block.text, b)
);
})}
</div>
{long && (
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="text-xs text-fg-muted hover:text-fg-secondary"
>
{open ? t("conversation.showLess") : t("conversation.showMore")}
</button>
)}
{msg.timestamp && (
<span className={`${meta} opacity-0 transition-opacity group-hover:opacity-100`}>
{formatLocalTime(msg.timestamp)}
</span>
)}
</div>
);
});
/** The assistant's prose, bare on the page; model, tokens and time on hover. */
const AssistantText = memo(function AssistantText({
msg,
text,
}: {
msg: TranscriptMessage;
text: string;
}) {
return (
<div
data-testid="chat-assistant"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
className="group min-w-0"
>
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
renderTextBlock(text, 0)
) : (
<MarkdownContent text={stripAnsi(text)} prose />
)}
<div
className={`${meta} mt-1 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100`}
>
{msg.model && <span>{formatModelName(msg.model)}</span>}
{msg.usage && (
<span>
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
</span>
)}
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
</div>
</div>
);
});
/** A muted line that opens to show what it stands for. */
function Disclosure({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="inline-flex items-center gap-1 text-sm text-fg-muted transition-colors hover:text-fg-secondary"
>
{label}
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
</button>
{open && <div className="mt-2 space-y-2">{children}</div>}
</div>
);
}
/** The kind of work a tool call is, for the group's label (Claude Code and
* Codex names); anything else counts as a tool. */
const WORK_KIND: Record<string, string> = {
bash: "commands",
shell: "commands",
exec_command: "commands",
powershell: "commands",
edit: "edits",
multiedit: "edits",
write: "edits",
notebookedit: "edits",
apply_patch: "edits",
read: "reads",
grep: "searches",
glob: "searches",
ls: "searches",
websearch: "web",
webfetch: "web",
task: "agents",
agent: "agents",
};
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
export function workLabel(blocks: TranscriptContent[]): string {
const counts = new Map<string, number>();
for (const b of blocks) {
if (b.type !== "tool_use") continue;
const kind = WORK_KIND[(b.name ?? "").toLowerCase()] ?? "tools";
counts.set(kind, (counts.get(kind) ?? 0) + 1);
}
if (!counts.size) return i18n.t("sessions:conversation.thinking");
const text = [...counts]
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
.join(", ");
return text.charAt(0).toUpperCase() + text.slice(1);
}
/** One step inside an opened group, as Claude Desktop lists them: its own
* description (Claude Code's Bash `description`) or the tool and target as
* the title, the call itself under it in a capped scroll box, the result one
* click further. Thinking is a step too, closed. */
function WorkStep({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const thinking = block.type === "thinking";
const [open, setOpen] = useState(!thinking);
const [showResult, setShowResult] = useState(false);
const input = block.input as Record<string, unknown> | undefined;
const title = thinking
? t("conversation.thinking")
: typeof input?.description === "string" && input.description
? input.description
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
return (
<div className="px-3 py-2">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={`flex w-full min-w-0 items-center gap-1 text-left text-sm transition-colors hover:text-fg-secondary ${
result?.is_error ? "text-status-danger" : "text-fg-muted"
}`}
>
<span className="truncate">{title}</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
/>
</button>
{open && (
<div className="mt-2 space-y-2">
{thinking ? (
<div className="max-h-60 overflow-auto text-fg-muted">
<MarkdownContent text={block.text ?? ""} dense />
</div>
) : (
<>
<div className="max-h-60 overflow-auto">
{/* The description is the title already. */}
{renderInput(
input ? { ...block, input: { ...input, description: undefined } } : block
)}
</div>
{result && (
<button
type="button"
aria-expanded={showResult}
onClick={() => setShowResult(!showResult)}
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-fg-secondary"
>
{result.is_error ? t("conversation.tool.error") : t("conversation.tool.output")}
<ChevronRight
className={`h-3 w-3 transition-transform ${showResult ? "rotate-90" : ""}`}
/>
</button>
)}
{result && showResult && (
<div className="max-h-60 overflow-auto">
{renderResult(result, block.name ?? "")}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/** What the assistant did between two replies as one line, opening onto
* one framed list of its steps. */
const WorkGroup = memo(function WorkGroup({
blocks,
toolResultMap,
}: {
blocks: TranscriptContent[];
toolResultMap: ReturnType<typeof buildToolResultMap>;
}) {
return (
<div data-testid="chat-tools">
<Disclosure label={workLabel(blocks)}>
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
{blocks.map((block, i) => (
<WorkStep
key={block.id ?? i}
block={block}
result={block.id ? (toolResultMap.get(block.id) ?? null) : null}
/>
))}
</div>
</Disclosure>
</div>
);
});
/** One user/assistant row. Memoized: a live tail append re-renders only the
* new rows; its thinking-block expansion is its own state, kept by key. */
const MessageRow = memo(function MessageRow({
@@ -419,59 +766,7 @@ const MessageRow = memo(function MessageRow({
{/* Content blocks */}
{msg.content.map((block, bIdx) => {
if (block.type === "text" && block.text) {
// Detect task notifications, collapsed by default
if (isTaskNotification(block.text)) {
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
}
title={t("conversation.taskNotification")}
borderClass="border-syntax-property/20"
bgClass="bg-syntax-property/5"
textClass="text-syntax-property"
/>
);
}
// Detect skill content, collapsed by default
if (isSkillContent(block.text)) {
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
return (
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
title={skillPath}
borderClass="border-syntax-builtin/20"
bgClass="bg-syntax-builtin/5"
textClass="text-syntax-builtin"
/>
);
}
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
// can appear inline (sometimes interleaved with prose). Parse the text
// into segments and render each with the appropriate visual treatment.
if (hasTuiTags(block.text)) {
const segments = parseTuiSegments(block.text);
return (
<div key={bIdx} className="space-y-2 min-w-0">
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
</div>
);
}
return (
<div key={bIdx} className="min-w-0">
<MarkdownContent text={stripAnsi(block.text)} />
</div>
);
}
if (block.type === "text" && block.text) return renderTextBlock(block.text, bIdx);
if (block.type === "thinking" && block.text) {
const thinkKey = bIdx;
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
}
/** Build a one-line summary of the tool call to show in the collapsed header. */
function buildSummary(toolUse: TranscriptContent): string | null {
export function buildSummary(toolUse: TranscriptContent): string | null {
const input = toolUse.input;
if (!input || typeof input !== "object" || "_truncated" in input) return null;
const obj = input as Record<string, unknown>;
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
/** Render the input pane with tool-aware formatting. */
function renderInput(toolUse: TranscriptContent) {
export function renderInput(toolUse: TranscriptContent) {
const input = toolUse.input;
if (!input) return null;
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
}
/** Render the result pane: detect diff/json/text. */
function renderResult(toolResult: TranscriptContent, toolName: string) {
export function renderResult(toolResult: TranscriptContent, toolName: string) {
const text = toolResult.output ?? "";
if (text.length === 0)
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
@@ -1,13 +1,15 @@
/**
* @file MessageList.sender.test.tsx
* @description Verifies the transcript renders each message under its TRUE
* sender label — User / Assistant / Main agent / System — instead of labeling
* every `type:"user"` line "User" (reported transcript mis-attribution).
* @description Verifies the transcript renders each message by its TRUE
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MessageList } from "../MessageList";
import { fireEvent, render, screen } from "@testing-library/react";
import { MessageList, workLabel } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -36,8 +38,8 @@ describe("MessageList — sender attribution", () => {
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
expect(screen.getByText("System")).toBeInTheDocument();
expect(screen.getByText("Main agent")).toBeInTheDocument();
});
@@ -48,7 +50,31 @@ describe("MessageList — sender attribution", () => {
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
];
render(<MessageList messages={messages} loading={false} />);
expect(screen.getByText("User")).toBeInTheDocument();
expect(screen.getByText("Assistant")).toBeInTheDocument();
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
});
it("folds consecutive tool calls — one per transcript line — into one line", () => {
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 messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
render(<MessageList messages={messages} loading={false} />);
const group = screen.getAllByTestId("chat-tools");
expect(group).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
expect(group[0]).toHaveTextContent("echo b");
});
it("names a mixed run by what it did, like Claude Desktop", () => {
const use = (name: string) => ({ type: "tool_use" as const, name });
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
"Ran 2 commands, edited a file, read a file"
);
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
});
});
+42 -28
View File
@@ -21,7 +21,8 @@
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
* a function that moves the cursor to the next / previous changed block.
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
* Chat chip above it, which hands the panel the selection's 1-based lines.
* Chat chip at the end of its first line (inline, so it covers no code),
* which hands the panel the selection's 1-based lines.
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
* CodeMirror stays out of the main bundle, and one mount is one view.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -49,9 +50,7 @@ import {
highlightActiveLineGutter,
keymap,
lineNumbers,
showTooltip,
type DecorationSet,
type Tooltip,
type ViewUpdate,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
@@ -285,23 +284,41 @@ function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
});
}
/** Add to Chat above a non-empty selection; `build` makes its DOM from the
* selection's first and last 1-based line. A selection ending at a line's
* start (a whole-line drag) does not take that line. */
class ChatChip extends WidgetType {
constructor(
readonly start: number,
readonly end: number,
readonly build: (start: number, end: number) => HTMLElement
) {
super();
}
eq(o: ChatChip) {
return this.start === o.start && this.end === o.end;
}
toDOM() {
return this.build(this.start, this.end);
}
}
/** Add to Chat inline at the end of a non-empty selection's first line —
* the empty space right of the code, so it covers no line. `build` makes its
* DOM from the selection's first and last 1-based line. A selection ending
* at a line's start (a whole-line drag) does not take that line. */
function chatChip(build: (start: number, end: number) => HTMLElement) {
return StateField.define<Tooltip | null>({
create: () => null,
update(tip, tr) {
if (!tr.docChanged && !tr.selection) return tip;
return StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
if (!tr.docChanged && !tr.selection) return deco;
const { from, to, empty } = tr.state.selection.main;
if (empty) return null;
if (empty) return Decoration.none;
const doc = tr.state.doc;
const start = doc.lineAt(from).number;
const first = doc.lineAt(from);
const last = doc.lineAt(to);
const end = to === last.from && last.number > start ? last.number - 1 : last.number;
return { pos: from, above: true, create: () => ({ dom: build(start, end) }) };
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
const widget = new ChatChip(first.number, end, build);
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
},
provide: (f) => showTooltip.from(f),
provide: (f) => EditorView.decorations.from(f),
});
}
@@ -319,8 +336,6 @@ const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
const M = "&.cm-merge-b.cm-merge-b";
const A = "&.cm-merge-a.cm-merge-a";
const theme = EditorView.theme({
// The chip draws its own box; drop its wrapper's default tooltip one.
".cm-tooltip.cm-ah-chat": { border: "none", background: "transparent" },
"&": {
height: "100%",
fontSize: "11px",
@@ -491,7 +506,11 @@ export default function DiffEditor({
useEffect(() => {
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
const common = [lineNumbers(), diffColours(lang), theme];
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
// would rewrite every line ending.
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
const editing = [
...common,
highlightActiveLineGutter(),
@@ -513,9 +532,9 @@ export default function DiffEditor({
]),
EditorView.contentAttributes.of({ "aria-label": label }),
EditorView.updateListener.of((u) => {
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
}),
blockBars(Text.of(original.split("\n")), buildBar),
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
blameSlot.of([]),
...(onAddToChat
? [
@@ -525,16 +544,11 @@ export default function DiffEditor({
b.dataset.testid = "lane-editor-add-to-chat";
b.textContent = cb.current.t("changes.addToChat");
b.className =
"rounded border border-border-light bg-surface-1 px-1.5 py-0.5 font-sans text-[11px] text-fg-primary shadow-card hover:bg-surface-2";
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
// Keep the editor's focus (and so its selection) through the click.
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => cb.current.onAddToChat?.(start, end);
// CodeMirror makes the returned element the `.cm-tooltip`, whose
// themed rule above would strip the button's own border and fill.
const wrap = document.createElement("div");
wrap.className = "cm-ah-chat";
wrap.append(b);
return wrap;
return b;
}),
]
: []),
@@ -628,7 +642,7 @@ export default function DiffEditor({
// A value set from outside (Cancel's revert, a save's fresh text).
useEffect(() => {
const v = view.current;
if (v && v.state.doc.toString() !== value) {
if (v && v.state.sliceDoc() !== value) {
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
}
}, [value]);
-7
View File
@@ -104,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
return locks;
}
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
// A ring: a surface-4 fill vanished on the light themes.
idle: "border border-fg-muted",
dead: "bg-status-danger",
};
export function since(sec: number | null): string {
if (sec === null) return "—";
if (sec < 60) return `${sec}s`;
@@ -128,12 +128,12 @@ export function SessionReplay({
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} />
<ConversationView sessionId={sessionId} fill />
</div>
{livePane ? (
<div
data-testid="session-replay-live"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
@@ -148,7 +148,7 @@ export function SessionReplay({
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
@@ -160,7 +160,7 @@ export function SessionReplay({
</button>
</div>
) : (
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -52,6 +53,7 @@ export function SessionResumeButton({
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { lanes } = useLanes();
const { canEditLane } = usePermissions();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false);
@@ -77,7 +79,8 @@ export function SessionResumeButton({
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null;
// Server mode: an analyst may resume only in a workspace of their own.
if (!lane || !canEditLane(lane) || runs === null) return null;
const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => strip(a) === strip(b);
// Its own worktree counts too: the server resumes it there (`claude
@@ -776,6 +776,16 @@ function HomeBody({
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
</button>
)}
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
<button
type="button"
data-testid="workspace-recents-less"
onClick={() => setRecentLimit(RECENT_PAGE)}
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
<div className="mt-2">
<SessionFold
testId={`workspace-archived-${lane.id}`}
@@ -923,6 +933,7 @@ function HomeBody({
}}
/>
</div>
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
{files.length === 0 ? (
<button
type="button"
@@ -15,13 +15,13 @@
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
* which never has a session) as **Terminal** while it is open — a closed one
* leaves no row; Ctrl/Cmd+click opens
* it in a split beside the focused pane) — attaching, never starting or
* it in a split in the tab on screen) — attaching, never starting or
* resuming anything;
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
* `SessionReplay`): its conversation with a prompt box, resumed only when a
* message is SENT, because a resume re-reads the whole transcript into context.
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one. 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,
* and archiving a live one asks first. F2 renames the focused row too; each
* dot carries its meaning as screen-reader text.
@@ -568,6 +568,16 @@ export function WorkspaceSessionList({
{t("tree.seeAll", { count: more })}
</button>
)}
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
<button
type="button"
data-testid={`workspace-sessions-less-${lane.id}`}
onClick={() => setLimit(SESSION_LIMIT)}
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
>
{t("tree.showLess")}
</button>
)}
{failure}
{confirmArchive}
{ctx.menu && (
@@ -255,6 +255,39 @@ describe("DiffEditor", () => {
);
});
it.each(["unified", "split"] as const)(
"keeps a CRLF file's text as is (%s): no edit at mount, so the bars stay",
async (layout) => {
const onChange = vi.fn();
const { container } = render(
<DiffEditor
layout={layout}
value={"a\r\nB\r\nc"}
original={"a\r\nb\r\nc"}
lang="plain"
label="Edit"
revertLabel="Revert"
onChange={onChange}
onSave={vi.fn()}
onBlock={vi.fn()}
/>
);
await waitFor(() =>
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(1)
);
expect(onChange).not.toHaveBeenCalled();
const eds = container.querySelectorAll<HTMLElement>(".cm-editor");
const view = EditorView.findFromDOM(eds[eds.length - 1]!)!;
expect(view.state.sliceDoc()).toBe("a\r\nB\r\nc");
// Only B changed: the CRs are not read as an edit on every line.
expect(
container
.querySelectorAll(".cm-editor")
[eds.length - 1]!.querySelectorAll(".cm-changedLine")
).toHaveLength(1);
}
);
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
const onAddToChat = vi.fn();
const { container } = render(
@@ -280,9 +313,10 @@ describe("DiffEditor", () => {
expect(b).not.toBeNull();
return b!;
});
// The chip keeps its own box: the tooltip theme strips the WRAPPER's, so
// the button must not be the `.cm-tooltip` itself.
expect(chip.classList.contains("cm-tooltip")).toBe(false);
// Inline at the end of the first selected line, never a tooltip over the
// line above it.
expect(chip.closest(".cm-tooltip")).toBeNull();
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
fireEvent.click(chip);
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
view.dispatch({ selection: { anchor: 6 } });
@@ -397,6 +397,24 @@ describe("WorkspaceHome", () => {
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
});
it("shows more recents a page at a time, and collapses them again", async () => {
vi.mocked(api.sessions.list).mockImplementation(async (params) => {
const all = (params as { archived?: boolean }).archived
? []
: Array.from({ length: 35 }, (_, i) => ({ ...sessions[1]!, id: `s-${i}` }));
const limit = (params as { limit?: number }).limit ?? 30;
return { sessions: all.slice(0, limit), total: all.length, limit, offset: 0 };
});
const user = userEvent.setup();
renderHome();
await user.click(await screen.findByTestId("workspace-recents-more"));
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 60 });
await user.click(await screen.findByTestId("workspace-recents-less"));
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 30 });
expect(await screen.findByTestId("workspace-recents-more")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-recents-less")).toBeNull();
});
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
const user = userEvent.setup();
renderHome();
+2 -2
View File
@@ -262,8 +262,8 @@ export function splitDiff({
collapse?: { margin: number; minSize: number };
revertLabel: string;
}): { a: EditorView; b: EditorView; destroy: () => void } {
const origDoc = Text.of(original.split("\n"));
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
const origDoc = Text.of(original.split(/\r?\n/));
const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
const root = parent.appendChild(document.createElement("div"));
root.className = "flex h-full min-h-0";
+1 -1
View File
@@ -19,7 +19,7 @@
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
* a `+` item) brings it into the tab on screen, split to the right, and a
* a `+` item) brings it into the tab on screen, in the same order as a join, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
+27 -2
View File
@@ -12,6 +12,10 @@
* new lane's own live session for its slot, or an empty pane — never the
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
* tab) attaches to that tmux session only; nothing is started or resumed.
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
* conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,6 +27,10 @@ import type { RunHandle, RunListResponse } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat";
import { useChatUi } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]);
export interface LaneConsolePaneProps {
lanes: Lane[];
@@ -70,6 +78,12 @@ export function LaneConsolePane({
// The tool of the run on screen — a session's own, which need not be the
// workspace's; the workspace's with no run.
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
const chat = useChatUi();
// A run learns its session id after it starts (Codex): the page's poll has it.
const chatSession = handle
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
: null;
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -173,11 +187,22 @@ export function LaneConsolePane({
)}
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<div className="relative flex-1 min-h-0 flex flex-col">
{/* Over the terminal, never instead of it: unmounting would drop the attach. */}
{chatable && chat && (
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
<PaneChat
runId={handle.id}
sessionId={chatSession}
tool={handle.tool}
cwd={handle.cwd || currentLane?.cwd || ""}
/>
</div>
)}
<TerminalView
runId={handle.id}
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus}
autoFocus={autoFocus && !(chatable && chat)}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
+87
View File
@@ -0,0 +1,87 @@
/**
* @file PaneChat.tsx
* @description A live pane's Chat view, the way Claude Desktop shows a session:
* the session's conversation (`ConversationView`, kept current by its own poll)
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
* attached underneath — a message is sent through that terminal
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
* nothing new reaches the server. A permission prompt or a menu still needs the
* terminal; the hint under the box says so.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp } from "lucide-react";
import { ConversationView } from "../conversation/ConversationView";
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
import { submitToRun } from "../../lib/panePaste";
import { toast } from "../../lib/toast";
export function PaneChat({
runId,
sessionId,
tool,
cwd,
}: {
runId: string;
sessionId: string;
tool: string;
cwd: string;
}) {
const { t } = useTranslation("run");
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
const [uploading, setUploading] = useState(false);
const text = promptWithImages(prompt, images);
const send = () => {
if (!text || uploading) return;
if (!submitToRun(runId, text)) {
toast.error(t("chat.notSent"));
return;
}
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
};
return (
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
<div className="min-h-0 flex-1 overflow-hidden">
<ConversationView sessionId={sessionId} fill />
</div>
<div className="mx-4">
<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">
<PromptEditor
bare
images={images}
onImagesChange={setImages}
onBusyChange={setUploading}
value={prompt}
onChange={setPrompt}
onSubmit={send}
placeholder={t("chat.placeholder")}
rows={2}
slash={tool === "claude"}
fileCwd={cwd}
/>
<div className="mt-1 flex items-center gap-2">
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
<button
type="button"
data-testid="pane-chat-send"
onClick={send}
disabled={uploading || !text}
aria-label={t("chat.send")}
title={t("chat.send")}
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" />
</button>
</div>
</div>
</div>
</div>
);
}
+57 -10
View File
@@ -31,7 +31,10 @@
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
* READ the user's clipboard). While the socket is down a "Reconnecting…"
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
* most every 150 ms.
* most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
@@ -328,6 +331,25 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const decoder = new TextDecoder();
let ws: WebSocket | null = null;
// Another browser drives this pane's size: the grid it set, or null.
let follow: { cols: number; rows: number } | null = null;
// A follower's grid may not fit this container: shrink it (never grow).
const scaleToFit = () => {
const el = term.element;
const box = containerRef.current;
if (!el || !box) return;
el.style.transformOrigin = "0 0";
el.style.transform = "";
// `.xterm` is a block as wide as the box; the grid's size is the screen's.
const grid = el.querySelector<HTMLElement>(".xterm-screen");
if (!follow || !grid?.offsetWidth || !grid.offsetHeight) return;
const s = Math.min(
1,
box.clientWidth / grid.offsetWidth,
box.clientHeight / grid.offsetHeight
);
if (s < 1) el.style.transform = `scale(${s})`;
};
/**
* Tell the server this terminal's size. tmux draws the pane at whatever
@@ -342,10 +364,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
* (`display:none` leaves the container 0×0), and fitting to that would
* resize the real tmux pane down to nothing.
*/
const sendSizeNow = () => {
const sendSizeNow = (drive = false) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
};
// This browser is the one in use now: back to its own size, and the
// pane (and every other viewer) follows it.
const takeLead = () => {
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
follow = null;
scaleToFit();
fit.fit();
sendSizeNow(true);
};
// A divider drag or a window resize fires a resize per frame; tmux and the
// program in the pane redraw on each, so the LAST size is sent once things
@@ -400,7 +432,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (data.startsWith('{"type"')) {
try {
const msg = JSON.parse(data);
if (msg.type === "exit") {
if (msg.type === "size") {
follow = { cols: msg.cols, rows: msg.rows };
term.resize(msg.cols, msg.rows);
requestAnimationFrame(scaleToFit);
} else if (msg.type === "lead") {
takeLead();
} else if (msg.type === "exit") {
ended = true;
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
onExitRef.current?.();
@@ -429,6 +467,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
connect();
const dataDisposable = term.onData((data) => {
// A keystroke takes the lead; a mouse report (wheel, hover) does not.
if (follow && !/^\x1b\[(<|M)/.test(data)) takeLead();
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
});
@@ -438,16 +478,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
const resizeDisposable = term.onResize(sendSize);
// Re-assert on focus. Several attach clients can share one tmux session
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
// the window to whichever client was last active — resizing this pane out
// from under a terminal that is never told. Coming back to it says so.
const reassert = () => sendSizeNow();
// (a second tab, another browser): coming back to this one makes it the
// driver again, and the others follow its size.
const reassert = takeLead;
term.textarea?.addEventListener("focus", reassert);
const refit = () => {
// Never fit a collapsed console — see sendSize.
const el = containerRef.current;
if (!el || !el.clientWidth || !el.clientHeight) return;
if (follow) return scaleToFit(); // the driver's grid, not ours
fit.fit(); // a real change fires onResize, which reports it
};
const resizeObserver = new ResizeObserver(refit);
@@ -502,9 +542,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
() =>
registerPane(
runId,
(text) => {
(text, submit) => {
termRef.current?.paste(text);
termRef.current?.focus();
// Enter a beat after the paste, so a TUI that times its paste
// detection never reads it as a newline inside the paste. A submit
// comes from the pane's Chat view, which keeps the keyboard.
if (submit)
setTimeout(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send("\r");
}, 100);
else termRef.current?.focus();
},
// On screen (a console hidden with CSS measures 0×0) AND attached:
// `onData` drops input while the socket is connecting or reconnecting,
@@ -13,6 +13,7 @@ import { LaneConsolePane } from "../LaneConsolePane";
import { api } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { __resetAiTools } from "../../../hooks/useAiTools";
import { __resetChatUi } from "../../../lib/chatUi";
vi.mock("../TerminalView", () => ({
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
@@ -24,8 +25,15 @@ vi.mock("../TerminalView", () => ({
),
}));
vi.mock("../PaneChat", () => ({
PaneChat: ({ sessionId }: { sessionId: string }) => (
<div data-testid="pane-chat" data-session-id={sessionId} />
),
}));
vi.mock("../../../lib/api", () => ({
api: {
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
lanes: {
ensure: vi.fn(),
action: vi.fn(),
@@ -139,6 +147,34 @@ describe("LaneConsolePane", () => {
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
});
it("shows a Claude pane as a chat OVER its terminal when the viewer's Chat UI is on", async () => {
// Unmounting the terminal would drop the attach the chat sends through.
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")).toHaveAttribute("data-session-id", "s-1");
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
__resetChatUi();
});
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
const run = (id: string, laneId: number) => ({
@@ -69,6 +69,7 @@ vi.mock("@xterm/xterm", () => ({
attachCustomKeyEventHandler: keyHandlerMock,
focus: focusMock,
paste: pasteMock,
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
@@ -368,7 +369,9 @@ describe("TerminalView", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 }));
expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
});
it("reports a size change xterm made on its own, with no container resize", async () => {
@@ -385,7 +388,34 @@ describe("TerminalView", () => {
onResizeHandlers.forEach((fn) => fn());
expect(ws.sent).toEqual([]);
await new Promise((r) => setTimeout(r, 200));
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 127, rows: 28 })]);
expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 127, rows: 28, drive: false }),
]);
});
it("follows another browser's size until a keystroke here takes the lead", () => {
// Two browsers of different sizes on one pane: the one not in use renders
// the driver's grid instead of resizing tmux under it.
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
ws.onmessage?.({ data: '{"type":"size","cols":90,"rows":20}' });
expect([termState.cols, termState.rows]).toEqual([90, 20]);
ws.sent.length = 0;
onDataHandlers.forEach((fn) => fn("\x1b[<64;5;5M")); // a wheel notch
expect(ws.sent).toEqual(["\x1b[<64;5;5M"]);
onDataHandlers.forEach((fn) => fn("a"));
expect(ws.sent[1]).toBe(
JSON.stringify({ type: "resize", cols: 90, rows: 20, drive: true })
);
expect(ws.sent[2]).toBe("a");
} finally {
w.mockRestore();
h.mockRestore();
}
});
it("never reports a collapsed console's zero size", () => {
@@ -10,6 +10,8 @@
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default /
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
* A click on an installed row (outside its buttons) opens that tool's settings
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
* working.
@@ -19,12 +21,14 @@
import { Switch } from "../Switch";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { api, type AiTool, type AiToolJob } from "../../lib/api";
import { usePermissions } from "../AuthGate";
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
);
}
export function AiToolsSettings() {
export function AiToolsSettings({
settingsHref,
}: {
/** A tool's own settings page, when it has one. */
settingsHref?: (id: string) => string | undefined;
} = {}) {
const { t } = useTranslation("settings");
const navigate = useNavigate();
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
// Installing, updating and switching tools changes the host: an admin's.
const { isAdmin } = usePermissions();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{updateAllTools.length > 0 && (
{isAdmin && updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
{t("aiTools.updateAll", { count: updateAllTools.length })}
</button>
)}
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
{isAdmin && (
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className={GHOST_BTN}
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
)}
</div>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
const href = settingsHref?.(tool.id);
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
onContextMenu={(e) => ctx.open(e, tool)}
className="px-3 py-2.5"
onClick={(e) => {
// The row's own buttons, switch, links and log keep their click.
if (href && !(e.target as Element).closest("button, a, input, pre"))
navigate(href);
}}
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
>
<div className="flex items-center gap-3">
<div
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
<AiToolIcon tool={tool} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
<span className="truncate">{tool.label}</span>
{href ? (
<Link
to={href}
title={t("aiTools.openSettings", { tool: tool.label })}
className="truncate hover:text-accent hover:underline"
>
{tool.label}
</Link>
) : (
<span className="truncate">{tool.label}</span>
)}
<Homepage tool={tool} />
{isDefault && (
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
</div>
</div>
{/* Fixed slots, so the buttons line up from row to row. */}
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
{isAdmin && (
<>
<div className="flex min-w-20 shrink-0 justify-end">
{tool.update && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className={GHOST_BTN}
>
{t("aiTools.update")}
</button>
)}
</div>
<div className="flex min-w-28 shrink-0 justify-end">
{!isDefault && (
<button
type="button"
disabled={!tool.enabled || busy !== null}
onClick={() => setDefault(tool)}
className={GHOST_BTN}
>
{t("aiTools.setDefault")}
</button>
)}
</div>
<ToolSwitch
tool={tool}
disabled={busy !== null}
onToggle={() => toggle(tool)}
/>
</>
)}
</div>
{logFor(tool.id)}
</li>
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
</ul>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
{ctx.menu.item.update && (
{isAdmin && ctx.menu.item.update && (
<MenuItem
disabled={jobBusy}
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
{t("aiTools.update")}
</MenuItem>
)}
{ctx.menu.item.id !== defaultId && (
{isAdmin && ctx.menu.item.id !== defaultId && (
<MenuItem
disabled={!ctx.menu.item.enabled || busy !== null}
onSelect={pick(setDefault)}
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
{t("aiTools.setDefault")}
</MenuItem>
)}
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
{isAdmin && (
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
</MenuItem>
)}
{ctx.menu.item.path && (
<>
<MenuSeparator />
{isAdmin && <MenuSeparator />}
<MenuItem
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
>
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
</span>
</span>
<Homepage tool={tool} />
{tool.install && (
{isAdmin && tool.install && (
<button
type="button"
disabled={jobBusy}
@@ -3,6 +3,7 @@
* @description Settings → Appearance (`/settings/appearance`): the interface language, 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
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
* 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
@@ -17,6 +18,7 @@ import { useTranslation } from "react-i18next";
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
import { PetSettings } from "./PetSettings";
import { ChatUiSettings } from "./ChatUiSettings";
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
@@ -146,6 +148,8 @@ export function AppearanceSettings() {
</p>
</SettingsSection>
<ChatUiSettings />
<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>
<SettingsRow
@@ -7,7 +7,9 @@
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
* stops the server, which stays stopped until `agenthub start` — or the next
* boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js).
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -74,6 +76,24 @@ export function AutostartSettings() {
}
};
const [uninstalling, setUninstalling] = useState(false);
const uninstall = async () => {
const ok = await confirmDialog({
title: t("uninstall.confirmTitle"),
message: t("uninstall.confirmMessage"),
confirmLabel: t("uninstall.button"),
});
if (!ok) return;
setUninstalling(true);
try {
const { log } = await api.settings.uninstall();
toast.success(t("uninstall.done", { log }));
} catch (err) {
setUninstalling(false);
toast.error(err instanceof Error ? err.message : String(err));
}
};
const note = !status
? undefined
: !status.available
@@ -113,6 +133,21 @@ export function AutostartSettings() {
</button>
}
/>
<SettingsRow
label={t("uninstall.label")}
description={t("uninstall.desc")}
control={
<button
type="button"
data-testid="uninstall-agenthub"
disabled={uninstalling}
onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
>
{t("uninstall.button")}
</button>
}
/>
</SettingsSection>
);
}
@@ -0,0 +1,202 @@
/**
* @file BackupSettings.tsx
* @description Settings → Data → Backup: the daily database backup of
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
* while it runs, then toasts the outcome) and the newest backup in the folder.
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
* field the environment set is read-only. Restoring is manual and the card says
* how.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, DatabaseBackup } from "lucide-react";
import { api } from "../../lib/api";
import type { BackupState } from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { Select } from "../Select";
import { SettingsSection } from "./SettingsRow";
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
export function BackupSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<BackupState | null>(null);
const [dir, setDir] = useState("");
const [hour, setHour] = useState(2);
const [keep, setKeep] = useState("7");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const apply = (s: BackupState) => {
setState(s);
setDir(s.dir);
setHour(s.hour);
setKeep(String(s.keep));
};
useEffect(() => {
api.settings.backup
.get()
.then(apply)
.catch(() => undefined);
}, []);
// A backup takes tens of seconds on a large database: poll until it ends.
const running = !!state?.running;
useEffect(() => {
if (!running) return;
const id = window.setInterval(() => {
api.settings.backup
.get()
.then((s) => {
setState(s); // the form keeps its drafts
if (s.running || !s.last) return;
if (s.last.ok) toast.success(t("backup.done", { file: s.last.file }));
else toast.error(t("backup.failed", { error: s.last.error }));
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [running, t]);
const keepN = Number(keep);
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
const patch: { dir?: string; hour?: number; keep?: number } = {};
if (state) {
if (dir.trim() !== state.dir) patch.dir = dir.trim();
if (hour !== state.hour) patch.hour = hour;
if (keepN !== state.keep) patch.keep = keepN;
}
const changed = Object.keys(patch).length > 0;
async function save() {
setSaving(true);
setError(null);
try {
apply(await api.settings.backup.save(patch));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function runNow() {
try {
setState(await api.settings.backup.run());
} catch (err) {
toast.error(err);
}
}
const owned = state?.env_owned;
const newest = state?.files[0];
const total = state?.files.reduce((n, f) => n + f.size, 0) ?? 0;
return (
<SettingsSection
id="backup"
title={
<span className="flex items-center gap-2">
<DatabaseBackup className="w-4 h-4 text-fg-muted" />
{t("backup.title")}
</span>
}
description={t("backup.description")}
>
<div className="py-3 space-y-3">
<label className="block text-xs text-fg-muted">
{t("backup.folder")}
<input
className="input mt-1 w-full font-mono text-xs"
placeholder="/mnt/hdd/agenthub-backups"
value={dir}
disabled={!state || owned?.dir}
onChange={(e) => {
setDir(e.target.value);
setError(null);
}}
/>
</label>
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
<div className="flex flex-wrap items-end gap-3">
<div className="w-28">
<Select
value={String(hour)}
onChange={(v) => setHour(Number(v))}
label={t("backup.hour")}
disabled={!state || owned?.hour}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
<label className="text-xs text-fg-muted">
{t("backup.keep")}
<input
type="number"
min={1}
max={365}
step={1}
className="input mt-1 block w-24 text-sm"
value={keep}
disabled={!state || owned?.keep}
onChange={(e) => setKeep(e.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || !keepValid}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
<button
type="button"
className="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!state?.dir || running || changed}
onClick={() => void runNow()}
>
<DatabaseBackup className={`w-4 h-4 ${running ? "animate-pulse" : ""}`} />{" "}
{running ? t("backup.running") : t("backup.runNow")}
</button>
</div>
{error && <p className="text-xs text-status-danger">{error}</p>}
<p className="text-xs text-fg-secondary">
{newest
? t("backup.newest", {
time: formatDateTime(newest.mtime),
size: formatBytes(newest.size),
n: state?.files.length,
total: formatBytes(total),
})
: state?.dir
? t("backup.none")
: t("backup.off")}
</p>
{state?.last && !state.last.ok && (
<p className="text-xs text-status-danger">
{t("backup.lastFailed", {
time: formatDateTime(state.last.at),
error: state.last.error,
})}
</p>
)}
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
{(owned?.dir || owned?.hour || owned?.keep) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
</div>
</SettingsSection>
);
}
@@ -0,0 +1,33 @@
/**
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
* account on a shared dashboard keeps its own choice on any browser.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { saveChatUi, useChatUi } from "../../lib/chatUi";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChatUiSettings() {
const { t } = useTranslation("settings");
const chatUi = useChatUi();
return (
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
<SettingsSwitchRow
label={t("chatUi.label")}
description={t("chatUi.labelDesc")}
checked={chatUi}
onChange={(on) =>
saveChatUi(on).catch((err: unknown) =>
toast.error(
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
)
)
}
/>
</SettingsSection>
);
}
+47 -39
View File
@@ -33,6 +33,7 @@ import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets";
import type { PetView } from "../../lib/api";
import { usePermissions } from "../AuthGate";
const PREVIEW = 72;
@@ -121,6 +122,8 @@ export function PetSettings() {
const { t } = useTranslation("settings");
const worn = useWornPet();
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
// Pets are stored for the whole install: adding or deleting one is an admin's.
const { isAdmin } = usePermissions();
const imageInput = useRef<HTMLInputElement>(null);
const bundleInput = useRef<HTMLInputElement>(null);
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
@@ -219,6 +222,7 @@ export function PetSettings() {
/>
{list?.featured.map((f) => {
const have = installed.get(f.id);
if (!have && !isAdmin) return null;
return (
<PetCard
key={f.id}
@@ -252,50 +256,54 @@ export function PetSettings() {
disabled={busy !== null}
preview={<Preview pet={p} size={PREVIEW} />}
onPick={() => wear(p)}
onDelete={() => void remove(p)}
onDelete={isAdmin ? () => void remove(p) : undefined}
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
/>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
{isAdmin && (
<>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => imageInput.current?.click()}
>
{busy === "image" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("sagi.pet.upload")}
</button>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy !== null}
onClick={() => bundleInput.current?.click()}
>
{busy === "bundle" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PackageOpen className="h-3.5 w-3.5" />
)}
{t("sagi.pet.import")}
</button>
<a
href="https://codex-pets.net/"
target="_blank"
rel="noreferrer noopener"
className="btn-ghost text-xs"
>
<ExternalLink className="h-3.5 w-3.5" />
{t("sagi.pet.browse")}
</a>
</div>
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
</>
)}
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
@@ -5,7 +5,8 @@
* a homepage link, Refresh re-detects, every updater gets Update without a version
* lookup, and Install/Update confirm the exact command, then show the polled
* job log, folded once it is done. An installed row's right-click menu runs
* the same Set default / enable switch as its controls.
* the same Set default / enable switch as its controls. A click on a row (or
* its name link) opens the tool's settings page; its buttons do not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,6 +14,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { AiToolsSettings } from "../AiToolsSettings";
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -92,9 +94,48 @@ const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
...over,
});
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname}</span>;
}
describe("AiToolsSettings", () => {
it("opens a tool's settings from its row or name, never from its buttons", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/settings/agents"]}>
<Routes>
<Route
path="*"
element={
<>
<AiToolsSettings
settingsHref={(id) => (id === "gemini" ? `/settings/agents/${id}` : undefined)}
/>
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByText("Set default"));
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
await user.click(gemini);
expect(screen.getByTestId("where").textContent).toBe("/settings/agents/gemini");
expect(within(gemini).getByRole("link", { name: "Gemini" })).toHaveAttribute(
"href",
"/settings/agents/gemini"
);
// A tool without a page has no link.
expect(
within(screen.getByTestId("ai-tool-claude")).queryByRole("link", { name: "Claude Code" })
).toBeNull();
});
it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
const claude = screen.getByTestId("ai-tool-claude");
@@ -107,7 +148,7 @@ describe("AiToolsSettings", () => {
it("toggles a tool and sets the default through the API", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
@@ -121,7 +162,7 @@ describe("AiToolsSettings", () => {
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude");
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
@@ -129,7 +170,7 @@ describe("AiToolsSettings", () => {
it("sets the default and disables a tool from its right-click menu", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.pointer({ keys: "[MouseRight]", target: gemini });
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
@@ -182,7 +223,7 @@ describe("AiToolsSettings — install and update", () => {
vi.mocked(api.aiTools.job)
.mockResolvedValueOnce(null)
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
const qwen = await screen.findByTestId("ai-tool-qwen");
await user.click(within(qwen).getByRole("button", { name: "Install" }));
@@ -212,7 +253,7 @@ describe("AiToolsSettings — install and update", () => {
it("names the vendor script for a script install", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable();
const cursor = await screen.findByTestId("ai-tool-cursor");
await user.click(within(cursor).getByRole("button", { name: "Install" }));
@@ -222,7 +263,7 @@ describe("AiToolsSettings — install and update", () => {
});
it("offers Update on every installed tool with an updater", async () => {
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
for (const id of ["claude", "codex", "gemini"]) {
const row = await screen.findByTestId(`ai-tool-${id}`);
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
@@ -231,7 +272,7 @@ describe("AiToolsSettings — install and update", () => {
it("Update all lists every installed tool's updater", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
const cmd = screen.getByTestId("ai-tool-confirm-command");
expect(cmd).toHaveTextContent("claude update");
@@ -241,13 +282,13 @@ describe("AiToolsSettings — install and update", () => {
it("hides Update all when no tool has an updater", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude");
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
const { container } = render(<AiToolsSettings />, { wrapper: MemoryRouter });
await openAvailable();
await screen.findByTestId("ai-tool-qwen");
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
@@ -2,7 +2,8 @@
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
* first, says when start-at-login will bring the dashboard back, and only a
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
* switch, which goes straight to PUT /api/settings/tray.
* switch, which goes straight to PUT /api/settings/tray, and Uninstall
* AgentHub, which also asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -17,6 +18,7 @@ vi.mock("../../../lib/api", () => ({
autostart: { get: vi.fn(), set: vi.fn() },
tray: { get: vi.fn(), set: vi.fn() },
quit: vi.fn(),
uninstall: vi.fn(),
},
},
}));
@@ -65,3 +67,30 @@ describe("Quit AgentHub", () => {
await waitFor(() => expect(icon).toBeChecked());
});
});
describe("Uninstall AgentHub", () => {
beforeEach(() => {
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
vi.mocked(api.settings.uninstall)
.mockReset()
.mockResolvedValue({ ok: true, log: "/tmp/agenthub-uninstall-1.log" });
});
it("does nothing when the ask is cancelled", async () => {
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
expect(api.settings.uninstall).not.toHaveBeenCalled();
});
it("uninstalls once confirmed, saying the database goes too", async () => {
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
render(<AutostartSettings />);
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
await waitFor(() => expect(api.settings.uninstall).toHaveBeenCalledTimes(1));
expect(ask.mock.calls[0]![0].message).toMatch(/database/);
expect(screen.getByTestId("uninstall-agenthub")).toBeDisabled();
});
});
@@ -0,0 +1,65 @@
/**
* @file BackupSettings.test.tsx
* @description Settings → Data → Backup: a save sends only the fields that
* changed, "Back up now" is off until a folder is saved, and the newest backup
* in the folder is shown.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { backup } = vi.hoisted(() => ({
backup: { get: vi.fn(), save: vi.fn(), run: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({ api: { settings: { backup } } }));
import { BackupSettings } from "../BackupSettings";
const off = {
dir: "",
hour: 2,
keep: 7,
running: false,
last: null,
files: [],
dir_error: null,
env_owned: { dir: false, hour: false, keep: false },
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("BackupSettings", () => {
it("saves only the folder when only the folder changed, then allows a run", async () => {
const user = userEvent.setup();
backup.get.mockResolvedValue(off);
backup.save.mockImplementation((p) => Promise.resolve({ ok: true, ...off, ...p }));
render(<BackupSettings />);
const run = await screen.findByRole("button", { name: /Back up now/ });
expect(run).toBeDisabled();
expect(screen.getByText("Backups are off.")).toBeInTheDocument();
await user.type(screen.getByLabelText("Backup folder"), "/mnt/hdd/b");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ dir: "/mnt/hdd/b" }));
await waitFor(() => expect(run).toBeEnabled());
});
it("shows the newest backup", async () => {
backup.get.mockResolvedValue({
...off,
dir: "/mnt/hdd/b",
files: [
{ name: "agenthub-20261008-020000.db", size: 2048, mtime: "2026-10-08T02:00:30Z" },
{ name: "agenthub-20261007-020000.db", size: 1024, mtime: "2026-10-07T02:00:30Z" },
],
});
render(<BackupSettings />);
expect(await screen.findByText(/2\.0 KB · 2 kept, 3\.0 KB in all/)).toBeInTheDocument();
});
});