@uniqbit/mate-core 0.18.1-canary.4 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import {
11
11
  switchView,
12
12
  toHref,
13
13
  } from "../selection";
14
+ import { REPORTS_ROUTE, VAULT_CHANGES_ROUTE, VAULT_DIR_ROUTE, VAULT_FILTER_ROUTE } from "../routes";
14
15
  import { STUDIO_CLIENT_SCRIPT, STUDIO_PREPAINT_SCRIPT } from "./client";
15
16
  import { CompanionPicker } from "./companion-picker";
16
17
  import { CompanionSelector } from "./companion-selector";
@@ -18,6 +19,8 @@ import { Dashboard } from "./dashboard/index";
18
19
  import { CompanionError } from "./error";
19
20
  import { formatCollectedAt, type StudioPage, type StudioVaultPage } from "./model";
20
21
  import type { VaultTreeNode } from "../vault";
22
+ import { Reports } from "./reports/index";
23
+ import { specActionOffers } from "../actions";
21
24
  import { Specs } from "./specs/index";
22
25
  import { SelectionFields } from "./selection-fields";
23
26
  import { STUDIO_STYLES } from "./styles";
@@ -55,6 +58,10 @@ const VIEW_DETAILS = {
55
58
  eyebrow: "Companion files",
56
59
  title: "Vault",
57
60
  },
61
+ reports: {
62
+ eyebrow: "Agent output",
63
+ title: "Reports",
64
+ },
58
65
  } as const;
59
66
 
