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:
2026-10-06 21:56:51 +07:00
parent af014d9829
commit dd9b23106c
23 changed files with 168 additions and 150 deletions
@@ -1,9 +1,11 @@
/**
* @file QueryExplorer.tsx
* @description `/query`: filter sessions, events or agents (free text, AI tool,
* status, local date range, folder subtree, plus the global data scope), sort
* by a column the server allows, page through the rows, and export the whole
* filtered set as CSV or JSON (`GET /api/query`, capped server-side).
* @file AgentsTable.tsx
* @description The Sessions page's **Agents** tab (`/sessions?view=agents`,
* where the old `/query` redirects): filter agents (free text, AI tool, status,
* local date range, folder subtree, plus the global data scope), sort by a
* 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>
*/
@@ -16,21 +18,15 @@ import { applyRange } from "../lib/dateRange";
import { useDataScope } from "../lib/dataScope";
import { useAiTools } from "../hooks/useAiTools";
import { Select } from "../components/Select";
import type { QueryEntity, QueryPage } from "../lib/types";
import type { QueryPage } from "../lib/types";
const PAGE_SIZE = 50;
const ENTITIES: QueryEntity[] = ["sessions", "events", "agents"];
const STATUSES: Record<QueryEntity, string[]> = {
sessions: ["active", "completed", "error", "abandoned"],
events: [],
agents: ["working", "waiting", "completed", "error"],
};
const STATUSES = ["working", "waiting", "completed", "error"];
export function QueryExplorer() {
export function AgentsTable() {
const { t } = useTranslation("analytics");
const aiTools = useAiTools();
const [scope] = useDataScope();
const [entity, setEntity] = useState<QueryEntity>("sessions");
const [q, setQ] = useState("");
const [cwd, setCwd] = useState("");
const [typed, setTyped] = useState({ q: "", cwd: "" });
@@ -63,7 +59,7 @@ export function QueryExplorer() {
};
const filters = useMemo(() => {
const p = new URLSearchParams({ entity });
const p = new URLSearchParams({ entity: "agents" });
if (typed.q) p.set("q", typed.q);
if (typed.cwd) p.set("cwd", typed.cwd);
if (tool) p.set("tool", tool);
@@ -73,7 +69,7 @@ export function QueryExplorer() {
p.set("dir", dir);
}
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(() => {
let live = true;
@@ -93,14 +89,6 @@ export function QueryExplorer() {
};
}, [filters, offset, scope]);
const switchEntity = (v: QueryEntity) => {
setEntity(v);
setStatus("");
setSort("");
setDir("desc");
setOffset(0);
setPage(null);
};
const reset = () => {
setQ("");
setCwd("");
@@ -122,24 +110,13 @@ export function QueryExplorer() {
const total = page?.total ?? 0;
const cap = page?.export_cap ?? 50_000;
const statuses = STATUSES[entity];
return (
<div className="animate-fade-in space-y-5">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("query.title")}</h1>
<p className="text-sm text-fg-muted">{t("query.subtitle")}</p>
</div>
{/* The tab bar names the view. */}
<h1 className="sr-only">{t("query.entities.agents")}</h1>
<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
className="input text-sm w-56"
value={q}
@@ -158,19 +135,17 @@ export function QueryExplorer() {
]}
/>
</div>
{statuses.length > 0 && (
<div className="w-40">
<Select
value={status}
onChange={fresh(setStatus)}
label={t("query.status")}
options={[
{ value: "", label: t("query.allStatuses") },
...statuses.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
]}
/>
</div>
)}
<div className="w-40">
<Select
value={status}
onChange={fresh(setStatus)}
label={t("query.status")}
options={[
{ value: "", label: t("query.allStatuses") },
...STATUSES.map((s) => ({ value: s, label: t(`common:status.${s}`) })),
]}
/>
</div>
<input
type="date"
className="input text-sm"
@@ -276,8 +251,7 @@ export function QueryExplorer() {
<tr key={`${r.id}-${i}`} className="hover:bg-surface-4">
{page.columns.map((c) => {
const v = r[c];
const sid =
(entity === "sessions" && c === "id") || c === "session_id" ? v : null;
const sid = c === "session_id" ? v : null;
return (
<td
key={c}
+8 -5
View File
@@ -1,17 +1,19 @@
/**
* @file History.tsx
* @description Tabbed shell for /sessions that hosts the two views over the same
* event pipeline: the per-session list and the raw activity feed. The active tab
* lives in the `?view=` query param, so `/activity` can redirect into it.
* @description Tabbed shell for /sessions that hosts the three views over the
* same data: the per-session list, the raw activity feed and the agents table.
* 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>
*/
import type { ComponentType } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Activity, FolderOpen } from "lucide-react";
import { Activity, Bot, FolderOpen } from "lucide-react";
import { PageTabs } from "../components/PageTabs";
import { Sessions } from "./Sessions";
import { ActivityFeed } from "./ActivityFeed";
import { AgentsTable } from "./AgentsTable";
interface HistoryView {
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.
*/
export function History() {
@@ -31,6 +33,7 @@ export function History() {
const views: HistoryView[] = [
{ id: "sessions", label: t("sessions"), Icon: FolderOpen, Panel: Sessions },
{ id: "events", label: t("activityFeed"), Icon: Activity, Panel: ActivityFeed },
{ id: "agents", label: t("agents"), Icon: Bot, Panel: AgentsTable },
];
const defaultView = views[0]!;
const active = views.find((v) => v.id === params.get("view")) ?? defaultView;
@@ -1,8 +1,8 @@
/**
* @file QueryExplorer.test.tsx
* @description The Query Explorer page: it renders a page of rows with session
* links, sorts by an allowed column on a header click (flipping on a second),
* switches entity, and points both export links at the current filter.
* @file AgentsTable.test.tsx
* @description The Sessions page's Agents tab: it asks only for agents, renders
* a page of rows with session links, sorts by an allowed column on a header
* click (flipping on a second), and points both export links at the filter.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -23,19 +23,8 @@ vi.mock("../../hooks/useAiTools", () => ({
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 calls = query.page.mock.calls;
return calls[calls.length - 1]![0] as URLSearchParams;
@@ -44,28 +33,34 @@ const lastParams = () => {
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
query.page.mockImplementation((p: URLSearchParams) =>
Promise.resolve(
p.get("entity") === "events"
? page("events", [{ id: "1", session_id: "sess-abcdef12", created_at: "2026-10-01" }])
: page("sessions", [{ id: "sess-abcdef12", name: "first", started_at: "2026-10-01" }])
)
);
query.page.mockResolvedValue({
entity: "agents",
columns: ["id", "session_id", "name", "started_at"],
sortable: ["started_at", "name"],
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", () => {
it("shows rows, sorts by an allowed column and exports the filter", async () => {
describe("AgentsTable", () => {
it("lists agents, sorts by an allowed column and exports the filter", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<QueryExplorer />
<AgentsTable />
</MemoryRouter>
);
expect(await screen.findByText("first")).toBeInTheDocument();
expect(lastParams().get("entity")).toBe("agents");
expect(screen.getByRole("link", { name: "sess-abc" })).toHaveAttribute(
"href",
"/sessions/sess-abcdef12"
);
expect(screen.getByRole("button", { name: /Status/ })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "name" }));
await waitFor(() => expect(lastParams().get("sort")).toBe("name"));
expect(lastParams().get("dir")).toBe("desc");
@@ -74,22 +69,8 @@ describe("QueryExplorer", () => {
// A column outside `sortable` has no sort button.
expect(screen.queryByRole("button", { name: "id" })).toBeNull();
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("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
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>
</div>
<div