From 98fa722ce3cc3d5146b50fa3232ba8c134af9154 Mon Sep 17 00:00:00 2001 From: Fabian Bienk <19612069+zfarbp@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:46:00 +0200 Subject: [PATCH 1/5] feat: agent graph (#23) --- packages/server/package.json | 2 +- packages/shared/package.json | 2 +- packages/web/package.json | 2 +- packages/web/src/panels/AgentsGraph.vue | 991 ++++++++++++++++++ packages/web/src/panels/PluginsBrowser.vue | 15 +- .../web/src/views/dashboard/AgentsView.vue | 451 ++++++-- packages/web/src/views/dashboard/chrome.css | 10 +- 7 files changed, 1357 insertions(+), 116 deletions(-) create mode 100644 packages/web/src/panels/AgentsGraph.vue diff --git a/packages/server/package.json b/packages/server/package.json index e24a076..5d3850b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "threadle", - "version": "1.0.10", + "version": "1.0.11", "description": "Local-first node-graph patchbay for Claude Code, opencode, Cursor Agent, Antigravity, Codex, Copilot, and Grok Build sessions.", "type": "module", "license": "MIT", diff --git a/packages/shared/package.json b/packages/shared/package.json index 9e535ca..c3eb9e7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@threadle/shared", - "version": "1.0.10", + "version": "1.0.11", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/web/package.json b/packages/web/package.json index 7fcebe1..1ffc47e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@threadle/web", - "version": "1.0.10", + "version": "1.0.11", "private": true, "type": "module", "scripts": { diff --git a/packages/web/src/panels/AgentsGraph.vue b/packages/web/src/panels/AgentsGraph.vue new file mode 100644 index 0000000..7b78ef4 --- /dev/null +++ b/packages/web/src/panels/AgentsGraph.vue @@ -0,0 +1,991 @@ + + + + + diff --git a/packages/web/src/panels/PluginsBrowser.vue b/packages/web/src/panels/PluginsBrowser.vue index beb877c..da50e38 100644 --- a/packages/web/src/panels/PluginsBrowser.vue +++ b/packages/web/src/panels/PluginsBrowser.vue @@ -124,6 +124,10 @@ const filtered = computed(() => { }); }); +function childCount(e: PluginEntry, kind: PluginChildKind): number { + return e.children.filter((c) => c.kind === kind).length; +} + interface ProvGroup { provider: string; entries: PluginEntry[]; @@ -284,12 +288,15 @@ const loading = computed(() => items.value === undefined);
nameverstateoriginkids + >origincommandsagentschildren
diff --git a/packages/web/src/views/dashboard/AgentsView.vue b/packages/web/src/views/dashboard/AgentsView.vue index b418610..bfde2b9 100644 --- a/packages/web/src/views/dashboard/AgentsView.vue +++ b/packages/web/src/views/dashboard/AgentsView.vue @@ -22,7 +22,9 @@ ? 'Filter memory…' : agentBrowse === 'plugins' ? 'Filter plugins…' - : 'Filter runs…' + : agentBrowse === 'graph' + ? 'Filter graph…' + : 'Filter runs…' " spellcheck="false" /> @@ -59,6 +61,14 @@ > live{{ agentInstances.live ? ` · ${agentInstances.live}` : "" }} + + @@ -176,7 +189,14 @@ @mousedown="onSessionRowMouseDown($event, s)" > - {{ s.title ?? shortId(s.id) }} + + ✦{{ s.title ?? shortId(s.id) }} + {{ s.agent ?? "—" }} {{ s.model?.split("/").pop() ?? "—" @@ -288,7 +308,14 @@ {{ pickedSession.title ?? shortId(pickedSession.id) }}
- +
- + number> }; const activeOnly = ref(false); +/** When false (default), hide Settings test-pilot smokes. When true, only those. */ +const showPilot = ref(false); const liveSessionCount = computed( () => sessions.sessions.filter((s) => isSessionLive(s.status)).length, ); +const pilotSessionCount = computed( + () => sessions.sessions.filter((s) => isPilotSession(s)).length, +); const filteredSessions = computed((): SessionRef[] => { + const q = sessionFilter.value.toLowerCase(); + return sessions.sessions.filter( + (s) => + (!activeOnly.value || isSessionLive(s.status)) && + (showPilot.value ? isPilotSession(s) : !isPilotSession(s)) && + (providerFilter.value === "all" || s.provider === providerFilter.value) && + (!q || + s.title?.toLowerCase().includes(q) || + s.projectDir.toLowerCase().includes(q) || + s.agent?.toLowerCase().includes(q)), + ); +}); + +/** Year chart: same filters as the list, but always includes both actual + test. */ +const heatBaseSessions = computed((): SessionRef[] => { const q = sessionFilter.value.toLowerCase(); return sessions.sessions.filter( (s) => @@ -767,13 +823,20 @@ const sessionHeat = computed(() => { const year = heatYear.value; const yearPrefix = `${year}-`; const today = startOfLocalDay(Date.now()); - const sessCounts = new Map(); + const testMode = showPilot.value; + const actualCounts = new Map(); + const testCounts = new Map(); const weights = new Map(); let sessInYear = 0; - for (const s of filteredSessions.value) { + for (const s of heatBaseSessions.value) { if (!s.updatedAt) continue; - const w = Math.max(1, Math.min(20, Math.ceil((s.messageCount ?? 0) / 50) || 1)); + const isTest = isPilotSession(s); + // Heat / year totals follow the list filter: actual by default, tests when ✦ test is on. + const contributesHeat = testMode ? isTest : !isTest; + const w = contributesHeat + ? Math.max(1, Math.min(20, Math.ceil((s.messageCount ?? 0) / 50) || 1)) + : 0; let touchesYear = false; const days: string[] = []; forEachSessionDay(s, (k) => { @@ -781,13 +844,14 @@ const sessionHeat = computed(() => { if (k.startsWith(yearPrefix)) touchesYear = true; }); if (!touchesYear) continue; - sessInYear += 1; + if (contributesHeat) sessInYear += 1; const dayN = days.length || 1; - const perDay = Math.max(1, Math.round(w / dayN)); + const perDay = w > 0 ? Math.max(1, Math.round(w / dayN)) : 0; for (const k of days) { if (!k.startsWith(yearPrefix)) continue; - sessCounts.set(k, (sessCounts.get(k) ?? 0) + 1); - weights.set(k, (weights.get(k) ?? 0) + perDay); + if (isTest) testCounts.set(k, (testCounts.get(k) ?? 0) + 1); + else actualCounts.set(k, (actualCounts.get(k) ?? 0) + 1); + if (perDay > 0) weights.set(k, (weights.get(k) ?? 0) + perDay); } } @@ -806,6 +870,7 @@ const sessionHeat = computed(() => { key: string; weight: number; sessions: number; + tests: number; ts: number; inRange: boolean; inYear: boolean; @@ -818,9 +883,18 @@ const sessionHeat = computed(() => { const inYear = cur.getFullYear() === year; const inRange = inYear && key <= todayKey; const weight = inYear ? (weights.get(key) ?? 0) : 0; - const sessionsN = inYear ? (sessCounts.get(key) ?? 0) : 0; + const sessionsN = inYear ? (actualCounts.get(key) ?? 0) : 0; + const testsN = inYear ? (testCounts.get(key) ?? 0) : 0; if (inRange && weight > max) max = weight; - raw.push({ key, weight, sessions: sessionsN, ts, inRange, inYear }); + raw.push({ + key, + weight, + sessions: sessionsN, + tests: testsN, + ts, + inRange, + inYear, + }); cur.setDate(cur.getDate() + 1); } @@ -835,6 +909,23 @@ const sessionHeat = computed(() => { return 4; }; + function heatTitle(actual: number, tests: number, date: string): string { + if (testMode) { + const testPart = + tests === 0 + ? "No test sessions" + : `${tests} test session${tests === 1 ? "" : "s"}`; + if (actual <= 0) return `${testPart} · ${date}`; + return `${testPart} (${actual} session${actual === 1 ? "" : "s"}) · ${date}`; + } + const actualPart = + actual === 0 + ? "No sessions" + : `${actual} session${actual === 1 ? "" : "s"}`; + if (tests <= 0) return `${actualPart} · ${date}`; + return `${actualPart} (${tests} test session${tests === 1 ? "" : "s"}) · ${date}`; + } + for (let w = 0; w < weekCount; w++) { const week: HeatCell[] = []; for (let d = 0; d < 7; d++) { @@ -846,11 +937,9 @@ const sessionHeat = computed(() => { day: "numeric", year: "numeric", }); - const n = cell.sessions; let title = ""; if (cell.inRange) { - title = - n === 0 ? `No sessions · ${date}` : `${n} session${n === 1 ? "" : "s"} · ${date}`; + title = heatTitle(cell.sessions, cell.tests, date); } else if (cell.inYear) { title = date; } @@ -859,7 +948,7 @@ const sessionHeat = computed(() => { level, inRange: cell.inRange, inYear: cell.inYear, - count: n, + count: testMode ? cell.tests : cell.sessions, title, }); } @@ -876,15 +965,18 @@ const sessionHeat = computed(() => { } const inYearCells = raw.filter((c) => c.inYear && c.inRange); - const activeDays = inYearCells.filter((c) => c.sessions > 0).length; + const activeDays = inYearCells.filter((c) => + testMode ? c.tests > 0 : c.sessions > 0, + ).length; - // Year totals for the header — stable on day click (list filters separately) + // Year totals for the header — match the active list filter (actual vs test). let msgs = 0; let tokensIn = 0; let out = 0; const yearStart = `${year}-01-01`; const yearEnd = `${year}-12-31`; - for (const s of filteredSessions.value) { + for (const s of heatBaseSessions.value) { + if (testMode ? !isPilotSession(s) : isPilotSession(s)) continue; const r = sessionDayRange(s); if (!r || r.end < yearStart || r.start > yearEnd) continue; msgs += s.messageCount ?? 0; @@ -1233,6 +1325,16 @@ async function focusSession(s: SessionRef): Promise { .sess-time { width: 64px; } +.sess-pilot-mark { + display: inline-block; + margin-right: 6px; + color: var(--text-faint); + font-size: var(--fs-2xs); +} +.sess-row:hover .sess-pilot-mark, +.sess-row.picked .sess-pilot-mark { + color: var(--text-dim); +} .compare-hint { margin: 6px 0 0; font-size: var(--fs-2xs); diff --git a/packages/web/src/views/dashboard/SettingsView.vue b/packages/web/src/views/dashboard/SettingsView.vue index ce83133..ee5e027 100644 --- a/packages/web/src/views/dashboard/SettingsView.vue +++ b/packages/web/src/views/dashboard/SettingsView.vue @@ -207,6 +207,44 @@

+
providers
+ +
+
harness extras
+
+ post-answer extras +
+ + +
+
+

+ Provider post-answer work that holds the CLI open: Muse reminder subagents, + Claude slash skills, Grok subagents, Antigravity slash skills. + off (default) skips them for faster workflow nodes. Flipable on the + canvas HUD while a run is live, or per agent node. Stored as + harnessExtras in settings.json. +

+
+ + +
costs
@@ -341,6 +379,7 @@ import { vColResize } from "@/lib/colResize"; import { useSettingsStore } from "@/stores/settings"; import SettingsCustomNodes from "./settings/SettingsCustomNodes.vue"; import SettingsMcp from "./settings/SettingsMcp.vue"; +import SettingsPilot from "./settings/SettingsPilot.vue"; import "./chrome.css"; interface InternalItem { @@ -424,6 +463,10 @@ async function setShowExamples(on: boolean): Promise { await saveSettings(); } +async function setHarnessExtras(on: boolean): Promise { + await settings.setHarnessExtras(on); +} + async function setNotifyEnabled(on: boolean): Promise { if (settings.notifications.enabled === on) return; settings.notifications.enabled = on; diff --git a/packages/web/src/views/dashboard/settings/SettingsPilot.vue b/packages/web/src/views/dashboard/settings/SettingsPilot.vue new file mode 100644 index 0000000..f061c3a --- /dev/null +++ b/packages/web/src/views/dashboard/settings/SettingsPilot.vue @@ -0,0 +1,1118 @@ + + + + + diff --git a/packages/web/src/views/graph-editor/GraphRunHud.vue b/packages/web/src/views/graph-editor/GraphRunHud.vue new file mode 100644 index 0000000..c1b7723 --- /dev/null +++ b/packages/web/src/views/graph-editor/GraphRunHud.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/packages/web/src/views/graph-editor/NodeContextMenu.vue b/packages/web/src/views/graph-editor/NodeContextMenu.vue index 5ce9107..bc55f3e 100644 --- a/packages/web/src/views/graph-editor/NodeContextMenu.vue +++ b/packages/web/src/views/graph-editor/NodeContextMenu.vue @@ -6,6 +6,13 @@ @contextmenu.prevent >
{{ label }}
+