60
67
  /**
@@ -99,6 +106,7 @@ function StudioShell({ page }: { page: StudioPage }) {
99
106
  className="shell"
100
107
  data-companion={page.companion ? companionDigest(page.companion.path) : undefined}
101
108
  data-terminal={page.terminal ? "" : undefined}
109
+ {...reportAttributes(page)}
102
110
  >
103
111
  <Sidebar page={page} />
104
112
  <main className="main">
@@ -109,10 +117,27 @@ function StudioShell({ page }: { page: StudioPage }) {
109
117
  </div>
110
118
  {page.terminal ? <TerminalDrawerButton /> : null}
111
119
  <div className="toast" id="studio-toast" data-shown="false" />
120
+ <div className="toast toast-report" id="reports-toast" data-shown="false" role="status">
121
+ <span id="reports-toast-title" />
122
+ <a id="reports-toast-open">Open</a>
123
+ </div>
112
124
  </>
113
125
  );
114
126
  }
115
127
 
128
+ /** What the page client needs to follow the shown companion's reports; nothing without one. */
129
+ function reportAttributes(page: StudioPage): Record<string, string> {
130
+ if (!page.companion) return {};
131
+ const digest = companionDigest(page.companion.path);
132
+ const query = new URLSearchParams({ companion: digest });
133
+ const events = new URLSearchParams({ companion: digest, scope: "reports" });
134
+ return {
135
+ "data-reports-url": `${REPORTS_ROUTE}?${query}`,
136
+ "data-reports-events-url": `${VAULT_CHANGES_ROUTE}?${events}`,
137
+ "data-reports-view-url": toHref(switchView(page.selection, "reports"), "/"),
138
+ };
139
+ }
140
+
116
141
  function Content({ page }: { page: StudioPage }) {
117
142
  if (!page.companion) {
118
143
  return <CompanionPicker inventory={page.inventory} selection={page.selection} />;
@@ -121,6 +146,7 @@ function Content({ page }: { page: StudioPage }) {
121
146
  return <CompanionError companionPath={page.error.companionPath} reason={page.error.reason} />;
122
147
  }
123
148
  if (page.selection.view === "vault") return <Vault page={page} selection={page.selection} />;
149
+ if (page.selection.view === "reports") return <Reports />;
124
150
  if (!page.payload) {
125
151
  return (
126
152
  <section className="panel">
@@ -131,7 +157,15 @@ function Content({ page }: { page: StudioPage }) {
131
157
  if (page.selection.view === "workflow") {
132
158
  return <Workflow payload={page.payload} />;
133
159
  }
134
- if (page.selection.view === "specs") return <Specs payload={page.payload} />;
160
+ if (page.selection.view === "specs") {
161
+ const { payload } = page;
162
+ return (
163
+ <Specs
164
+ payload={payload}
165
+ actions={specActionOffers(payload.companionPath, payload.skillInventory, page.terminal)}
166
+ />
167
+ );
168
+ }
135
169
  if (page.selection.view === "skills") return <Skills skills={page.payload.skillInventory} />;
136
170
  return <Dashboard payload={page.payload} />;
137
171
  }
@@ -185,6 +219,7 @@ const VIEW_NAV: readonly (readonly [StudioView, string])[] = [
185
219
  ["specs", "Specs"],
186
220
  ["workflow", "Workflow"],
187
221
  ["skills", "Skills"],
222
+ ["reports", "Reports"],
188
223
  ];
189
224
 
190
225
  /**
@@ -201,6 +236,11 @@ function ViewNav({ selection }: { selection: StudioSelection }) {
201
236
  <SelectionFields selection={switchView(selection, view)} />
202
237
  <button type="submit" aria-pressed={selection.view === view}>
203
238
  <span>{label}</span>
239
+ {view === "reports" ? (
240
+ <span id="reports-badge" className="nav-badge" hidden>
241
+ new
242
+ </span>
243
+ ) : null}
204
244
  </button>
205
245
  </form>
206
246
  ))}
@@ -266,6 +306,83 @@ export function renderVaultView(page: StudioPage): string {
266
306
  );
267
307
  }
268
308
 
309
+ /** A folder's entries, for a folder the page did not expand; `null` when the listed tree lacks it. */
310
+ export function renderVaultChildren(page: StudioPage): string | null {
311
+ const { selection, vault } = page;
312
+ if (!vault?.tree || selection.view !== "vault" || !selection.openDir) return null;
313
+ const folder = findDirectory(vault.tree, selection.openDir);
314
+ if (!folder) return null;
315
+ const nobody = new Set<string>();
316
+ return String(
317
+ <>
318
+ {(folder.children ?? []).toSorted(byKindThenName).map((child) => (
319
+ <VaultNode key={child.path} node={child} selection={selection} expanded={nobody} />
320
+ ))}
321
+ </>,
322
+ );
323
+ }
324
+
325
+ /** Most matches a filter shows; the rest are counted, not rendered. */
326
+ export const VAULT_FILTER_LIMIT = 200;
327
+
328
+ function matchingFiles(
329
+ tree: VaultTreeNode[],
330
+ needle: string,
331
+ ): { shown: VaultTreeNode[]; total: number } {
332
+ const shown: VaultTreeNode[] = [];
333
+ let total = 0;
334
+ const stack = [...tree].reverse();
335
+ while (stack.length > 0) {
336
+ const node = stack.pop()!;
337
+ if (node.kind === "directory") {
338
+ for (let index = (node.children?.length ?? 0) - 1; index >= 0; index -= 1) {
339
+ stack.push(node.children![index]!);
340
+ }
341
+ } else if (segments(node.path).join("/").toLowerCase().includes(needle)) {
342
+ total += 1;
343
+ if (shown.length < VAULT_FILTER_LIMIT) shown.push(node);
344
+ }
345
+ }
346
+ return { shown, total };
347
+ }
348
+
349
+ /** Name-filter results over the whole listed tree, bounded; never touches the disk. */
350
+ export function renderVaultMatches(page: StudioPage, query: string): string {
351
+ const { selection, vault } = page;
352
+ const needle = query.trim().toLowerCase();
353
+ if (!vault?.tree || selection.view !== "vault" || !needle) return "";
354
+ const { shown, total } = matchingFiles(vault.tree, needle);
355
+ return String(
356
+ <>
357
+ {shown.length === 0 ? (
358
+ <p className="empty">No files match.</p>
359
+ ) : (
360
+ <nav className="vault-tree vault-results" aria-label="Matching files">
361
+ {shown.map((node) => (
362
+ <VaultFileLink
363
+ key={node.path}
364
+ node={node}
365
+ selection={selection}
366
+ label={segments(node.path).join("/")}
367
+ />
368
+ ))}
369
+ </nav>
370
+ )}
371
+ {total > shown.length ? (
372
+ <p className="note" data-vault-more>
373
+ {total - shown.length} more match{total - shown.length === 1 ? "" : "es"}; refine the
374
+ filter.
375
+ </p>
376
+ ) : null}
377
+ </>,
378
+ );
379
+ }
380
+
381
+ /** The selection a read-only vault route is addressed by: no one-shot refresh. */
382
+ function plain(selection: StudioVaultSelection): StudioVaultSelection {
383
+ return { ...selection, refresh: false };
384
+ }
385
+
269
386
  function segments(relative: string): string[] {
270
387
  return relative.split(/[\\/]/).filter(Boolean);
271
388
  }
@@ -326,11 +443,19 @@ function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSe
326
443
  className="vault-layout"
327
444
  data-vault-layout
328
445
  data-vault-view={vault.tree === null ? toHref(selection, "/api/vault/view") : undefined}
446
+ data-vault-refresh-url={toHref(refresh(selection), "/api/vault/view")}
447
+ data-vault-filter-url={toHref(plain(selection), VAULT_FILTER_ROUTE)}
448
+ data-vault-changes-url={toHref(plain(selection), VAULT_CHANGES_ROUTE)}
329
449
  >
330
450
  <aside className="panel vault-tree-panel" id="vault-tree-panel" aria-label="Files">
331
451
  <div className="vault-tree-head">
332
452
  <strong>Files</strong>
453
+ <RefreshTree selection={selection} />
333
454
  </div>
455
+ <p id="vault-stale" className="note vault-warning" role="status" hidden>
456
+ Files changed — refresh tree.
457
+ </p>
458
+ <p id="vault-refresh-status" className="note vault-warning" role="status" hidden />
334
459
  <input
335
460
  type="search"
336
461
  id="vault-filter"
@@ -339,6 +464,7 @@ function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSe
339
464
  aria-label="Go to file"
340
465
  hidden
341
466
  />
467
+ <div id="vault-filter-results" data-vault-filter-results hidden />
342
468
  <VaultTreeSlot vault={vault} selection={selection} />
343
469
  </aside>
344
470
  <div className="vault-main">
@@ -362,9 +488,10 @@ function Vault({ page, selection }: { page: StudioPage; selection: StudioVaultSe
362
488
  );
363
489
  }
364
490
 
491
+ /** The refresh that keeps the tree and filter data current; without script it is a plain reload. */
365
492
  function RefreshTree({ selection }: { selection: StudioVaultSelection }) {
366
493
  return (
367
- <form method="get" action="/" data-studio-navigation>
494
+ <form method="get" action="/" data-studio-navigation data-vault-refresh>
368
495
  <SelectionFields selection={refresh(selection)} />
369
496
  <button type="submit">Refresh tree</button>
370
497
  </form>
@@ -382,7 +509,6 @@ function VaultTreeSlot({
382
509
  return (
383
510
  <div data-vault-slot="tree">
384
511
  <p className="note vault-warning">The files could not be listed: {vault.failure}</p>
385
- <RefreshTree selection={selection} />
386
512
  </div>
387
513
  );
388
514
  }
@@ -395,11 +521,12 @@ function VaultTreeSlot({
395
521
  }
396
522
  const expanded = expandedPaths(selection);
397
523
  return (
398
- <div data-vault-slot="tree">
524
+ <div data-vault-slot="tree" data-vault-generation={vault.generation ?? undefined}>
399
525
  {vault.warning ? (
400
526
  <p className="note vault-warning">{vault.warning}. The tree may be out of date.</p>
401
- ) : null}
402
- {!vault.watching ? <RefreshTree selection={selection} /> : null}
527
+ ) : vault.watching ? null : (
528
+ <p className="note vault-warning">The tree may be out of date.</p>
529
+ )}
403
530
  {vault.tree.length === 0 ? (
404
531
  <p className="empty">This companion has no markdown files.</p>
405
532
  ) : (
@@ -431,6 +558,9 @@ function VaultNode({
431
558
  open={open}
432
559
  data-vault-dir={key}
433
560
  data-vault-expanded={open ? "" : undefined}
561
+ data-vault-children-url={
562
+ open ? undefined : toHref(openFolder(selection, key), VAULT_DIR_ROUTE)
563
+ }
434
564
  >
435
565
  <summary
436
566
  aria-current={
@@ -442,27 +572,45 @@ function VaultNode({
442
572
  <span>{node.name}</span>
443
573
  </summary>
444
574
  <div className="vault-children">
445
- {(node.children ?? []).toSorted(byKindThenName).map((child) => (
446
- <VaultNode key={child.path} node={child} selection={selection} expanded={expanded} />
447
- ))}
575
+ {open
576
+ ? (node.children ?? [])
577
+ .toSorted(byKindThenName)
578
+ .map((child) => (
579
+ <VaultNode
580
+ key={child.path}
581
+ node={child}
582
+ selection={selection}
583
+ expanded={expanded}
584
+ />
585
+ ))
586
+ : null}
448
587
  </div>
449
588
  </details>
450
589
  );
451
590
  }
591
+ return <VaultFileLink node={node} selection={selection} label={node.name} />;
592
+ }
593
+
594
+ /** A plain link: no form, no hidden fields, no handler of its own. */
595
+ function VaultFileLink({
596
+ node,
597
+ selection,
598
+ label,
599
+ }: {
600
+ node: VaultTreeNode;
601
+ selection: StudioVaultSelection;
602
+ label: string;
603
+ }) {
452
604
  return (
453
- <form
454
- method="get"
455
- action="/"
605
+ <a
456
606
  className="vault-file"
457
- data-studio-navigation
458
- data-vault-entry={key}
607
+ href={toHref(openFile(selection, node.path))}
608
+ data-vault-entry={segments(node.path).join("/")}
609
+ aria-current={selection.openPath === node.path ? "page" : undefined}
459
610
  >
460
- <SelectionFields selection={openFile(selection, node.path)} />
461
- <button type="submit" aria-current={selection.openPath === node.path ? "page" : undefined}>
462
- <FileIcon />
463
- <span>{node.name}</span>
464
- </button>
465
- </form>
611
+ <FileIcon />
612
+ <span>{label}</span>
613
+ </a>
466
614
  );
467
615
  }
468
616
 
@@ -41,6 +41,8 @@ export interface StudioVaultPage {
41
41
  overwritten: VaultFile | null;
42
42
  watching: boolean;
43
43
  warning: string | null;
44
+ /** The listing generation `tree` was taken at; absent while the tree is deferred. */
45
+ generation?: number | null;
44
46
  }
45
47
 
46
48
  /**
@@ -0,0 +1,49 @@
1
+ /** @jsxImportSource hono/jsx */
2
+
3
+ /**
4
+ * The list, frame and empty note are filled by the page client from
5
+ * `GET /api/reports`; titles are written as text, never markup.
6
+ */
7
+ export function Reports() {
8
+ return (
9
+ <section className="panel reports-panel" data-reports-view>
10
+ <div className="section-header">
11
+ <div>
12
+ <h3>Hosted reports</h3>
13
+ <p className="section-note">Reports published by agent sessions Studio started.</p>
14
+ </div>
15
+ </div>
16
+ <div className="reports-layout">
17
+ <div className="reports-side">
18
+ <ul id="reports-list" className="reports-list" aria-label="Reports" />
19
+ <template id="reports-item-template">
20
+ <li>
21
+ <button type="button" data-report-id="">
22
+ <span data-report-title />
23
+ <span className="report-time" data-report-time />
24
+ </button>
25
+ </li>
26
+ </template>
27
+ <p id="reports-empty" className="empty">
28
+ No hosted reports yet.
29
+ </p>
30
+ </div>
31
+ <div className="reports-main">
32
+ <div className="reports-bar">
33
+ <a id="reports-open" target="_blank" rel="noopener noreferrer" hidden>
34
+ Open in new tab
35
+ </a>
36
+ </div>
37
+ <iframe
38
+ id="reports-frame"
39
+ className="reports-frame"
40
+ title="Hosted report"
41
+ sandbox="allow-scripts allow-modals"
42
+ referrerPolicy="no-referrer"
43
+ hidden
44
+ />
45
+ </div>
46
+ </div>
47
+ </section>
48
+ );
49
+ }
@@ -1,8 +1,10 @@
1
1
  /** @jsxImportSource hono/jsx */
2
2
 
3
+ import { SUBJECT_PATTERN, type SpecActionOffer } from "../../actions";
3
4
  import type { StudioCompanionPayload, StudioSpec } from "../../payload";
4
5
  import { Warnings } from "../warnings";
5
6
 
7
+ const NO_ACTIONS: SpecActionOffer[] = [];
6
8
  const UNASSIGNED_AREA = "unassigned";
7
9
 
8
10
  /** A spec binds one Area or several, so it appears under each of them. */
@@ -27,19 +29,70 @@ function specStatus(spec: StudioSpec): { label: string; invalid: boolean } {
27
29
 
28
30
  interface SpecsProps {
29
31
  payload: StudioCompanionPayload;
32
+ /** Skill actions offered on each spec row; none by default. */
33
+ actions?: SpecActionOffer[];
30
34
  }
31
35
 
32
36
  /** Its own view: an Area map is read on its own, not alongside the changes. */
33
- export function Specs({ payload }: SpecsProps) {
37
+ export function Specs({ payload, actions = NO_ACTIONS }: SpecsProps) {
34
38
  return (
35
39
  <>
36
- <SpecsByArea specs={payload.specs} />
40
+ <SpecsByArea specs={payload.specs} actions={actions} />
37
41
  <Warnings warnings={payload.warnings} />
38
42
  </>
39
43
  );
40
44
  }
41
45
 
42
- function SpecsByArea({ specs }: { specs: StudioSpec[] }) {
46
+ function SpecActions({ capability, actions }: { capability: string; actions: SpecActionOffer[] }) {
47
+ if (!SUBJECT_PATTERN.test(capability)) return null;
48
+ return (
49
+ <span className="spec-actions">
50
+ {actions.map(({ action, runAgents, copyAgents }) => (
51
+ <>
52
+ {runAgents.length > 0 ? (
53
+ <button
54
+ type="button"
55
+ className="button spec-action"
56
+ data-studio-action={action.id}
57
+ data-studio-subject={capability}
58
+ data-studio-agents={runAgents.join(" ")}
59
+ >
60
+ {action.label}
61
+ </button>
62
+ ) : null}
63
+ <button
64
+ type="button"
65
+ className="runway-step-copy spec-copy"
66
+ aria-label="Copy prompt"
67
+ title="Copy prompt"
68
+ data-studio-copy-prompt={action.id}
69
+ data-prompt-claude={
70
+ copyAgents.includes("claude") ? action.prompts.claude(capability) : undefined
71
+ }
72
+ data-prompt-opencode={
73
+ copyAgents.includes("opencode") ? action.prompts.opencode(capability) : undefined
74
+ }
75
+ >
76
+ <svg
77
+ viewBox="0 0 16 16"
78
+ width="14"
79
+ height="14"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ stroke-width="1.5"
83
+ aria-hidden="true"
84
+ >
85
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
86
+ <path d="M10.5 3.5v-.5a1.5 1.5 0 0 0-1.5-1.5H3.5A1.5 1.5 0 0 0 2 3v5.5A1.5 1.5 0 0 0 3.5 10H4" />
87
+ </svg>
88
+ </button>
89
+ </>
90
+ ))}
91
+ </span>
92
+ );
93
+ }
94
+
95
+ function SpecsByArea({ specs, actions }: { specs: StudioSpec[]; actions: SpecActionOffer[] }) {
43
96
  const groups = groupSpecsByArea(specs);
44
97
 
45
98
  return (
@@ -81,12 +134,15 @@ function SpecsByArea({ specs }: { specs: StudioSpec[] }) {
81
134
  </span>
82
135
  </div>
83
136
  </div>
84
- <span
85
- className={
86
- status.invalid ? "spec-status spec-status-invalid" : "spec-status"
87
- }
88
- >
89
- {status.label}
137
+ <span className="spec-card-end">
138
+ <SpecActions capability={spec.capability} actions={actions} />
139
+ <span
140
+ className={
141
+ status.invalid ? "spec-status spec-status-invalid" : "spec-status"
142
+ }
143
+ >
144
+ {status.label}
145
+ </span>
90
146
  </span>
91
147
  </div>
92
148
  );
@@ -201,6 +201,17 @@ ul.plain li:last-child { border-bottom: 0; }
201
201
  opacity: 0; transition: opacity .16s; pointer-events: none; z-index: 20;
202
202
  }
203
203
  .toast[data-shown="true"] { opacity: 1; }
204
+ .toast-report { display: flex; gap: 10px; align-items: center; bottom: 64px; }
205
+ .toast-report[data-shown="true"] { pointer-events: auto; }
206
+ .toast-report a { color: inherit; font-weight: 700; text-decoration: underline; }
207
+ .nav-badge { padding: 1px 6px; border-radius: 999px; background: var(--accent); color: #0e1116; font-size: .6rem; font-weight: 750; text-transform: uppercase; }
208
+ .reports-layout { display: grid; grid-template-columns: minmax(200px, 280px) 1fr; gap: 12px; min-height: 60vh; }
209
+ .reports-list { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
210
+ .reports-list button { width: 100%; padding: 8px 10px; text-align: left; font-size: .78rem; }
211
+ .reports-list button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
212
+ .reports-list .report-time { display: block; color: var(--muted); font-size: .66rem; }
213
+ .reports-main { display: grid; grid-template-rows: auto 1fr; gap: 8px; }
214
+ .reports-frame { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border); border-radius: 8px; background: #fff; }
204
215
 
205
216
  .warnings { border-color: var(--warn); }
206
217
  .error { border-color: var(--bad); }
@@ -276,6 +287,11 @@ ul.plain li:last-child { border-bottom: 0; }
276
287
  .spec-card-row:first-child { padding-top: 0; }
277
288
  .spec-card-row strong { overflow: hidden; font-family: var(--mono); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
278
289
  .spec-card-meta { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: .7rem; }
290
+ .spec-card-end { display: inline-flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
291
+ .spec-copy { display: inline-flex; align-items: center; align-self: center; padding: 4px 6px; }
292
+ .spec-actions { display: inline-flex; gap: 6px; }
293
+ .spec-action { padding: 3px 7px; font-size: .68rem; }
294
+ html[data-agent-viewed] [data-studio-action] { display: none; }
279
295
  .spec-status { padding: 2px 5px; border: 1px solid var(--done); background: var(--done-soft); color: var(--done); font-family: var(--mono); font-size: .61rem; text-transform: uppercase; }
280
296
  .spec-status-invalid { border-color: var(--bad); background: var(--bad-soft); color: var(--bad); }
281
297
 
@@ -375,12 +391,13 @@ ul.plain li:last-child { border-bottom: 0; }
375
391
  .vault-directory > summary::-webkit-details-marker { display: none; }
376
392
  .vault-directory > summary:hover, .vault-directory > summary[aria-current="page"] { background: var(--accent-soft); }
377
393
  .vault-children { display: grid; gap: 1px; margin-left: 11px; padding-left: 8px; border-left: 1px solid var(--border-soft); }
378
- .vault-file { display: contents; }
379
- .vault-file[hidden], .vault-directory[hidden] { display: none; }
380
- .vault-file button { display: flex; align-items: center; gap: 6px; width: 100%; overflow: hidden; padding: 4px 6px 4px 24px; border: 0; border-radius: 6px; background: transparent; color: var(--text); font-size: .8rem; text-align: left; white-space: nowrap; }
381
- .vault-file button span, .vault-directory > summary span { overflow: hidden; text-overflow: ellipsis; }
382
- .vault-file button:hover { background: var(--accent-soft); }
383
- .vault-file button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
394
+ .vault-directory[hidden] { display: none; }
395
+ #vault-tree-panel[data-vault-filtering] [data-vault-slot="tree"] { display: none; }
396
+ .vault-tree-head form { margin: 0; }
397
+ .vault-file { display: flex; text-decoration: none; align-items: center; gap: 6px; width: 100%; overflow: hidden; padding: 4px 6px 4px 24px; border: 0; border-radius: 6px; background: transparent; color: var(--text); font-size: .8rem; text-align: left; white-space: nowrap; }
398
+ .vault-file span, .vault-directory > summary span { overflow: hidden; text-overflow: ellipsis; }
399
+ .vault-file:hover { background: var(--accent-soft); }
400
+ .vault-file[aria-current="page"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
384
401
  .vault-bar { display: flex; align-items: center; gap: 12px; min-height: 36px; margin-bottom: 12px; }
385
402
  .vault-tree-toggle { flex: none; padding: 5px 10px; font-size: .78rem; }
386
403
  .vault-breadcrumb ol { display: flex; flex-wrap: wrap; align-items: center; margin: 0; padding: 0; list-style: none; font-size: .95rem; }
@@ -460,6 +477,9 @@ ul.plain li:last-child { border-bottom: 0; }
460
477
  .terminal-target { margin: 0; color: var(--muted); font-size: .8rem; overflow-wrap: anywhere; }
461
478
  .terminal-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
462
479
  .terminal-actions .button { padding: 6px 9px; font-size: .78rem; }
480
+ .terminal-choice { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: .78rem; }
481
+ .terminal-choice[hidden] { display: none; }
482
+ .terminal-choice .button { padding: 6px 9px; font-size: .78rem; }
463
483
  .terminal-status { margin: 0; color: var(--muted); font-size: 12px; }
464
484
  .terminal-view { flex: 1; min-height: 160px; background: #000; border-radius: 8px; padding: 6px; overflow: hidden; }
465
485
  .terminal-sessions-footer { flex: none; display: grid; gap: 6px; max-height: 28vh; overflow-y: auto; }
@@ -103,6 +103,13 @@ export function TerminalSidebar({ terminal }: { terminal: StudioTerminalPage })
103
103
  Reconnect
104
104
  </button>
105
105
  </div>
106
+ <div
107
+ className="terminal-choice"
108
+ id="terminal-action-choice"
109
+ role="group"
110
+ aria-label="Choose an agent"
111
+ hidden
112
+ />
106
113
  <p className="terminal-status" id="terminal-status" role="status">
107
114
  Connecting…
108
115
  </p>
@@ -288,6 +295,16 @@ export const STUDIO_TERMINAL_SCRIPT = `(function () {
288
295
  function status(text) { if (statusNode) statusNode.textContent = text; }
289
296
  function state(name) { panel.setAttribute("data-terminal-state", name); }
290
297
  function currentSession() { return load(${JSON.stringify(TERMINAL_SESSION_KEY)}); }
298
+ var root = document.documentElement;
299
+ var choice = document.getElementById("terminal-action-choice");
300
+ var AGENT_LABELS = ${JSON.stringify(AGENT_LABELS)};
301
+ /** Which agent this connection views, or "pending" until a stored session reattaches; the page hides run buttons while set. */
302
+ function viewing(agent) {
303
+ if (agent) root.setAttribute("data-agent-viewed", agent);
304
+ else root.removeAttribute("data-agent-viewed");
305
+ if (agent && choice) choice.hidden = true;
306
+ }
307
+ if (currentSession()) viewing("pending");
291
308
  function size() { return { cols: term.cols, rows: term.rows }; }
292
309
  function send(message) {
293
310
  if (ws && ws.readyState === 1) { ws.send(JSON.stringify(message)); return true; }
@@ -317,28 +334,34 @@ export const STUDIO_TERMINAL_SCRIPT = `(function () {
317
334
  try { message = JSON.parse(event.data); } catch (error) { return; }
318
335
  if (message.type === "ready") {
319
336
  store(${JSON.stringify(TERMINAL_SESSION_KEY)}, message.sessionId);
337
+ viewing(message.agent);
320
338
  term.reset();
321
339
  state("connected");
322
340
  status("Connected to " + message.agent + " in " + message.companionPath + ".");
323
341
  term.focus();
324
342
  } else if (message.type === "exit") {
325
343
  store(${JSON.stringify(TERMINAL_SESSION_KEY)}, null);
344
+ viewing(null);
326
345
  state("detached");
327
346
  status("The agent exited with status " + message.status + ".");
328
347
  } else if (message.type === "ended") {
329
348
  store(${JSON.stringify(TERMINAL_SESSION_KEY)}, null);
349
+ viewing(null);
330
350
  state("detached");
331
351
  status("The session ended" + (message.reason ? ": " + message.reason : "") + ".");
332
352
  } else if (message.type === "detached") {
353
+ viewing(null);
333
354
  state("detached");
334
355
  status("Detached (" + message.reason + "); the agent keeps running.");
335
356
  } else if (message.type === "takenOver") {
357
+ viewing(null);
336
358
  takenOver = true;
337
359
  state("detached");
338
360
  status("Another tab took over this session.");
339
361
  if (reconnectButton) reconnectButton.hidden = false;
340
362
  } else if (message.type === "gone") {
341
363
  store(${JSON.stringify(TERMINAL_SESSION_KEY)}, null);
364
+ viewing(null);
342
365
  state("detached");
343
366
  status("That session no longer exists.");
344
367
  } else if (message.type === "error") {
@@ -371,12 +394,47 @@ export const STUDIO_TERMINAL_SCRIPT = `(function () {
371
394
  send({ type: "resize", cols: next.cols, rows: next.rows });
372
395
  });
373
396
 
397
+ /** \`task\` carries a registered action and subject; the server builds the prompt from them. */
398
+ function startAgent(agent, task) {
399
+ var dims = size();
400
+ var message = { type: "start", agent: agent, companion: companion, tabId: tabId, cols: dims.cols, rows: dims.rows };
401
+ if (task) { message.action = task.action; message.subject = task.subject; }
402
+ if (!send(message)) { pending = message; connect(); }
403
+ }
374
404
  Array.prototype.forEach.call(document.querySelectorAll("[data-terminal-start]"), function (button) {
375
- button.addEventListener("click", function () {
376
- var dims = size();
377
- var message = { type: "start", agent: button.getAttribute("data-terminal-start"), companion: companion, tabId: tabId, cols: dims.cols, rows: dims.rows };
378
- if (!send(message)) { pending = message; connect(); }
405
+ button.addEventListener("click", function () { startAgent(button.getAttribute("data-terminal-start")); });
406
+ });
407
+
408
+ /** The sidebar script owns collapse and drawer state, so reveal the terminal through its controls. */
409
+ function revealTerminal() {
410
+ var expand = document.getElementById("terminal-expand");
411
+ if (root.hasAttribute("data-terminal-collapsed") && expand) expand.click();
412
+ var opener = document.getElementById("terminal-drawer-open");
413
+ if (opener && !panel.hasAttribute("data-drawer-open") && getComputedStyle(opener).display !== "none") opener.click();
414
+ }
415
+ function showChoice(agents, task) {
416
+ if (!choice) return;
417
+ while (choice.firstChild) choice.removeChild(choice.firstChild);
418
+ var label = document.createElement("span");
419
+ label.textContent = "Run " + task.action + " with:";
420
+ choice.appendChild(label);
421
+ agents.forEach(function (agent) {
422
+ choice.appendChild(button(AGENT_LABELS[agent], function () {
423
+ choice.hidden = true;
424
+ startAgent(agent, task);
425
+ }));
379
426
  });
427
+ choice.appendChild(button("Cancel", function () { choice.hidden = true; }));
428
+ choice.hidden = false;
429
+ }
430
+ document.addEventListener("studio:terminal-action", function (event) {
431
+ var task = event.detail || {};
432
+ var agents = (task.agents || []).filter(function (agent) { return AGENT_LABELS[agent]; });
433
+ if (!agents.length) return;
434
+ revealTerminal();
435
+ if (root.hasAttribute("data-agent-viewed")) { status("End the current session first."); return; }
436
+ if (agents.length === 1) startAgent(agents[0], task);
437
+ else showChoice(agents, task);
380
438
  });
381
439
  if (reconnectButton) reconnectButton.addEventListener("click", function () { attempts = 0; connect(); });
382
440
 
@@ -38,5 +38,6 @@ export async function vaultState(
38
38
  watching: tree?.watching ?? false,
39
39
  warning: tree?.warning ?? null,
40
40
  failure,
41
+ generation: tree?.generation ?? null,
41
42
  };
42
43
  }