diff --git a/src/plugin/index.ts b/src/plugin/index.ts index afe23c3..837cef2 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -31,12 +31,13 @@ function serverAuthHeaders(): HeadersInit | undefined { return { Authorization: `Basic ${btoa(`${username}:${password}`)}` } } -async function listGlobalSessions(serverUrl: URL): Promise { +export async function listGlobalSessions(serverUrl: URL): Promise { const sessions: unknown[] = [] let cursor: string | undefined do { const url = new URL("/experimental/session", serverUrl) + url.searchParams.set("archived", "true") url.searchParams.set("limit", "100") if (cursor) url.searchParams.set("cursor", cursor) const response = await fetch(url, { headers: serverAuthHeaders() }) @@ -50,17 +51,39 @@ async function listGlobalSessions(serverUrl: URL): Promise { return sessions } -export default (async ({ client, project, directory, serverUrl }) => { - const transport = ( - client as unknown as { - _client: { - get(options: { - url: string - query: { limit: number; cursor?: number } - }): Promise<{ data?: unknown; error?: unknown; response: Response }> - } +type GlobalSessionTransport = { + get(options: { + url: string + query: { archived: true; limit: number; cursor?: number } + }): Promise<{ data?: unknown; error?: unknown; response: Response }> +} + +export async function listGlobalSessionsWithTransport( + transport: GlobalSessionTransport, +): Promise { + const sessions: unknown[] = [] + let cursor: number | undefined + + do { + const result = await transport.get({ + url: "/experimental/session", + query: { archived: true, limit: 100, cursor }, + }) + if (result.error || !Array.isArray(result.data)) { + throw new Error("Global session request failed") } - )._client + sessions.push(...result.data) + + const nextCursor = result.response.headers.get("x-next-cursor") + cursor = nextCursor ? Number(nextCursor) : undefined + } while (cursor !== undefined && Number.isFinite(cursor)) + + return sessions +} + +export default (async ({ client, project, directory, serverUrl }) => { + const transport = (client as unknown as { _client: GlobalSessionTransport }) + ._client const source: SourceIdentity = { processInstanceId, pluginInstanceId: crypto.randomUUID(), @@ -133,14 +156,7 @@ export default (async ({ client, project, directory, serverUrl }) => { try { globalResult = await listGlobalSessions(serverUrl) } catch { - const result = await transport.get({ - url: "/experimental/session", - query: { limit: 100 }, - }) - if (result.error || !Array.isArray(result.data)) { - throw new Error("Global session request failed") - } - globalResult = result.data + globalResult = await listGlobalSessionsWithTransport(transport) } for (const info of globalResult) { diff --git a/src/tui/index.ts b/src/tui/index.ts index 282ad84..fc05981 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -1,4 +1,9 @@ import type { TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"; +import { + createOpencodeClient, + type OpencodeClient, + type OpencodeClientConfig, +} from "@opencode-ai/sdk/v2/client"; import path from "node:path"; import { createServer } from "../collector/server"; import { sanitizeSession } from "../plugin/normalize"; @@ -42,12 +47,31 @@ async function ensureCollector() { } } -async function sendGlobalSnapshot(api: TuiPluginApi, source: SourceIdentity) { +export function createGlobalSessionClient(client: TuiPluginApi["client"]) { + const config = ( + client as unknown as { + client: { getConfig(): OpencodeClientConfig }; + } + ).client.getConfig(); + const headers = new Headers(config.headers as HeadersInit | undefined); + headers.delete("x-opencode-directory"); + headers.delete("x-opencode-workspace"); + + return createOpencodeClient({ + ...config, + directory: undefined, + experimental_workspaceID: undefined, + headers, + }); +} + +export async function listGlobalSessions(client: OpencodeClient) { const sessions: SnapshotSession[] = []; let cursor: number | undefined; do { - const result = await api.client.experimental.session.list({ + const result = await client.experimental.session.list({ + archived: true, limit: 100, cursor, }); @@ -62,16 +86,25 @@ async function sendGlobalSnapshot(api: TuiPluginApi, source: SourceIdentity) { cursor = nextCursor ? Number(nextCursor) : undefined; } while (cursor !== undefined && Number.isFinite(cursor)); - await sender.sendSnapshot({ source, scope: "global", sessions }); + return sessions; +} + +async function sendGlobalSnapshot(api: TuiPluginApi, source: SourceIdentity) { + await sender.sendSnapshot({ + source, + scope: "global", + sessions: await listGlobalSessions(createGlobalSessionClient(api.client)), + }); } function openDashboard() { + const url = `${dashboardUrl}/?v=${Date.now()}`; const command = process.platform === "darwin" - ? ["open", dashboardUrl] + ? ["open", url] : process.platform === "win32" - ? ["cmd", "/c", "start", "", dashboardUrl] - : ["xdg-open", dashboardUrl]; + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url]; const subprocess = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", diff --git a/src/web/App.tsx b/src/web/App.tsx index 024526c..93bb67d 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -1,5 +1,5 @@ import { useState, type FormEvent } from "react" -import type { DashboardSession } from "../shared/protocol" +import type { DashboardSession, DashboardState } from "../shared/protocol" import { useDashboard } from "./hooks/use-dashboard" import { useSessionActions } from "./hooks/use-session-actions" import { @@ -7,6 +7,7 @@ import { selectChildSessions, selectProjectTree, selectRunningSessions, + type AgentSummary, } from "./store/selectors" function statusColor(status: string) { @@ -48,14 +49,12 @@ function formatUpdatedAt(value: number) { function SessionRow({ session, sessions, - onRename, - onDelete, + onEdit, depth = 0, }: { session: DashboardSession sessions: DashboardSession[] - onRename: (session: DashboardSession) => void - onDelete: (session: DashboardSession) => void + onEdit: (session: DashboardSession) => void depth?: number }) { const children = selectChildSessions(sessions, session.id) @@ -63,7 +62,7 @@ function SessionRow({ return ( <> - +
{depth > 0 ? "|_" : "[*]"} @@ -79,36 +78,29 @@ function SessionRow({ )}
- + {session.agent ?? "--"} - + {session.model ?? "--"} - + {session.status} - + {formatUpdatedAt(session.updatedAt)} - +
-
@@ -118,8 +110,7 @@ function SessionRow({ key={child.id} session={child} sessions={sessions} - onRename={onRename} - onDelete={onDelete} + onEdit={onEdit} depth={depth + 1} /> ))} @@ -128,16 +119,166 @@ function SessionRow({ } type SessionDialog = { - mode: "rename" | "delete" session: DashboardSession } +type OverviewMetric = "processes" | "sessions" | "agents" | "projects" + +type DetailDialog = + | { kind: "overview"; metric: OverviewMetric } + | { kind: "agent"; agent: AgentSummary } + +function DetailModal({ + eyebrow, + title, + children, + onClose, +}: { + eyebrow: string + title: string + children: React.ReactNode + onClose: () => void +}) { + return ( +
+
event.stopPropagation()} + className="max-h-[80vh] w-full max-w-2xl overflow-y-auto border border-[var(--stats-line-strong)] bg-[var(--stats-bg)] shadow-2xl" + > +
+
+

+ {eyebrow} +

+

+ {title} +

+
+ +
+
{children}
+
+
+ ) +} + +function DetailRows({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function OverviewDetails({ + metric, + data, + agents, + running, +}: { + metric: OverviewMetric + data: DashboardState + agents: AgentSummary[] + running: DashboardSession[] +}) { + if (metric === "processes") { + return ( + + {data.processes.map((process) => ( +
+
+

{process.processInstanceId}

+

{process.projects.length} projects · OpenCode {process.openCodeVersion ?? "unknown"}

+
+ {process.stale ? "stale" : "connected"} +
+ ))} +
+ ) + } + + if (metric === "sessions") { + return ( + + {running.map((session) => ( +
+ {session.title} + {session.status} +
+ ))} + {running.length === 0 &&

No active sessions.

} +
+ ) + } + + if (metric === "agents") { + return ( + + {agents.map((agent) => ( +
+ {agent.name} + {agent.activeCount} active + {agent.sessionCount} sessions +
+ ))} +
+ ) + } + + return ( + + {data.projects.map((project) => ( +
+
+

{project.name}

+

{project.directories.join(" · ")}

+
+ {project.sessionCount} sessions +
+ ))} +
+ ) +} + +function AgentDetails({ agent, sessions }: { agent: AgentSummary; sessions: DashboardSession[] }) { + const matching = sessions.filter((session) => session.agent === agent.name) + return ( + <> +
+ {[["Active", agent.activeCount], ["Sessions", agent.sessionCount], ["Subagents", agent.subagentCount]].map(([label, value]) => ( +
+

{value}

+

{label}

+
+ ))} +
+ + {matching.map((session) => ( +
+
+

{session.title}

+

{session.model ?? "No model reported"}

+
+ {session.status} +
+ ))} +
+ + ) +} + function SessionActionDialog({ dialog, title, error, pending, onTitleChange, + onDelete, onClose, onSubmit, }: { @@ -146,11 +287,10 @@ function SessionActionDialog({ error?: string pending: boolean onTitleChange: (title: string) => void + onDelete: () => void onClose: () => void onSubmit: (event: FormEvent) => void }) { - const deleting = dialog.mode === "delete" - return (

- {deleting ? "Delete session?" : "Rename session"} + Edit session

{dialog.session.title}

- {deleting ? ( -

- This permanently deletes the session and its child sessions from OpenCode. This action cannot be undone. + +

+

+ Danger zone

- ) : ( - - )} +

+ Permanently delete this session and its child sessions from OpenCode. This action cannot be undone. +

+ +
{error &&

{error}

}
@@ -205,10 +355,10 @@ function SessionActionDialog({ @@ -236,13 +386,14 @@ export function App() { const { data, isLoading, error } = useDashboard() const { rename, remove } = useSessionActions() const [dialog, setDialog] = useState(null) + const [detailDialog, setDetailDialog] = useState(null) const [title, setTitle] = useState("") - function openSessionDialog(mode: SessionDialog["mode"], session: DashboardSession) { + function openSessionDialog(session: DashboardSession) { rename.reset() remove.reset() setTitle(session.title) - setDialog({ mode, session }) + setDialog({ session }) } async function submitSessionAction(event: FormEvent) { @@ -250,11 +401,18 @@ export function App() { if (!dialog) return try { - if (dialog.mode === "rename") { - await rename.mutateAsync({ sessionId: dialog.session.id, title: title.trim() }) - } else { - await remove.mutateAsync(dialog.session.id) - } + await rename.mutateAsync({ sessionId: dialog.session.id, title: title.trim() }) + setDialog(null) + } catch { + // Mutation errors are rendered in the dialog. + } + } + + async function deleteSelectedSession() { + if (!dialog) return + + try { + await remove.mutateAsync(dialog.session.id) setDialog(null) } catch { // Mutation errors are rendered in the dialog. @@ -262,7 +420,7 @@ export function App() { } if (isLoading) { - return + return } if (error && !data) { @@ -280,11 +438,11 @@ export function App() { const connectedProcesses = data.processes.filter((process) => !process.stale) const staleProcesses = data.processes.length - connectedProcesses.length - const metrics = [ - { label: "Processes", value: connectedProcesses.length, note: staleProcesses ? `${staleProcesses} stale` : "Reporting now" }, - { label: "Active sessions", value: running.length, note: `${data.sessions.length} total` }, - { label: "Active agents", value: activeAgents, note: `${agents.length} agent types` }, - { label: "Projects", value: data.projects.length, note: "With sessions" }, + const metrics: Array<{ id: OverviewMetric; label: string; value: number; note: string }> = [ + { id: "processes", label: "Processes", value: connectedProcesses.length, note: staleProcesses ? `${staleProcesses} stale` : "Reporting now" }, + { id: "sessions", label: "Active sessions", value: running.length, note: `${data.sessions.length} total` }, + { id: "agents", label: "Active agents", value: activeAgents, note: `${agents.length} agent types` }, + { id: "projects", label: "Projects", value: data.projects.length, note: "With sessions" }, ] return ( @@ -307,23 +465,23 @@ export function App() {
-
+

Local telemetry / Live

- Session Data + Dashboard

-

+

Monitor OpenCode sessions and agent activity across every local project, without collecting prompts or model output.

-
-
+
+

Overview.{" "} Current collector state. @@ -331,7 +489,7 @@ export function App() {

{metrics.map((metric) => ( -
+
- + ))}
-
-
+
+

Agents.{" "} @@ -367,7 +525,7 @@ export function App() { ) : (
{agents.map((agent, index) => ( -
+
+ ))}
)}

-
-
+
+

Sessions.{" "} Project activity. @@ -407,7 +565,7 @@ export function App() {

Historical sessions remain visible; live states update from connected processes.

-
+
{projectTree.map(({ project, sessions: projectSessions }, projectIndex) => (
@@ -441,8 +599,7 @@ export function App() { key={session.id} session={session} sessions={data.sessions} - onRename={(selected) => openSessionDialog("rename", selected)} - onDelete={(selected) => openSessionDialog("delete", selected)} + onEdit={openSessionDialog} /> ))} @@ -455,7 +612,7 @@ export function App() {