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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user