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.
This commit is contained in:
+4
-2
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,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}
|
||||||
@@ -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;
|
||||||
|
|||||||
+22
-41
@@ -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.3 (rebuild client to match)
|
UI build v2.5.4 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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
@@ -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.3
|
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ĩ
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.3",
|
"version": "2.5.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.3",
|
"version": "2.5.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.5.3",
|
"version": "2.5.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -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) |
|
||||||
|
|||||||
@@ -44,12 +44,17 @@ 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 TopMost owner, so it does not open behind the browser", () => {
|
it("gives the Windows dialog a foreground TopMost owner, so it does not open behind the browser", () => {
|
||||||
for (const kind of ["dir", "file"]) {
|
for (const kind of ["dir", "file"]) {
|
||||||
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
|
const [[cmd, args]] = nd.dialogCommands(kind, "C:\\x", "win32");
|
||||||
assert.equal(cmd, "powershell.exe");
|
assert.equal(cmd, "powershell.exe");
|
||||||
const script = args.at(-1);
|
assert.equal(args.at(-2), "-EncodedCommand");
|
||||||
assert.match(script, /\$o\.TopMost = \$true;.*\$o\.Show\(\);.*\$d\.ShowDialog\(\$o\)/);
|
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");
|
assert.ok(!script.includes("C:\\x"), "the folder travels in env, never the script");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,19 +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",
|
||||||
// The server is a background process, and Windows will not let one
|
Buffer.from(script, "utf16le").toString("base64"),
|
||||||
// bring a window to the front: an ownerless dialog opened BEHIND the
|
|
||||||
// browser (or hidden — the first ShowWindow of a process started
|
|
||||||
// with windowsHide). An invisible TopMost owner, shown first, takes
|
|
||||||
// that first ShowWindow and keeps the dialog on top.
|
|
||||||
`[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; $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.Show(); $o.Activate(); ${dialog}; if ($d.ShowDialog($o) -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user