my-durable 0.0.35 → 0.0.37

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.
@@ -7,8 +7,8 @@
7
7
  <meta name="referrer" content="no-referrer" />
8
8
  <link rel="icon" type="image/svg+xml" href="/icons/durable-pi.svg" />
9
9
  <title>Durable Pi | Network</title>
10
- <script type="module" crossorigin src="/assets/index-W7_6LpLB.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-CpTBG0M5.css">
10
+ <script type="module" crossorigin src="/assets/index-DdEAB2rE.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-BrDwRiiN.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="app"></div>
@@ -2,9 +2,10 @@
2
2
  // something actually looks at it, kept across tab switches, never re-fetched
3
3
  // while fresh. In-flight calls are shared so chip + panel + re-entry of either
4
4
  // cost ONE device round-trip.
5
- import { reactive } from "vue";
5
+ import { reactive, watch } from "vue";
6
6
  import type { GitStatus } from "@sdk/client";
7
7
  import { deviceClient } from "./hub";
8
+ import { network } from "./store";
8
9
 
9
10
  export interface GitEntry {
10
11
  at: number; // last settled fetch (0 = never)
@@ -37,6 +38,32 @@ export async function ensureGitBatch(deviceId: string, paths: string[]) {
37
38
  }
38
39
  const staleMsForBatch = 60_000;
39
40
 
41
+ /** fs-auto-refresh, FE side: the device's tree carries dirty marks for repos
42
+ * its watcher saw change — refresh exactly those entries, still lazy (never
43
+ * an interval), throttled per entry. */
44
+ let wired = false;
45
+ export function wireGitAutoRefresh() {
46
+ if (wired || import.meta.env.SSR) return;
47
+ wired = true;
48
+ watch(() => network.devices.value, (devices) => {
49
+ const now = Date.now();
50
+ for (const d of devices) {
51
+ const marks = ((d as { tree?: { git?: { root: string; at: number }[] } }).tree?.git ?? []).filter(
52
+ (m) => now - m.at < 60_000,
53
+ );
54
+ if (!marks.length) continue;
55
+ for (const key of [...entries.keys()]) {
56
+ const [devId, cwd] = key.split("::");
57
+ if (devId !== d.id || !cwd) continue;
58
+ const e = entries.get(key)!;
59
+ if (!marks.some((m) => cwd === m.root || cwd.startsWith(m.root + "/") || cwd.startsWith(m.root + "\\"))) continue;
60
+ if (e.loading || now - e.at < 4000) continue; // throttle: bursts coalesce
61
+ void ensureGit(devId, cwd, { force: true });
62
+ }
63
+ }
64
+ });
65
+ }
66
+
40
67
  /** Test hook: the store is module-global on purpose (tab switches must not
41
68
  * refetch) — tests reset it so entries never leak between cases. */
42
69
  export function clearGitCache() {
@@ -270,8 +270,20 @@ export type Dialog =
270
270
  };
271
271
 
272
272
  let toastTimer: ReturnType<typeof setTimeout>;
273
+ /** In-app notification center (orca delivery-service, FE-only v1): every
274
+ * notify() is kept as history — the bell lists what happened while you
275
+ * were elsewhere. Capped, newest first, marked read when the panel opens. */
276
+ export const notifications = reactive<{ id: number; text: string; at: number; read: boolean }[]>([]);
277
+ let notifSeq = 1;
278
+ export function markNotificationsRead() {
279
+ for (const n of notifications) n.read = true;
280
+ }
273
281
  export function notify(text: string) {
274
282
  ui.toast = text;
283
+ if (text) {
284
+ notifications.unshift({ id: notifSeq++, text, at: Date.now(), read: false });
285
+ if (notifications.length > 50) notifications.length = 50;
286
+ }
275
287
  clearTimeout(toastTimer);
276
288
  toastTimer = setTimeout(() => (ui.toast = ""), 4500);
277
289
  }
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from "vue";
3
- import { ArrowUp, ChevronDown, Clock3, Octagon, X } from "lucide-vue-next";
3
+ import { ArrowUp, ChevronDown, Clock3, LibraryBig, Octagon, Trash2, X } from "lucide-vue-next";
4
4
  import type { WhenBusy } from "@sdk/api";
5
5
  import { newId } from "../client/ids";
6
6
  import { commands, parseCommand, type SlashCommand } from "../client/commands";
@@ -19,6 +19,34 @@ import {
19
19
  } from "../client/store";
20
20
 
21
21
  const mode = ref<WhenBusy>("followUp");
22
+ /** orca QuickCommandsPane, FE-only v1: named snippets in localStorage, one
23
+ * click into the composer. Saved prompts never leave the browser. */
24
+ const PROMPTS_KEY = "dpi-prompt-library";
25
+ const libOpen = ref(false);
26
+ const libName = ref("");
27
+ type Snippet = { name: string; text: string };
28
+ const snippets = ref<Snippet[]>([]);
29
+ const loadSnippets = () => {
30
+ try {
31
+ snippets.value = JSON.parse(localStorage.getItem(PROMPTS_KEY) ?? "[]") as Snippet[];
32
+ } catch {
33
+ snippets.value = [];
34
+ }
35
+ };
36
+ const saveSnippets = () => localStorage.setItem(PROMPTS_KEY, JSON.stringify(snippets.value));
37
+ loadSnippets();
38
+ const saveCurrent = () => {
39
+ const text = ui.composer.trim();
40
+ const name = libName.value.trim() || text.slice(0, 24) || "snippet";
41
+ if (!text) return;
42
+ snippets.value = [{ name, text }, ...snippets.value.filter((p) => p.name !== name)].slice(0, 30);
43
+ saveSnippets();
44
+ libName.value = "";
45
+ };
46
+ const dropSnippet = (name: string) => {
47
+ snippets.value = snippets.value.filter((p) => p.name !== name);
48
+ saveSnippets();
49
+ };
22
50
  const pending = ref(false);
23
51
  const canSend = computed(
24
52
  () =>
@@ -160,6 +188,35 @@ function withdraw(id: number) {
160
188
  />
161
189
  <div class="composer-toolbar">
162
190
  <div class="composer-left">
191
+ <div class="lib-wrap">
192
+ <button
193
+ type="button"
194
+ class="icon-button"
195
+ :aria-label="`Prompt library (${snippets.length})`"
196
+ title="Prompt library — saved snippets"
197
+ @click="libOpen = !libOpen"
198
+ >
199
+ <LibraryBig :size="18" />
200
+ </button>
201
+ <div v-if="libOpen" class="lib" @click.stop>
202
+ <header><b>Prompts</b><button class="icon-button" aria-label="Close library" @click="libOpen = false"><X :size="14" /></button></header>
203
+ <p v-if="!snippets.length" class="hint">Type something below, give it a name, save it.</p>
204
+ <div class="save-row">
205
+ <input v-model="libName" placeholder="name…" @keyup.enter="saveCurrent" />
206
+ <button type="button" class="small" :disabled="!ui.composer.trim()" @click="saveCurrent">save composer as</button>
207
+ </div>
208
+ <ul>
209
+ <li v-for="p in snippets" :key="p.name">
210
+ <button type="button" class="use" :title="p.text" @click="((ui.composer = p.text), (libOpen = false))">
211
+ <b>{{ p.name }}</b>
212
+ </button>
213
+ <button type="button" class="icon-button" :aria-label="`Delete ${p.name}`" @click="dropSnippet(p.name)">
214
+ <Trash2 :size="13" />
215
+ </button>
216
+ </li>
217
+ </ul>
218
+ </div>
219
+ </div>
163
220
  <div class="queue-mode">
164
221
  <select
165
222
  v-model="mode"
@@ -196,3 +253,19 @@ function withdraw(id: number) {
196
253
  </div>
197
254
  </div>
198
255
  </template>
256
+
257
+ <style scoped>
258
+ .lib-wrap { position: relative; }
259
+ .lib {
260
+ position: absolute; bottom: calc(100% + 8px); left: 0; z-index: 70; width: 300px; max-height: 260px;
261
+ overflow: auto; background: var(--bg-2, #221a1c); border: 1px solid var(--line); border-radius: 10px;
262
+ padding: 10px 12px; box-shadow: 0 10px 30px rgb(0 0 0 / 50%); display: grid; gap: 8px;
263
+ }
264
+ .lib header { display: flex; justify-content: space-between; align-items: center; }
265
+ .lib .save-row { display: flex; gap: 6px; }
266
+ .lib .save-row input { flex: 1; min-width: 0; font: inherit; }
267
+ .lib ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
268
+ .lib li { display: flex; align-items: center; gap: 4px; }
269
+ .lib li .use { flex: 1; text-align: left; background: none; border: none; cursor: pointer; font: inherit; padding: 4px 6px; border-radius: 6px; color: inherit; }
270
+ .lib li .use:hover { background: #403536; }
271
+ </style>
@@ -34,7 +34,7 @@ import {
34
34
  } from "lucide-vue-next";
35
35
  import { deviceId, network, sessionSummary, notify, select } from "../client/store";
36
36
  import { deviceClient } from "../client/hub";
37
- import { ensureGit, gitEntry } from "../client/git-store";
37
+ import { ensureGit, gitEntry, wireGitAutoRefresh } from "../client/git-store";
38
38
  import type { GitStatus } from "@sdk/client";
39
39
 
40
40
  const props = defineProps<{ deviceId?: string; cwd?: string }>();
@@ -201,7 +201,7 @@ async function prCommentNow(n: number, text: string) {
201
201
  }
202
202
 
203
203
  /** Per-turn summaries (zcode taskChangeSummary) under the session chip. */
204
- const turns = ref<{ when: string; files: { path: string; writes: number }[] }[]>([]);
204
+ const turns = ref<{ when: string; files: { path: string; writes: number; a?: number; d?: number }[] }[]>([]);
205
205
  const turnsOpen = ref(false);
206
206
  const loadTurns = async () => {
207
207
  if (!devId.value || !sessionId.value) return;
@@ -233,6 +233,7 @@ const draftLoad = () => {
233
233
  commitMsg.value = localStorage.getItem(draftKey.value) ?? "";
234
234
  };
235
235
  if (!import.meta.env.SSR) {
236
+ wireGitAutoRefresh();
236
237
  void ensureGit(devId.value || "?", cwd.value || "?");
237
238
  draftLoad();
238
239
  }
@@ -961,7 +962,7 @@ const laneX = (lane: number) => lane * 12 + 7;
961
962
  <div v-for="(t, ti) in turns" :key="ti" class="cp-row">
962
963
  <code>t{{ turns.length - ti }}</code>
963
964
  <span class="muted-line">{{ t.when.slice(0, 16).replace("T", " ") }}</span>
964
- <span class="cp-subj">{{ t.files.map((f) => `${f.path}×${f.writes}`).join(", ") || "(no file writes)" }}</span>
965
+ <span class="cp-subj">{{ t.files.map((f) => `${f.path}×${f.writes}${f.a !== undefined || f.d !== undefined ? ` (+${f.a ?? 0} −${f.d ?? 0})` : ""}`).join(", ") || "(no file writes)" }}</span>
965
966
  </div>
966
967
  </div>
967
968
  <p v-if="atCap" class="muted-line cap">showing the first 500 changed files — commit in smaller batches</p>
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
3
3
  import {
4
4
  Activity,
5
5
  Braces,
6
+ Download,
6
7
  GitBranch,
7
8
  ChevronDown,
8
9
  FolderOpen,
@@ -23,7 +24,7 @@ import TasksPanel from "./TasksPanel.vue";
23
24
  import GitPanel from "./GitPanel.vue";
24
25
  import ExtensionsPanel from "./ExtensionsPanel.vue";
25
26
  import ContextBar from "./ContextBar.vue";
26
- import { message } from "../client/hub";
27
+ import { deviceClient, message } from "../client/hub";
27
28
  import { toolSignature } from "../client/signature";
28
29
  import {
29
30
  agentHandle,
@@ -41,6 +42,7 @@ import {
41
42
  notify,
42
43
  number,
43
44
  refreshDetail,
45
+ selected,
44
46
  session,
45
47
  sessionHandle,
46
48
  sessionSummary,
@@ -83,6 +85,26 @@ function configure(
83
85
  });
84
86
  }
85
87
 
88
+ /** Transcript download (API existed since v1; the FE button is the checklist's
89
+ * Wave 1a). Markdown straight from the device into a file. */
90
+ async function downloadTranscript() {
91
+ const dev = device.value;
92
+ const summary = sessionSummary.value;
93
+ if (!dev || !summary) return;
94
+ try {
95
+ const r = await deviceClient(dev.id).agent(summary.id, selected.value?.agentId ?? 1).transcript("markdown");
96
+ const blob = new Blob([typeof r === "string" ? r : JSON.stringify(r)], { type: "text/markdown" });
97
+ const a = document.createElement("a");
98
+ a.href = URL.createObjectURL(blob);
99
+ a.download = `${summary.name.replace(/[^\w.-]+/g, "_") || "session"}-${summary.id.slice(0, 8)}.md`;
100
+ a.click();
101
+ URL.revokeObjectURL(a.href);
102
+ notify("Transcript downloaded.");
103
+ } catch (e) {
104
+ notify(`transcript download failed: ${message(e).slice(0, 120)}`);
105
+ }
106
+ }
107
+
86
108
  function renameSession() {
87
109
  const handle = sessionHandle.value;
88
110
  if (!handle) return;
@@ -226,6 +248,13 @@ const offeredTools = computed(() => detail.value?.tools.filter((t) => t.offered)
226
248
  @click="renameSession"
227
249
  >
228
250
  <Pencil :size="18" />
251
+ </button><button
252
+ class="icon-button"
253
+ title="Download transcript (markdown)"
254
+ aria-label="Download transcript"
255
+ @click="downloadTranscript"
256
+ >
257
+ <Download :size="18" />
229
258
  </button>
230
259
  </h1>
231
260
  <button
@@ -310,6 +339,11 @@ const offeredTools = computed(() => detail.value?.tools.filter((t) => t.offered)
310
339
  <td>{{ row.tokens.toLocaleString("en-US") }}</td>
311
340
  <td>${{ row.cost.toFixed(4) }}</td>
312
341
  </tr>
342
+ <tr class="usage-total">
343
+ <th scope="row">Total</th>
344
+ <td>{{ usage.tokens.toLocaleString("en-US") }}</td>
345
+ <td>${{ usage.cost.toFixed(4) }}</td>
346
+ </tr>
313
347
  </tbody>
314
348
  </table>
315
349
  </section>
@@ -427,3 +461,5 @@ const offeredTools = computed(() => detail.value?.tools.filter((t) => t.offered)
427
461
  </template>
428
462
  </aside>
429
463
  </template>
464
+
465
+ <style scoped>.usage-total th, .usage-total td { border-top: 1px solid var(--line); font-weight: 650; }</style>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, ref } from "vue";
3
- import { Link2, Network, PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
3
+ import { Link2, ListChecks, Network, PanelLeftClose, PanelLeftOpen } from "lucide-vue-next";
4
4
  import DeviceIcon from "./DeviceIcon.vue";
5
5
  import type { TreeNode } from "@sdk/models";
6
6
  import TreeRow from "./TreeRow.vue";
@@ -39,6 +39,42 @@ const rows = computed(() => {
39
39
  const focusKey = ref<string>();
40
40
  const list = ref<HTMLElement>();
41
41
 
42
+ /** Bulk session manage (orca ManageSessionsSection): select many, then
43
+ * archive/abort each with a per-row result. Client-side loop over the
44
+ * existing APIs — no new backend surface. */
45
+ const bulkMode = ref(false);
46
+ const bulkSel = ref(new Set<string>());
47
+ const bulkBusy = ref("");
48
+ const bulkToggle = (node: TreeNode) => {
49
+ if (node.type !== "session") return;
50
+ const next = new Set(bulkSel.value);
51
+ if (next.has(node.session.id)) next.delete(node.session.id);
52
+ else next.add(node.session.id);
53
+ bulkSel.value = next;
54
+ };
55
+ const bulkRun = async (op: "archive" | "abort") => {
56
+ bulkBusy.value = op;
57
+ let ok = 0;
58
+ const failed: string[] = [];
59
+ for (const node of rows.value.map((r) => r.node)) {
60
+ if (node.type !== "session" || !bulkSel.value.has(node.session.id)) continue;
61
+ const device = deviceOf(node);
62
+ if (!device) continue;
63
+ const client = deviceClient(device.id);
64
+ try {
65
+ if (op === "archive") await client.session(node.session.id).archive();
66
+ else await client.agent(node.session.id, 1).abort(true);
67
+ ok++;
68
+ if (op === "archive" && selected.value?.sessionId === node.session.id) select(undefined);
69
+ } catch {
70
+ failed.push(node.session.name);
71
+ }
72
+ }
73
+ notify(`${op}: ${ok} done${failed.length ? `, ${failed.length} failed (${failed.slice(0, 3).join(", ")})` : ""}`);
74
+ bulkSel.value = new Set();
75
+ bulkBusy.value = "";
76
+ };
77
+
42
78
  const deviceOf = (node: TreeNode) =>
43
79
  network.devices.value.find(
44
80
  (d) => d.id === (node.type === "device" ? node.device.id : node.deviceId),
@@ -52,6 +88,7 @@ const isSelected = (node: TreeNode) =>
52
88
 
53
89
  function activate(node: TreeNode) {
54
90
  focusKey.value = node.key;
91
+ if (bulkMode.value && node.type === "session") return bulkToggle(node);
55
92
  if (node.type === "device") return toggle(node.key);
56
93
  if (node.type === "agent")
57
94
  return select({ deviceId: node.deviceId, sessionId: node.sessionId, agentId: node.agent.id });
@@ -156,6 +193,15 @@ function keydown(e: KeyboardEvent) {
156
193
  <template v-if="layout.tree || ui.drawer">
157
194
  <header class="tree-head">
158
195
  <span><Network :size="16" />NETWORK</span>
196
+ <button
197
+ class="icon-button"
198
+ :aria-label="bulkMode ? 'Exit bulk select' : 'Bulk manage sessions'"
199
+ :title="bulkMode ? 'Exit bulk select' : 'Bulk manage sessions (select → archive / abort)'"
200
+ :class="{ on: bulkMode }"
201
+ @click="((bulkMode = !bulkMode), (bulkSel = new Set()))"
202
+ >
203
+ <ListChecks :size="15" />
204
+ </button>
159
205
  <button
160
206
  class="icon-button"
161
207
  aria-label="Pair a device"
@@ -182,6 +228,13 @@ function keydown(e: KeyboardEvent) {
182
228
  @keydown="keydown"
183
229
  @focus="focusFirst"
184
230
  >
231
+ <div v-if="bulkMode && bulkSel.size" class="bulk-bar">
232
+ <span>{{ bulkSel.size }} selected</span>
233
+ <button class="small" :disabled="!!bulkBusy" @click="bulkRun('abort')">abort work</button>
234
+ <button class="small danger" :disabled="!!bulkBusy" @click="bulkRun('archive')">
235
+ {{ bulkBusy === "archive" ? "archiving…" : "archive" }}
236
+ </button>
237
+ </div>
185
238
  <TreeRow
186
239
  v-for="row in rows"
187
240
  :key="row.node.key"
@@ -191,9 +244,12 @@ function keydown(e: KeyboardEvent) {
191
244
  :selected="isSelected(row.node)"
192
245
  :focused="focusKey === row.node.key"
193
246
  :online="isOnline(row.node)"
247
+ :checkbox="bulkMode && row.node.type === 'session'"
248
+ :checked="row.node.type === 'session' && bulkSel.has(row.node.session.id)"
194
249
  @toggle="toggle(row.node.key)"
195
250
  @activate="activate(row.node)"
196
251
  @action="action(row.node, $event)"
252
+ @check="bulkToggle(row.node)"
197
253
  />
198
254
  <p v-if="!rows.length" class="hint tree-empty" data-testid="tree-empty">
199
255
  No devices yet. Declare them in <code>dpi-network.yaml</code> (start from
@@ -234,3 +290,11 @@ function keydown(e: KeyboardEvent) {
234
290
  </div>
235
291
  </aside>
236
292
  </template>
293
+
294
+ <style scoped>
295
+ .bulk-bar {
296
+ display: flex; gap: 8px; align-items: center; padding: 6px 10px; margin: 4px 0;
297
+ border: 1px solid var(--line); border-radius: 8px; font-size: 14px;
298
+ }
299
+ .bulk-bar .danger { border-color: var(--warn, #e5a50a); }
300
+ </style>
@@ -42,6 +42,24 @@ const catalogEmpty = computed(
42
42
  const keyless = computed(() => !!(device.value?.info as { providerKeyMissing?: boolean } | undefined)?.providerKeyMissing);
43
43
  const key = (m: { provider: string; id: string }) => `${m.provider}/${m.id}`;
44
44
  const draft = ref({ name: "", cwd: "", model: "" });
45
+ /** orca new-workspace composer: the last N working dirs per device, one click
46
+ * away. localStorage only — the device never learns about recents. */
47
+ const RECENT_KEY = "dpi-recent-cwds";
48
+ type Recents = Record<string, string[]>;
49
+ const recentCwds = computed(() => {
50
+ try {
51
+ return (JSON.parse(localStorage.getItem(RECENT_KEY) ?? "{}") as Recents)[deviceId.value] ?? [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ });
56
+ const rememberCwd = (devId: string, cwd: string) => {
57
+ try {
58
+ const all = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "{}") as Recents;
59
+ const kept = [cwd, ...(all[devId] ?? []).filter((c) => c !== cwd)].slice(0, 6);
60
+ localStorage.setItem(RECENT_KEY, JSON.stringify({ ...all, [devId]: kept }));
61
+ } catch {}
62
+ };
45
63
  const pending = ref(false);
46
64
  const error = ref("");
47
65
 
@@ -69,6 +87,7 @@ async function create() {
69
87
  cwd: draft.value.cwd.trim(),
70
88
  model: { provider: model.provider, modelId: model.id },
71
89
  });
90
+ rememberCwd(device.value.id, created.cwd);
72
91
  const root = created.agents.find((a) => a.kind === "root") ?? created.agents[0];
73
92
  select({ deviceId: device.value.id, sessionId: created.id, agentId: root?.id ?? 1 });
74
93
  notify(`Session created on ${device.value.id}.`);
@@ -119,6 +138,19 @@ async function create() {
119
138
  /></label>
120
139
  <div class="field">
121
140
  <span>Working directory on {{ devices.find((d) => d.id === deviceId)?.name ?? deviceId }}</span>
141
+ <div v-if="recentCwds.length" class="recent-cwds">
142
+ <button
143
+ v-for="c in recentCwds"
144
+ :key="c"
145
+ type="button"
146
+ class="chip"
147
+ :class="{ on: c === draft.cwd }"
148
+ :title="c"
149
+ @click="draft.cwd = c"
150
+ >
151
+ {{ c.split("/").filter(Boolean).at(-1) || c }}
152
+ </button>
153
+ </div>
122
154
  <DirBrowser v-model="draft.cwd" :device-id="deviceId" />
123
155
  </div>
124
156
  <label
@@ -144,3 +176,12 @@ async function create() {
144
176
  </form>
145
177
  </div>
146
178
  </template>
179
+
180
+ <style scoped>
181
+ .recent-cwds { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }
182
+ .recent-cwds .chip {
183
+ font-size: 13px; padding: 3px 10px; border: 1px solid var(--line); border-radius: 12px;
184
+ background: none; cursor: pointer; color: inherit;
185
+ }
186
+ .recent-cwds .chip.on { border-color: var(--accent); font-weight: 550; }
187
+ </style>
@@ -3,21 +3,45 @@
3
3
  // stream state, and the panel/new-session actions. Presentation only; all data comes
4
4
  // from client/store.ts.
5
5
  import { computed, ref } from "vue";
6
- import { CircleHelp, Network, PanelLeft, PanelRight, Plus } from "lucide-vue-next";
6
+ import { CircleHelp, Network, PanelLeft, PanelRight, Plus, Bell } from "lucide-vue-next";
7
7
  import {
8
8
  attempt,
9
+ markNotificationsRead,
9
10
  network,
10
11
  notify,
12
+ notifications,
11
13
  online,
12
14
  select,
13
15
  selected,
14
16
  sessionHandle,
15
17
  streamState,
16
18
  ui,
19
+ usage,
17
20
  showInspector,
18
21
  hideInspector,
19
22
  } from "../client/store";
20
23
 
24
+ const bellOpen = ref(false);
25
+ const unread = computed(() => notifications.filter((n) => !n.read).length);
26
+ const openBell = () => {
27
+ bellOpen.value = !bellOpen.value;
28
+ if (bellOpen.value) markNotificationsRead();
29
+ };
30
+ const when = (at: number) => {
31
+ const sec = Math.round((Date.now() - at) / 1000);
32
+ return sec < 60 ? `${sec}s` : sec < 3600 ? `${Math.floor(sec / 60)}m` : `${Math.floor(sec / 3600)}h`;
33
+ };
34
+ const sessionTokens = computed(
35
+ () => usage.value.input + usage.value.output + usage.value.cacheRead + usage.value.cacheWrite,
36
+ );
37
+ const sessionCost = computed(
38
+ () =>
39
+ (usage.value.inputCost ?? 0) +
40
+ (usage.value.outputCost ?? 0) +
41
+ (usage.value.cacheReadCost ?? 0) +
42
+ (usage.value.cacheWriteCost ?? 0),
43
+ );
44
+
21
45
  const onlineCount = computed(
22
46
  () => network.devices.value.filter((d) => d.status === "online").length,
23
47
  );
@@ -82,7 +106,31 @@ const emit = defineEmits<{ newSession: [] }>();
82
106
  ><span class="dot" :class="{ off: streamState !== 'live' }" aria-hidden="true" />{{
83
107
  streamState === "live" ? "Connected" : "Reconnecting"
84
108
  }}</span
109
+ ><span v-if="sessionTokens" class="usage-chip" title="This session's token usage and cost">{{
110
+ sessionTokens.toLocaleString()
111
+ }}
112
+ tok · ${{ sessionCost.toFixed(3) }}</span
85
113
  ><button
114
+ class="icon-button"
115
+ :aria-label="`Notifications${unread ? ` (${unread} unread)` : ''}`"
116
+ :title="`Notification center${unread ? ` — ${unread} unread` : ''}`"
117
+ @click="openBell"
118
+ >
119
+ <Bell :size="18" /><i v-if="unread" class="bell-dot">{{ unread > 9 ? "9+" : unread }}</i>
120
+ </button>
121
+ <div v-if="bellOpen" class="notif-panel" @click.stop>
122
+ <header>
123
+ <b>Notifications</b>
124
+ <button class="icon-button" aria-label="Close notifications" @click="bellOpen = false">✕</button>
125
+ </header>
126
+ <p v-if="!notifications.length" class="hint">Nothing yet — events land here as they happen.</p>
127
+ <ul>
128
+ <li v-for="n in notifications" :key="n.id" :class="{ unread: !n.read }">
129
+ <span class="when">{{ when(n.at) }}</span><span>{{ n.text }}</span>
130
+ </li>
131
+ </ul>
132
+ </div>
133
+ <button
86
134
  class="icon-button"
87
135
  title="How this works"
88
136
  aria-label="How this works"
@@ -112,3 +160,22 @@ const emit = defineEmits<{ newSession: [] }>();
112
160
  </div>
113
161
  </header>
114
162
  </template>
163
+
164
+ <style scoped>
165
+ .usage-chip { font-size: 12px; opacity: 0.8; margin-right: 6px; white-space: nowrap; }
166
+ .bell-host, .icon-button:has(.bell-dot) { position: relative; }
167
+ .bell-dot {
168
+ position: absolute; top: -4px; right: -4px; font-size: 9px; font-style: normal;
169
+ background: var(--warn, #e5a50a); color: #000; border-radius: 8px; padding: 0 4px;
170
+ }
171
+ .notif-panel {
172
+ position: absolute; top: calc(100% + 6px); right: 8px; z-index: 80; width: 320px; max-height: 340px;
173
+ overflow: auto; background: var(--bg-2, #221a1c); border: 1px solid var(--line); border-radius: 10px;
174
+ padding: 10px 12px; box-shadow: 0 10px 30px rgb(0 0 0 / 50%);
175
+ }
176
+ .notif-panel header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
177
+ .notif-panel ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
178
+ .notif-panel li { display: flex; gap: 8px; font-size: 14px; opacity: 0.85; }
179
+ .notif-panel li.unread { opacity: 1; font-weight: 550; }
180
+ .notif-panel .when { opacity: 0.6; font-size: 12px; min-width: 3ch; text-align: right; }
181
+ </style>
@@ -25,8 +25,12 @@ const props = defineProps<{
25
25
  selected: boolean;
26
26
  focused: boolean;
27
27
  online: boolean;
28
+ /** Bulk-manage mode (orca ManageSessions): when set, session rows show a
29
+ * checkbox instead of the action menu. */
30
+ checkbox?: boolean;
31
+ checked?: boolean;
28
32
  }>();
29
- const emit = defineEmits<{ toggle: []; activate: []; action: [name: string] }>();
33
+ const emit = defineEmits<{ toggle: []; activate: []; action: [name: string]; check: [] }>();
30
34
 
31
35
  const hasChildren = computed(() => props.node.children.length > 0);
32
36
  const device = computed(() => (props.node.type === "device" ? props.node.device : undefined));
@@ -139,6 +143,16 @@ const title = computed(() => {
139
143
  </span>
140
144
  </template>
141
145
  <template v-else-if="session">
146
+ <input
147
+ v-if="checkbox"
148
+ type="checkbox"
149
+ class="bulk-check"
150
+ :checked="checked"
151
+ :aria-label="`Select ${label}`"
152
+ tabindex="-1"
153
+ @click.stop
154
+ @change="emit('check')"
155
+ />
142
156
  <FolderOpen class="ico" :size="16" />
143
157
  <b class="name">{{ label }}</b>
144
158
  <span class="path">…/{{ session.cwd.split("/").filter(Boolean).at(-1) }}</span>
@@ -201,3 +215,5 @@ const title = computed(() => {
201
215
  </template>
202
216
  </div>
203
217
  </template>
218
+
219
+ <style scoped>.bulk-check { margin-left: 2px; flex-shrink: 0; }</style>
@@ -194,6 +194,9 @@ export interface DeviceTree {
194
194
  name: string;
195
195
  sessions: SessionSummary[];
196
196
  at: number;
197
+ /** Repos that changed on-device recently (fs watcher) — the cue for
198
+ * connected UIs to refresh their cached git status. */
199
+ git?: { root: string; at: number }[];
197
200
  }
198
201
 
199
202
  export interface AgentConfig {