Compare commits

..

2 Commits

Author SHA1 Message Date
nntrivi2001 dd9b23106c feat: v2.5.4 — Query Explorer folded into Sessions, Windows picker and paths
- Sessions page tabs: Sessions · Events · Agents. QueryExplorer.tsx became
  AgentsTable.tsx (agents only — sessions and events already had tabs);
  /query redirects to /sessions?view=agents and leaves the sidebar.
  GET /api/query is unchanged and still answers all three entities.
- Windows picker opens in front: -EncodedCommand script spends the hidden
  first ShowWindow that windowsHide gives the process, then brings the TopMost
  owner forward via AttachThreadInput (v2.5.3's owner was itself hidden).
- Session page Resume/Fork/Open pane: owningLane and sessionWorktreeName
  match Windows folders (either separator), with a test.
2026-10-06 21:56:51 +07:00
nntrivi2001 af014d9829 fix: v2.5.3 — Windows workspace folders, picker on top, no console flashes
A workspace could not be created on Windows: "cwd must be an absolute path"
for D:\…, because createLane/canonicalCwd tested for a leading "/".

- lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a
  missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too
- native-dialog.js: the Windows picker gets an invisible TopMost owner form,
  shown first — an ownerless dialog from the background server opened behind
  the browser, or hidden
- windows-hide.js: every child process on Windows starts with windowsHide,
  required first by the server and the detached scripts (a console window
  flashed per git/npm call)
- Confirm/update/clear-data modals and the quota panel scroll instead of
  overflowing a short window
2026-10-06 20:22:34 +07:00
38 changed files with 304 additions and 162 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -205,7 +205,8 @@ client/
│ │ │ │
│ ├── pages/ # Route pages │ ├── pages/ # Route pages
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards │ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
│ │ ├── QueryExplorer.tsx # /query: filter sessions / events / agents, sort, page, export CSV/JSON (GET /api/query) │ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID │ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action │ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload │ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
@@ -504,7 +505,8 @@ function App() {
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="sessions" element={<History />} /> <Route path="sessions" element={<History />} />
<Route path="sessions/:id" element={<SessionDetail />} /> <Route path="sessions/:id" element={<SessionDetail />} />
<Route path="query" element={<QueryExplorer />} /> {/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
{/* legacy path - the activity feed is now a tab on /sessions */} {/* legacy path - the activity feed is now a tab on /sessions */}
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} /> <Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} /> <Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
+1 -11
View File
@@ -85,9 +85,6 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() => const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView })) import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
); );
const QueryExplorer = lazy(() =>
import("./pages/QueryExplorer").then((m) => ({ default: m.QueryExplorer }))
);
function PageFallback() { function PageFallback() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -172,14 +169,7 @@ export default function App() {
</Page> </Page>
} }
/> />
<Route <Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
path="query"
element={
<Page>
<QueryExplorer />
</Page>
}
/>
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} /> <Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
<Route <Route
path="workflows" path="workflows"
+2 -2
View File
@@ -85,14 +85,14 @@ export function ConfirmModal({
> >
<div <div
ref={panelRef} ref={panelRef}
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40" className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={message ? messageId : undefined} aria-describedby={message ? messageId : undefined}
> >
<div className="flex items-start gap-3 p-5"> <div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
{destructive && ( {destructive && (
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0"> <div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden /> <AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
-2
View File
@@ -28,8 +28,6 @@ export function DocumentTitle() {
title = t("workspace"); title = t("workspace");
} else if (path.startsWith("/settings")) { } else if (path.startsWith("/settings")) {
title = t("settings"); title = t("settings");
} else if (path.startsWith("/query")) {
title = t("query");
} else if (path !== "/") { } else if (path !== "/") {
title = t("notFound"); title = t("notFound");
} }
-2
View File
@@ -69,7 +69,6 @@ import {
ChevronUp, ChevronUp,
ChevronDown, ChevronDown,
FolderTree, FolderTree,
TableProperties,
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api"; import { api } from "../lib/api";
@@ -84,7 +83,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [ const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" }, { to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" }, { to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/query", icon: TableProperties, key: "nav:query" },
] as const; ] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is // SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
+1 -1
View File
@@ -208,7 +208,7 @@ function QuotaPanel({
onMouseLeave={onLeave} onMouseLeave={onLeave}
onFocus={onEnter} onFocus={onEnter}
onBlur={onLeave} onBlur={onLeave}
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20" className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
> >
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div> <div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
{groups.map(([id, list]) => ( {groups.map(([id, list]) => (
+2 -2
View File
@@ -330,7 +330,7 @@ export function UpdateNotifier() {
longest label is a whole sentence ("restarting the dashboard…"). */} longest label is a whole sentence ("restarting the dashboard…"). */}
<div <div
ref={panelRef} ref={panelRef}
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden" className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
> >
{/* Header */} {/* Header */}
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border"> <div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
@@ -370,7 +370,7 @@ export function UpdateNotifier() {
</div> </div>
{/* Body */} {/* Body */}
<div className="px-5 py-4 space-y-3"> <div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
<p className="text-sm text-fg-secondary leading-relaxed"> <p className="text-sm text-fg-secondary leading-relaxed">
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")} {pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
</p> </p>
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
aria-modal="true" aria-modal="true"
aria-labelledby="cc-confirm-delete-title" aria-labelledby="cc-confirm-delete-title"
aria-describedby="cc-confirm-delete-body" aria-describedby="cc-confirm-delete-body"
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1" className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2"> <div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
{t("edit.confirmDelete")} {t("edit.confirmDelete")}
</span> </span>
</div> </div>
<div className="p-4 space-y-3"> <div className="min-h-0 overflow-y-auto p-4 space-y-3">
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed"> <p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
{t("edit.confirmDeleteBody")} {t("edit.confirmDeleteBody")}
</p> </p>
@@ -26,15 +26,20 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession"; import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList"; import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
/** The workspace of this tool whose folder holds `cwd` most deeply — the /** The workspace of this tool whose folder holds `cwd` most deeply — the
* same path-boundary rule the server binds hook sessions with. */ * same path-boundary rule the server binds hook sessions with. */
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null { export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
let best: Lane | null = null; let best: Lane | null = null;
const target = strip(cwd);
for (const l of lanes) { for (const l of lanes) {
if (l.tool !== tool) continue; if (l.tool !== tool) continue;
const root = l.cwd.replace(/\/+$/, ""); const root = strip(l.cwd);
if (cwd !== root && !cwd.startsWith(root + "/")) continue; if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l; continue;
if (!best || root.length > strip(best.cwd).length) best = l;
} }
return best; return best;
} }
@@ -74,7 +79,7 @@ export function SessionResumeButton({
owningLane(session.cwd, tool, lanes)); owningLane(session.cwd, tool, lanes));
if (!lane || runs === null) return null; if (!lane || runs === null) return null;
const run = liveRunFor(lane, runs, session.id); const run = liveRunFor(lane, runs, session.id);
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); const same = (a: string, b: string) => strip(a) === strip(b);
// Its own worktree counts too: the server resumes it there (`claude // Its own worktree counts too: the server resumes it there (`claude
// --worktree` sessions), and refuses one whose worktree is gone. // --worktree` sessions), and refuses one whose worktree is gone.
const resumable = const resumable =
@@ -66,9 +66,10 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
/** The name of the git worktree a session ran in (`claude --worktree` puts it /** The name of the git worktree a session ran in (`claude --worktree` puts it
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */ * at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
export function sessionWorktreeName(lane: Lane, s: Session): string | null { export function sessionWorktreeName(lane: Lane, s: Session): string | null {
const strip = (p: string) => p.replace(/\/+$/, ""); // Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
const strip = (p: string) => p.replace(/[\\/]+$/, "");
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null; if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null; return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
} }
/** A session row's dot: waiting for the user, failed, held by a live pane, or /** A session row's dot: waiting for the user, failed, held by a live pane, or
@@ -0,0 +1,33 @@
/**
* @file Tests that a session finds its workspace and its `claude --worktree`
* name by folder on Windows paths (`D:\…`) as well as POSIX ones — the
* transcript page's Resume/Fork/Open pane hid on Windows when they matched
* on "/" only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import type { Lane, Session } from "../../../lib/types";
import { owningLane } from "../SessionResumeButton";
import { sessionWorktreeName } from "../WorkspaceSessionList";
const lane = (id: number, cwd: string) => ({ id, cwd, tool: "claude" }) as Lane;
describe("session paths", () => {
it("finds the deepest owning workspace with either separator", () => {
const lanes = [lane(1, "D:\\Project"), lane(2, "D:\\Project\\Hobby\\"), lane(3, "/home/u/app")];
expect(owningLane("D:\\Project\\Hobby\\tri-vi-portfolio", "claude", lanes)?.id).toBe(2);
expect(owningLane("D:\\Project\\Hobby", "claude", lanes)?.id).toBe(2);
expect(owningLane("D:\\ProjectX", "claude", lanes)).toBeNull();
expect(owningLane("/home/u/app/src", "claude", lanes)?.id).toBe(3);
});
it("reads a worktree name from a Windows or POSIX session folder", () => {
const at = (cwd: string) => ({ cwd }) as Session;
expect(
sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\.claude\\worktrees\\fix-a"))
).toBe("fix-a");
expect(sessionWorktreeName(lane(1, "/r"), at("/r/.claude/worktrees/fix-b/"))).toBe("fix-b");
expect(sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\"))).toBeNull();
});
});
@@ -79,12 +79,7 @@
"rangeFrom": "From", "rangeFrom": "From",
"rangeTo": "To", "rangeTo": "To",
"query": { "query": {
"title": "Query Explorer",
"subtitle": "Filter sessions, events and agents, then export what you see.",
"entity": "Data",
"entities": { "entities": {
"sessions": "Sessions",
"events": "Events",
"agents": "Agents" "agents": "Agents"
}, },
"search": "Search", "search": "Search",
+1 -1
View File
@@ -1,6 +1,7 @@
{ {
"activityFeed": "Activity Feed", "activityFeed": "Activity Feed",
"agentBoard": "Kanban Board", "agentBoard": "Kanban Board",
"agents": "Agents",
"analytics": "Analytics", "analytics": "Analytics",
"avg": "avg", "avg": "avg",
"brand": "AgentHub", "brand": "AgentHub",
@@ -88,7 +89,6 @@
} }
}, },
"version": "Version", "version": "Version",
"query": "Query Explorer",
"openMenu": "Open menu", "openMenu": "Open menu",
"resizeSidebar": "Resize sidebar (double-click to reset)", "resizeSidebar": "Resize sidebar (double-click to reset)",
"closeMenu": "Close menu", "closeMenu": "Close menu",
@@ -79,12 +79,7 @@
"rangeFrom": "Từ ngày", "rangeFrom": "Từ ngày",
"rangeTo": "Đến ngày", "rangeTo": "Đến ngày",
"query": { "query": {
"title": "Truy vấn dữ liệu",
"subtitle": "Lọc phiên, sự kiện và agent, rồi xuất đúng những gì đang thấy.",
"entity": "Dữ liệu",
"entities": { "entities": {
"sessions": "Phiên",
"events": "Sự kiện",
"agents": "Agent" "agents": "Agent"
}, },
"search": "Tìm kiếm", "search": "Tìm kiếm",
+1 -1
View File
@@ -1,6 +1,7 @@
{ {
"activityFeed": "Luồng hoạt động", "activityFeed": "Luồng hoạt động",
"agentBoard": "Bảng Kanban", "agentBoard": "Bảng Kanban",
"agents": "Agent",
"analytics": "Phân tích", "analytics": "Phân tích",
"avg": "TB", "avg": "TB",
"brand": "AgentHub", "brand": "AgentHub",
@@ -88,7 +89,6 @@
} }
}, },
"version": "Phiên bản", "version": "Phiên bản",
"query": "Truy vấn",
"openMenu": "Mở menu", "openMenu": "Mở menu",
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)", "resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
"closeMenu": "Đóng menu", "closeMenu": "Đóng menu",
@@ -1,9 +1,11 @@
/** /**
* @file QueryExplorer.tsx * @file AgentsTable.tsx
* @description `/query`: filter sessions, events or agents (free text, AI tool, * @description The Sessions page's **Agents** tab (`/sessions?view=agents`,
* status, local date range, folder subtree, plus the global data scope), sort * where the old `/query` redirects): filter agents (free text, AI tool, status,
* by a column the server allows, page through the rows, and export the whole * local date range, folder subtree, plus the global data scope), sort by a
* filtered set as CSV or JSON (`GET /api/query`, capped server-side). * column the server allows, page through the rows, and export the whole
* filtered set as CSV or JSON (`GET /api/query?entity=agents`, capped
* server-side). Sessions and events have their own tabs beside it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -16,21 +18,15 @@ import { applyRange } from "../lib/dateRange";
import { useDataScope } from "../lib/dataScope"; import { useDataScope } from "../lib/dataScope";
import { useAiTools } from "../hooks/useAiTools"; import { useAiTools } from "../hooks/useAiTools";
import { Select } from "../components/Select"; import { Select } from "../components/Select";
import type { QueryEntity, QueryPage } from "../lib/types"; import type { QueryPage } from "../lib/types";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const ENTITIES: QueryEntity[] = ["sessions", "events", "agents"]; const STATUSES = ["working", "waiting", "completed", "error"];
const STATUSES: Record<QueryEntity, string[]> = {
sessions: ["active", "completed", "error", "abandoned"],
events: [],
agents: ["working", "waiting", "completed", "error"],
};
export function QueryExplorer() { export function AgentsTable() {
const { t } = useTranslation("analytics"); const { t } = useTranslation("analytics");
const aiTools = useAiTools(); const aiTools = useAiTools();
const [scope] = useDataScope(); const [scope] = useDataScope();
const [entity, setEntity] = useState<QueryEntity>("sessions");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [cwd, setCwd] = useState(""); const [cwd, setCwd] = useState("");
const [typed, setTyped] = useState({ q: "", cwd: "" }); const [typed, setTyped] = useState({ q: "", cwd: "" });
@@ -63,7 +59,7 @@ export function QueryExplorer() {
}; };
const filters = useMemo(() => { const filters = useMemo(() => {
const p = new URLSearchParams({ entity }); const p = new URLSearchParams({ entity: "agents" });
if (typed.q) p.set("q", typed.q); if (typed.q) p.set("q", typed.q);
if (typed.cwd) p.set("cwd", typed.cwd); if (typed.cwd) p.set("cwd", typed.cwd);
if (tool) p.set("tool", tool); if (tool) p.set("tool", tool);
@@ -73,7 +69,7 @@ export function QueryExplorer() {
p.set("dir", dir); p.set("dir", dir);
} }
return applyRange(p, { from: from || undefined, to: to || undefined }); return applyRange(p, { from: from || undefined, to: to || undefined });
}, [entity, typed, tool, status, sort, dir, from, to]); }, [typed, tool, status, sort, dir, from, to]);
useEffect(() => { useEffect(() => {
let live = true; let live = true;
@@ -93,14 +89,6 @@ export function QueryExplorer() {
}; };
}, [filters, offset, scope]); }, [filters, offset, scope]);
const switchEntity = (v: QueryEntity) => {
setEntity(v);
setStatus("");
setSort("");
setDir("desc");
setOffset(0);
setPage(null);
};
const reset = () => { const reset = () => {
setQ(""); setQ("");
setCwd(""); setCwd("");
@@ -122,24 +110,13 @@ export function QueryExplorer() {
const total = page?.total ?? 0; const total = page?.total ?? 0;
const cap = page?.export_cap ?? 50_000; const cap = page?.export_cap ?? 50_000;
const statuses = STATUSES[entity];
return ( return (
<div className="animate-fade-in space-y-5"> <div className="animate-fade-in space-y-5">
<div> {/* The tab bar names the view. */}
<h1 className="text-lg font-semibold text-fg-primary">{t("query.title")}</h1> <h1 className="sr-only">{t("query.entities.agents")}</h1>
<p className="text-sm text-fg-muted">{t("query.subtitle")}</p>
</div>
<div className="card p-4 flex flex-wrap items-end gap-3"> <div className="card p-4 flex flex-wrap items-end gap-3">
<div className="w-36">
<Select
value={entity}
onChange={switchEntity}
label={t("query.entity")}
options={ENTITIES.map((v) => ({ value: v, label: t(`query.entities.${v}`) }))}
/>
</div>
<input <input
className="input text-sm w-56" className="input text-sm w-56"
value={q} value={q}
@@ -158,19 +135,17 @@ export function QueryExplorer() {
]} ]}
/> />
</div> </div>
{statuses.length > 0 && ( <div className="w-40">
<div className="w-40"> <Select
<Select value={status}
value={status} onChange={fresh(setStatus)}
onChange={fresh(setStatus)} label={t("query.status")}
label={t("query.status")} options={[
options={[ { value: "", label: t("query.allStatuses") },
{ value: "", label: t("query.allStatuses") }, ...STATUSES.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
...statuses.map((s) => ({ value: s, label: t(`common:status.${s}`) })), ]}
]} />
/> </div>
</div>
)}
<input <input
type="date" type="date"
className="input text-sm" className="input text-sm"
@@ -276,8 +251,7 @@ export function QueryExplorer() {
<tr key={`${r.id}-${i}`} className="hover:bg-surface-4"> <tr key={`${r.id}-${i}`} className="hover:bg-surface-4">
{page.columns.map((c) => { {page.columns.map((c) => {
const v = r[c]; const v = r[c];
const sid = const sid = c === "session_id" ? v : null;
(entity === "sessions" && c === "id") || c === "session_id" ? v : null;
return ( return (
<td <td
key={c} key={c}
+8 -5
View File
@@ -1,17 +1,19 @@
/** /**
* @file History.tsx * @file History.tsx
* @description Tabbed shell for /sessions that hosts the two views over the same * @description Tabbed shell for /sessions that hosts the three views over the
* event pipeline: the per-session list and the raw activity feed. The active tab * same data: the per-session list, the raw activity feed and the agents table.
* lives in the `?view=` query param, so `/activity` can redirect into it. * The active tab lives in the `?view=` query param, so `/activity` and the old
* `/query` Query Explorer can redirect into it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Activity, FolderOpen } from "lucide-react"; import { Activity, Bot, FolderOpen } from "lucide-react";
import { PageTabs } from "../components/PageTabs"; import { PageTabs } from "../components/PageTabs";
import { Sessions } from "./Sessions"; import { Sessions } from "./Sessions";
import { ActivityFeed } from "./ActivityFeed"; import { ActivityFeed } from "./ActivityFeed";
import { AgentsTable } from "./AgentsTable";
interface HistoryView { interface HistoryView {
id: string; id: string;
@@ -21,7 +23,7 @@ interface HistoryView {
} }
/** /**
* Sessions page shell: grouped sessions and the raw event stream as sibling tabs. * Sessions page shell: sessions, the raw event stream and agents as sibling tabs.
* @returns The tab bar plus the active view. * @returns The tab bar plus the active view.
*/ */
export function History() { export function History() {
@@ -31,6 +33,7 @@ export function History() {
const views: HistoryView[] = [ const views: HistoryView[] = [
{ id: "sessions", label: t("sessions"), Icon: FolderOpen, Panel: Sessions }, { id: "sessions", label: t("sessions"), Icon: FolderOpen, Panel: Sessions },
{ id: "events", label: t("activityFeed"), Icon: Activity, Panel: ActivityFeed }, { id: "events", label: t("activityFeed"), Icon: Activity, Panel: ActivityFeed },
{ id: "agents", label: t("agents"), Icon: Bot, Panel: AgentsTable },
]; ];
const defaultView = views[0]!; const defaultView = views[0]!;
const active = views.find((v) => v.id === params.get("view")) ?? defaultView; const active = views.find((v) => v.id === params.get("view")) ?? defaultView;
+1 -1
View File
@@ -1229,7 +1229,7 @@ function ClearDataDialog({
aria-modal="true" aria-modal="true"
aria-labelledby="clear-data-title" aria-labelledby="clear-data-title"
aria-describedby="clear-data-body" aria-describedby="clear-data-body"
className="card w-full max-w-md space-y-4 border-status-danger/40 p-5" className="card max-h-full w-full max-w-md space-y-4 overflow-y-auto border-status-danger/40 p-5"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 <h2
@@ -1,8 +1,8 @@
/** /**
* @file QueryExplorer.test.tsx * @file AgentsTable.test.tsx
* @description The Query Explorer page: it renders a page of rows with session * @description The Sessions page's Agents tab: it asks only for agents, renders
* links, sorts by an allowed column on a header click (flipping on a second), * a page of rows with session links, sorts by an allowed column on a header
* switches entity, and points both export links at the current filter. * click (flipping on a second), and points both export links at the filter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -23,19 +23,8 @@ vi.mock("../../hooks/useAiTools", () => ({
useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }), useAiTools: () => ({ tools: [{ id: "codex", label: "Codex" }] }),
})); }));
import { QueryExplorer } from "../QueryExplorer"; import { AgentsTable } from "../AgentsTable";
const page = (entity: string, rows: Record<string, string>[]) => ({
entity,
columns: Object.keys(rows[0] ?? {}),
sortable: entity === "sessions" ? ["started_at", "name"] : ["created_at"],
statuses: [],
rows,
total: rows.length,
limit: 50,
offset: 0,
export_cap: 50000,
});
const lastParams = () => { const lastParams = () => {
const calls = query.page.mock.calls; const calls = query.page.mock.calls;
return calls[calls.length - 1]![0] as URLSearchParams; return calls[calls.length - 1]![0] as URLSearchParams;
@@ -44,28 +33,34 @@ const lastParams = () => {
beforeEach(() => { beforeEach(() => {
i18n.changeLanguage("en"); i18n.changeLanguage("en");
vi.clearAllMocks(); vi.clearAllMocks();
query.page.mockImplementation((p: URLSearchParams) => query.page.mockResolvedValue({
Promise.resolve( entity: "agents",
p.get("entity") === "events" columns: ["id", "session_id", "name", "started_at"],
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }]) sortable: ["started_at", "name"],
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }]) statuses: ["working", "waiting", "completed", "error"],
) rows: [{ id: "a1", session_id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }],
); total: 1,
limit: 50,
offset: 0,
export_cap: 50000,
});
}); });
describe("QueryExplorer", () => { describe("AgentsTable", () => {
it("shows rows, sorts by an allowed column and exports the filter", async () => { it("lists agents, sorts by an allowed column and exports the filter", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render( render(
<MemoryRouter> <MemoryRouter>
<QueryExplorer /> <AgentsTable />
</MemoryRouter> </MemoryRouter>
); );
expect(await screen.findByText("first")).toBeInTheDocument(); expect(await screen.findByText("first")).toBeInTheDocument();
expect(lastParams().get("entity")).toBe("agents");
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute( expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
"href", "href",
"/sessions/sess-abcdef12" "/sessions/sess-abcdef12"
); );
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "name" })); await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("sort")).toBe("name")); await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
expect(lastParams().get("dir")).toBe("desc"); expect(lastParams().get("dir")).toBe("desc");
@@ -74,22 +69,8 @@ describe("QueryExplorer", () => {
// A column outside `sortable` has no sort button. // A column outside `sortable` has no sort button.
expect(screen.queryByRole("button", { name: "id" })).toBeNull(); expect(screen.queryByRole("button", { name: "id" })).toBeNull();
const csv = screen.getByRole("link", { name: /Export CSV/ }); const csv = screen.getByRole("link", { name: /Export CSV/ });
expect(csv.getAttribute("href")).toContain("entity=agents");
expect(csv.getAttribute("href")).toContain("sort=name"); expect(csv.getAttribute("href")).toContain("sort=name");
expect(csv.getAttribute("href")).toContain("format=csv"); expect(csv.getAttribute("href")).toContain("format=csv");
}); });
it("switches entity and drops the status filter for events", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
</MemoryRouter>
);
await screen.findByText("first");
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /^Data/ }));
await user.click(screen.getByRole("option", { name: "Events" }));
await waitFor(() => expect(lastParams().get("entity")).toBe("events"));
expect(screen.queryByRole("button", { name: /Status/ })).toBeNull();
});
}); });
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<p <p
class="text-[10px] text-status-warning/90 mt-1" class="text-[10px] text-status-warning/90 mt-1"
> >
UI build v2.5.2 (rebuild client to match) UI build v2.5.4 (rebuild client to match)
</p> </p>
</div> </div>
<div <div
+2
View File
@@ -2531,6 +2531,8 @@ A snapshot freezes a session as it is at that moment into one immutable `session
### Query Explorer ### Query Explorer
The dashboard's Sessions page reads its **Agents** tab (`/sessions?view=agents`; the old `/query` page redirects there) from `entity=agents`; sessions and events have their own tabs over `GET /api/sessions` and `GET /api/events`. The route still answers all three entities for scripts and exports.
```http ```http
GET /api/query?entity=sessions|events|agents&q=&tool=&status=&cwd=&from=&to=&tz_offset=&sources=&sort=&dir=asc|desc&limit=&offset=&format=csv|json GET /api/query?entity=sessions|events|agents&q=&tool=&status=&cwd=&from=&to=&tz_offset=&sources=&sort=&dir=asc|desc&limit=&offset=&format=csv|json
``` ```
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.5.2 version: 2.5.4
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.5.2", "version": "2.5.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.5.2", "version": "2.5.4",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.5.2", "version": "2.5.4",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+1
View File
@@ -22,6 +22,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs"); const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate"); const { rotateIfLarge } = require("./log-rotate");
const path = require("path"); const path = require("path");
+1
View File
@@ -26,6 +26,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs"); const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate"); const { rotateIfLarge } = require("./log-rotate");
const os = require("os"); const os = require("os");
+1
View File
@@ -31,6 +31,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs"); const fs = require("fs");
const { rotateIfLarge } = require("./log-rotate"); const { rotateIfLarge } = require("./log-rotate");
const path = require("path"); const path = require("path");
+1
View File
@@ -7,6 +7,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const [, , pidArg, entry] = process.argv; const [, , pidArg, entry] = process.argv;
+1 -1
View File
@@ -463,7 +463,7 @@ The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`),
| `POST` | `/api/snapshots` | `{session_id, redact?: {file_paths?}}` → freeze the session into an immutable row under a random token (same-origin) | | `POST` | `/api/snapshots` | `{session_id, redact?: {file_paths?}}` → freeze the session into an immutable row under a random token (same-origin) |
| `GET` | `/api/snapshots/:token` | `{snapshot, payload}` — the frozen session, agents, events, stats and cost | | `GET` | `/api/snapshots/:token` | `{snapshot, payload}` — the frozen session, agents, events, stats and cost |
| `DELETE`| `/api/snapshots/:id` | Delete a snapshot (same-origin) | | `DELETE`| `/api/snapshots/:id` | Delete a snapshot (same-origin) |
| `GET` | `/api/query` | Query Explorer: sessions / events / agents filtered, sorted from an allowlist, paged; `format=csv\|json` exports the whole filter (≤ 50,000 rows) — `server/routes/query.js` | | `GET` | `/api/query` | Query Explorer (the Sessions page's Agents tab): sessions / events / agents filtered, sorted from an allowlist, paged; `format=csv\|json` exports the whole filter (≤ 50,000 rows) — `server/routes/query.js` |
| `GET` | `/api/reports` | Scheduled report definitions with their latest run (`server/lib/reports.js`) | | `GET` | `/api/reports` | Scheduled report definitions with their latest run (`server/lib/reports.js`) |
| `POST`/`PUT`/`DELETE` | `/api/reports[/:id]` | Create / update / delete a report definition (same-origin; delete also removes its runs and files) | | `POST`/`PUT`/`DELETE` | `/api/reports[/:id]` | Create / update / delete a report definition (same-origin; delete also removes its runs and files) |
| `POST` | `/api/reports/:id/run` | Run a report now (same-origin) | | `POST` | `/api/reports/:id/run` | Run a report now (same-origin) |
+23 -2
View File
@@ -44,6 +44,21 @@ describe("native-dialog", () => {
assert.ok(!args.slice(0, -2).join(" ").includes("shell script")); assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
}); });
it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => {
for (const kind of ["dir", "file"]) {
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
assert.equal(cmd, "powershell.exe");
assert.equal(args.at(-2), "-EncodedCommand");
const script = Buffer.from(args.at(-1), "base64").toString("utf16le");
assert.match(
script,
/\$o\.TopMost = \$true;[\s\S]*ShowWindow\(\$o\.Handle, 1\)[\s\S]*\$o\.Show\(\)[\s\S]*Front\(\$o\.Handle\)[\s\S]*\$d\.ShowDialog\(\$o\)/
);
assert.match(script, /AttachThreadInput/);
assert.ok(!script.includes("C:\\x"), "the folder travels in env, never the script");
}
});
it( it(
"answers the chosen path, null on Cancel, and falls through a missing program", "answers the chosen path, null on Cancel, and falls through a missing program",
{ {
@@ -95,10 +110,16 @@ describe("native-dialog", () => {
it("tells a Cancel from a failure per program", () => { it("tells a Cancel from a failure per program", () => {
assert.equal(nd.isCancel("zenity", ""), true); assert.equal(nd.isCancel("zenity", ""), true);
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true); assert.equal(
nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"),
true
);
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false); assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true); assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false); assert.equal(
nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"),
false
);
assert.equal(nd.isCancel("powershell.exe", ""), true); assert.equal(nd.isCancel("powershell.exe", ""), true);
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false); assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
}); });
+50
View File
@@ -0,0 +1,50 @@
/**
* @file Tests for server/lib/windows-hide.js: on win32 every child process,
* async or sync, is started with `windowsHide: true`; elsewhere nothing changes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { install } = require("../lib/windows-hide");
function fakeCp() {
const seen = [];
class ChildProcess {
spawn(options) {
seen.push(options);
}
}
const rec = (...args) => seen.push(args);
return { seen, cp: { ChildProcess, spawnSync: rec, execFileSync: rec, execSync: rec } };
}
test("win32: async spawns and every sync form get windowsHide", () => {
const { seen, cp } = fakeCp();
install(cp, "win32");
install(cp, "win32"); // idempotent: no double wrap
new cp.ChildProcess().spawn({ file: "git", windowsHide: false });
cp.execFileSync("git", ["status"]);
cp.execFileSync("git", ["status"], { cwd: "x" });
cp.execFileSync("git", { cwd: "x" });
cp.execSync("dir");
cp.spawnSync("git", ["log"], undefined);
assert.deepEqual(seen, [
{ file: "git", windowsHide: true },
["git", ["status"], { windowsHide: true }],
["git", ["status"], { cwd: "x", windowsHide: true }],
["git", { cwd: "x", windowsHide: true }],
["dir", { windowsHide: true }],
["git", ["log"], { windowsHide: true }],
]);
});
test("other platforms are left alone", () => {
const { cp } = fakeCp();
const before = { ...cp, spawn: cp.ChildProcess.prototype.spawn };
install(cp, "linux");
assert.equal(cp.execSync, before.execSync);
assert.equal(cp.ChildProcess.prototype.spawn, before.spawn);
});
+3
View File
@@ -3,6 +3,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
// First, before any module destructures child_process: no console window per
// git/npm call on Windows (server/lib/windows-hide.js).
require("./lib/windows-hide").install();
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production"; if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
// A server started from inside a Claude Code session (the bootstrap fallback, // A server started from inside a Claude Code session (the bootstrap fallback,
+12 -9
View File
@@ -9,6 +9,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const path = require("node:path");
const { db } = require("../db"); const { db } = require("../db");
const aiTools = require("./ai-tools"); const aiTools = require("./ai-tools");
const { const {
@@ -182,17 +183,18 @@ function normalizeTitle(value) {
*/ */
function canonicalCwd(cwd) { function canonicalCwd(cwd) {
// A relative path must stay relative, so createLane still refuses it. // A relative path must stay relative, so createLane still refuses it.
if (!cwd.startsWith("/")) return cwd; // path.isAbsolute, not a leading "/": a Windows cwd is `D:\…`.
const trimmed = cwd.replace(/\/+$/, ""); if (!path.isAbsolute(cwd)) return cwd;
const trimmed = path.resolve(cwd);
// A path that does not exist yet resolves through its deepest existing // A path that does not exist yet resolves through its deepest existing
// ancestor, so /link/new and /real/new still name the same lane. // ancestor, so /link/new and /real/new still name the same lane.
const path = require("node:path");
const tail = []; const tail = [];
for (let dir = trimmed; ; dir = path.dirname(dir)) { for (let dir = trimmed; ; dir = path.dirname(dir)) {
try { try {
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail); return path.join(require("node:fs").realpathSync.native(dir), ...tail);
} catch { } catch {
if (dir === "/" || !dir) return trimmed; // A root (`/`, `D:\` — a drive that does not exist) is its own dirname.
if (path.dirname(dir) === dir) return trimmed;
tail.unshift(path.basename(dir)); tail.unshift(path.basename(dir));
} }
} }
@@ -210,7 +212,7 @@ function createLane({
tool = "claude", tool = "claude",
router_profile = null, router_profile = null,
} = {}) { } = {}) {
if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) { if (!cwd || typeof cwd !== "string" || !path.isAbsolute(cwd)) {
throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" }); throw Object.assign(new Error("cwd must be an absolute path"), { code: "EBADCWD" });
} }
validateKind(kind); validateKind(kind);
@@ -603,12 +605,13 @@ function recoverInterruptedProvisioning() {
*/ */
function resolveLaneByCwd(cwd, tool) { function resolveLaneByCwd(cwd, tool) {
if (!cwd || typeof cwd !== "string") return null; if (!cwd || typeof cwd !== "string") return null;
const target = cwd.replace(/\/+$/, ""); // A Windows cwd ends in and nests under `\`.
const target = cwd.replace(/[\\/]+$/, "");
let best = null; let best = null;
for (const lane of listLanes()) { for (const lane of listLanes()) {
if (tool && lane.tool !== tool) continue; if (tool && lane.tool !== tool) continue;
const base = lane.cwd.replace(/\/+$/, ""); const base = lane.cwd.replace(/[\\/]+$/, "");
if (target === base || target.startsWith(`${base}/`)) { if (target === base || target.startsWith(`${base}/`) || target.startsWith(base + path.sep)) {
if (!best || base.length > best.cwd.length) best = lane; if (!best || base.length > best.cwd.length) best = lane;
} }
} }
+43 -2
View File
@@ -61,6 +61,25 @@ function startDir(start) {
return os.homedir(); return os.homedir();
} }
// user32 calls PowerShell has no cmdlet for: `Front` makes a window the
// foreground one from a background process (see dialogCommands).
const WIN_FRONT = [
'[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);',
'[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
'[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
'[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
'[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr p);',
'[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool on);',
'[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
"public static void Front(IntPtr h) {",
" uint fg = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);",
" uint me = GetCurrentThreadId();",
" bool joined = fg != 0 && fg != me && AttachThreadInput(me, fg, true);",
" BringWindowToTop(h); SetForegroundWindow(h);",
" if (joined) AttachThreadInput(me, fg, false);",
"}",
].join("\n");
/** The candidate dialog commands for a platform, in order of preference. */ /** The candidate dialog commands for a platform, in order of preference. */
function dialogCommands(kind, dir, platform = process.platform) { function dialogCommands(kind, dir, platform = process.platform) {
const title = TITLES[kind]; const title = TITLES[kind];
@@ -88,14 +107,36 @@ function dialogCommands(kind, dir, platform = process.platform) {
? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE" ? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE"
: "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE"; : "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE";
const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName"; const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName";
// The server is a background process, and Windows keeps one from bringing
// a window forward: an ownerless dialog opened at the BOTTOM of the
// z-order, behind the browser. So the dialog gets a TopMost owner, made
// the foreground window by joining the current foreground thread's input
// (AttachThreadInput — the lock exempts it). The first ShowWindow of a
// process started with windowsHide is turned into a hide; a throwaway call
// spends it, or the owner stays hidden and takes the dialog down with it.
const script = [
"$ProgressPreference = 'SilentlyContinue'",
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
"Add-Type -AssemblyName System.Windows.Forms",
`Add-Type -Namespace AgentHub -Name Win -MemberDefinition '${WIN_FRONT}'`,
"$o = New-Object System.Windows.Forms.Form",
"$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.Opacity = 0; $o.Width = 1; $o.Height = 1; $o.StartPosition = 'CenterScreen'",
"[void][AgentHub.Win]::ShowWindow($o.Handle, 1)",
"$o.Show()",
"[AgentHub.Win]::Front($o.Handle)",
dialog,
`if ($d.ShowDialog($o) -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
].join("\n");
// -EncodedCommand: the C# above carries double quotes, which Windows
// PowerShell's own command-line parsing mangles inside -Command.
return [ return [
[ [
"powershell.exe", "powershell.exe",
[ [
"-NoProfile", "-NoProfile",
"-STA", "-STA",
"-Command", "-EncodedCommand",
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; ${dialog}; if ($d.ShowDialog() -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`, Buffer.from(script, "utf16le").toString("base64"),
], ],
], ],
]; ];
+41
View File
@@ -0,0 +1,41 @@
/**
* @file Makes every child process on Windows start with `windowsHide: true`.
* The dashboard runs detached, with no console of its own, so Windows gave
* each console program it ran (git, npm, where, …) a NEW console window — a
* flash that stole focus on every poll. Node has no global default, so this
* patches `ChildProcess.prototype.spawn` (the one path spawn/exec/execFile/fork
* and their promisified forms all take) and wraps the sync exports. Require it
* FIRST in an entry point: a `const { execSync } = require("child_process")`
* evaluated before it keeps the unwrapped function. A no-op off Windows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const DONE = Symbol.for("agenthub.windowsHide");
function install(cp = require("child_process"), platform = process.platform) {
if (platform !== "win32" || cp[DONE]) return;
cp[DONE] = true;
const spawn = cp.ChildProcess.prototype.spawn;
cp.ChildProcess.prototype.spawn = function (options) {
options.windowsHide = true;
return spawn.call(this, options);
};
// spawnSync(cmd, args?, opts?), execFileSync(file, args?, opts?), execSync(cmd, opts?)
for (const name of ["spawnSync", "execFileSync", "execSync"]) {
const fn = cp[name];
cp[name] = function (...args) {
while (args.length > 1 && args[args.length - 1] == null) args.pop();
const last = args[args.length - 1];
if (args.length > 1 && typeof last === "object" && !Array.isArray(last)) {
args[args.length - 1] = { ...last, windowsHide: true };
} else {
args.push({ windowsHide: true });
}
return fn.apply(this, args);
};
}
}
module.exports = { install };
+1 -1
View File
@@ -406,7 +406,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
// it, a nested cwd still resolves to its owner, which is what the CLI and // it, a nested cwd still resolves to its owner, which is what the CLI and
// other callers adopting "the lane I am inside" rely on. // other callers adopting "the lane I am inside" rely on.
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude"); const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
const strip = (p) => p.replace(/\/+$/, ""); const strip = (p) => p.replace(/[\\/]+$/, "");
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null; const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
if (owner) { if (owner) {
// First-write wins on the title: an ensure-with-title on an existing lane // First-write wins on the title: an ensure-with-title on an existing lane
+1
View File
@@ -67,6 +67,7 @@ function gitBranch(cwd) {
cwd, cwd,
timeout: 500, timeout: 500,
stdio: ["ignore", "pipe", "ignore"], stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
}) })
.toString() .toString()
.trim(); .trim();