my-durable 0.0.22 → 0.0.23

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.
@@ -1,78 +1,72 @@
1
1
  <script setup lang="ts">
2
- // Inspector GIT tab: everything the device knows about the session's folder.
3
- // Always visible — "not a git repository" is a state, not a reason to hide.
4
- import { computed, onBeforeUnmount, onMounted, onServerPrefetch, ref, watch } from "vue";
5
- import { RefreshCw } from "lucide-vue-next";
6
- import { deviceId, sessionSummary, notify } from "../client/store";
2
+ // Inspector GIT tab, IntelliJ-shaped:
3
+ // • branch PICKER — the list is right there, nobody types an existing name
4
+ // • worktrees are first-class rows: create a SESSION on one click
5
+ // • commit: big multi-line box + changed-file checkboxes (commit chosen files)
6
+ // Lazy: rides the shared git-store (one fetch per folder while fresh), the
7
+ // refresh button and every op are the only forced refetches.
8
+ import { computed, onServerPrefetch, ref, watch } from "vue";
9
+ import { Check, ChevronDown, RefreshCw, X } from "lucide-vue-next";
10
+ import { deviceId, network, sessionSummary, notify, select } from "../client/store";
7
11
  import { deviceClient } from "../client/hub";
12
+ import { ensureGit, gitEntry } from "../client/git-store";
8
13
  import type { GitStatus } from "@sdk/client";
9
14
 
10
15
  const props = defineProps<{ deviceId?: string; cwd?: string }>();
11
- const status = ref<GitStatus | null>(null);
12
- const error = ref("");
13
- const loading = ref(false); // first fetch, nothing to show yet — SAY SO
14
- const refreshing = ref(false); // refetch with data on screen — dim, keep, never blank
15
- let inFlight: Promise<void> | undefined;
16
+
17
+ const cwd = computed(() => props.cwd ?? sessionSummary.value?.cwd ?? "");
18
+ const devId = computed(() => props.deviceId ?? deviceId.value);
19
+ const entry = computed(() => gitEntry(devId.value || "?", cwd.value || "?"));
20
+ const status = computed(() => entry.value.status);
21
+ const loading = computed(() => entry.value.loading);
22
+ const refreshing = computed(() => entry.value.refreshing);
23
+ const error = computed(() => entry.value.error);
24
+
16
25
  const busy = ref("");
17
26
  const out = ref("");
18
27
  const commitMsg = ref("");
19
- const wtName = ref("");
28
+ const checked = ref<Set<string>>(new Set());
29
+ const branchMenu = ref(false);
30
+ const wtMenu = ref(false);
31
+ const newBranch = ref("");
20
32
 
21
- const cwd = computed(() => props.cwd ?? sessionSummary.value?.cwd ?? "");
22
- const devId = computed(() => props.deviceId ?? deviceId.value);
23
- const dirty = computed(() => (status.value?.staged ?? 0) + (status.value?.unstaged ?? 0) + (status.value?.untracked ?? 0));
24
- const client = () => (devId.value ? deviceClient(devId.value) : null);
25
-
26
- let timer: ReturnType<typeof setInterval> | undefined;
27
- async function refresh() {
28
- const c = client();
29
- if (!c || !cwd.value) return;
30
- if (inFlight) return inFlight; // interval + session-switch must not pile up
31
- const first = !status.value;
32
- if (first) loading.value = true;
33
- else refreshing.value = true;
34
- error.value = ""; // a new attempt must not stack on the old verdict
35
- inFlight = (async () => {
36
- try {
37
- // Big checkouts honestly take 20s+ (measured on the operator's repo):
38
- // wait for the truth instead of declaring the device unreachable.
39
- status.value = await c.gitStatus(cwd.value, { timeoutMs: 60_000 });
40
- error.value = "";
41
- } catch (e) {
42
- const raw = (e as Error).message ?? "";
43
- // Keep the LAST GOOD status — a dropped tunnel must not blank the tab
44
- // the operator is reading (reported: "treo rất lâu", "Cannot reach …").
45
- error.value = /fetch failed|Cannot reach|Failed to fetch|NetworkError|ERR_NAME/i.test(raw)
46
- ? `device unreachable — check that it is online, then retry (${raw.slice(0, 80)})`
47
- : raw.slice(0, 160);
48
- } finally {
49
- loading.value = false;
50
- refreshing.value = false;
51
- inFlight = undefined;
52
- }
53
- })();
54
- return inFlight;
55
- }
56
- // NO top-level await: an async setup outside <Suspense> never mounts in the
57
- // browser ("async setup invoked without a Suspense boundary") — the tab
58
- // rendered EMPTY (reported: "TAB git trống"). Fire now on the client;
59
- // onServerPrefetch is what SSR actually waits for.
60
- if (!import.meta.env.SSR) void refresh();
61
- onServerPrefetch(() => refresh());
62
- onMounted(() => {
63
- timer = setInterval(refresh, 30000);
33
+ if (!import.meta.env.SSR) void ensureGit(devId.value || "?", cwd.value || "?");
34
+ onServerPrefetch(() => ensureGit(devId.value || "?", cwd.value || "?"));
35
+ watch([devId, cwd], () => {
36
+ branchMenu.value = false;
37
+ wtMenu.value = false;
38
+ void ensureGit(devId.value || "?", cwd.value || "?");
39
+ });
40
+ const refresh = () => {
41
+ checked.value = new Set();
42
+ return ensureGit(devId.value || "?", cwd.value || "?", { force: true });
43
+ };
44
+
45
+ // changed files: staged+modified default-checked, untracked not — IntelliJ's
46
+ // changelist behavior.
47
+ const files = computed(() => status.value?.files ?? []);
48
+ watch(files, (list) => {
49
+ const next = new Set<string>();
50
+ for (const f of list) if (f.x !== "?" && f.y !== "?") next.add(f.path);
51
+ checked.value = next;
64
52
  });
65
- onBeforeUnmount(() => timer && clearInterval(timer));
66
- // The operator switching sessions changes cwd — refetch for the new folder.
67
- watch(cwd, () => void refresh());
53
+ const someChecked = computed(() => files.value.some((f) => checked.value.has(f.path)));
54
+ const toggle = (p: string) => {
55
+ const next = new Set(checked.value);
56
+ if (next.has(p)) next.delete(p);
57
+ else next.add(p);
58
+ checked.value = next;
59
+ };
60
+ const toggleAll = () => {
61
+ checked.value = someChecked.value ? new Set() : new Set(files.value.map((f) => f.path));
62
+ };
68
63
 
69
64
  async function op(name: "fetch" | "pull" | "log" | "branch" | "diff") {
70
- const c = client();
71
- if (!c || !cwd.value) return;
65
+ if (!devId.value || !cwd.value) return;
72
66
  busy.value = name;
73
67
  out.value = "";
74
68
  try {
75
- const r = await c.gitOp(cwd.value, name);
69
+ const r = await deviceClient(devId.value).gitOp(cwd.value, name);
76
70
  out.value = r.text || "(empty)";
77
71
  if (name === "fetch" || name === "pull") notify(`git ${name} ✓`);
78
72
  await refresh();
@@ -83,55 +77,100 @@ async function op(name: "fetch" | "pull" | "log" | "branch" | "diff") {
83
77
  }
84
78
  }
85
79
 
80
+ async function switchBranch(name: string) {
81
+ branchMenu.value = false;
82
+ if (!devId.value || !cwd.value || name === status.value?.branch) return;
83
+ busy.value = "switch";
84
+ try {
85
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "switch", { ref: name });
86
+ notify(r.text || `switched to ${name}`);
87
+ await refresh();
88
+ } catch (e) {
89
+ notify(`switch failed: ${(e as Error).message.slice(0, 140)}`);
90
+ } finally {
91
+ busy.value = "";
92
+ }
93
+ }
94
+
86
95
  async function commit() {
87
- const c = client();
88
- if (!c || !cwd.value || !commitMsg.value.trim()) return;
96
+ if (!devId.value || !cwd.value || !commitMsg.value.trim()) return;
89
97
  busy.value = "commit";
98
+ const chosen = files.value.filter((f) => checked.value.has(f.path)).map((f) => f.path);
90
99
  try {
91
- const r = await c.gitOp(cwd.value, "commit", { message: commitMsg.value.trim() });
100
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "commit", {
101
+ message: commitMsg.value.trim(),
102
+ ...(chosen.length ? { files: chosen } : {}),
103
+ });
92
104
  notify(r.text);
93
105
  commitMsg.value = "";
94
106
  await refresh();
95
107
  } catch (e) {
96
- notify(`commit failed: ${(e as Error).message.slice(0, 120)}`);
108
+ notify(`commit failed: ${(e as Error).message.slice(0, 140)}`);
97
109
  } finally {
98
110
  busy.value = "";
99
111
  }
100
112
  }
101
113
 
102
- async function addWorktree() {
103
- const c = client();
104
- if (!c || !cwd.value || !wtName.value.trim() || !status.value?.root) return;
114
+ async function addWorktree(branch: string) {
115
+ wtMenu.value = false;
116
+ if (!devId.value || !cwd.value || !status.value?.root) return;
105
117
  const root = status.value.root;
106
118
  const parent = root.slice(0, root.lastIndexOf("/"));
107
119
  const base = root.slice(root.lastIndexOf("/") + 1);
120
+ const target = `${parent}/${base}-${branch.replace(/[/\\]+/g, "-")}`;
108
121
  busy.value = "wt";
109
122
  try {
110
- const r = await c.gitOp(cwd.value, "worktree-add", { branch: wtName.value.trim(), target: `${parent}/${base}-${wtName.value.trim()}` });
111
- notify(r.text.split("\n")[0] ?? "worktree added");
112
- wtName.value = "";
123
+ const r = await deviceClient(devId.value).gitOp(cwd.value, "worktree-add", {
124
+ branch: branch, // existing branch checks out; new name creates it
125
+ target,
126
+ });
127
+ notify(r.text.split("\n")[0] ?? `worktree ${target}`);
128
+ newBranch.value = "";
113
129
  await refresh();
114
130
  } catch (e) {
115
- notify(`worktree failed: ${(e as Error).message.slice(0, 120)}`);
131
+ notify(`worktree failed: ${(e as Error).message.slice(0, 140)}`);
132
+ } finally {
133
+ busy.value = "";
134
+ }
135
+ }
136
+
137
+ /** A worktree is a working copy — one click and it is a SESSION (herd flow). */
138
+ async function sessionOn(path: string, branch: string | null) {
139
+ if (!devId.value) return;
140
+ busy.value = "session";
141
+ try {
142
+ const info = network.devices.value.find((d) => d.id === devId.value)?.info;
143
+ const model =
144
+ (info?.models ?? []).find((m) => m.id === info?.defaultModel) ?? (info?.models ?? [])[0];
145
+ if (!model) return notify(`no model available on ${devId.value} — cannot create a session`);
146
+ const s = await deviceClient(devId.value).createSession({
147
+ name: branch ?? path.slice(path.lastIndexOf("/") + 1),
148
+ cwd: path,
149
+ model: { provider: model.provider, modelId: model.id },
150
+ });
151
+ select({ deviceId: devId.value, sessionId: s.id, agentId: 1 });
152
+ notify(`session "${s.name}" on ${path}`);
153
+ } catch (e) {
154
+ notify(`session failed: ${(e as Error).message.slice(0, 160)}`);
116
155
  } finally {
117
156
  busy.value = "";
118
157
  }
119
158
  }
120
159
 
121
160
  async function removeWorktree(path: string) {
122
- const c = client();
123
- if (!c || !cwd.value) return;
161
+ if (!devId.value || !cwd.value) return;
124
162
  if (!confirm(`Remove worktree ${path}?`)) return;
125
163
  busy.value = "wt";
126
164
  try {
127
- await c.gitOp(cwd.value, "worktree-remove", { target: path });
165
+ await deviceClient(devId.value).gitOp(cwd.value, "worktree-remove", { target: path });
128
166
  await refresh();
129
167
  } catch (e) {
130
- notify(`remove failed: ${(e as Error).message.slice(0, 120)}`);
168
+ notify(`remove failed: ${(e as Error).message.slice(0, 140)}`);
131
169
  } finally {
132
170
  busy.value = "";
133
171
  }
134
172
  }
173
+ const worktrees = computed(() => (status.value?.worktrees ?? []).filter((w) => w.path));
135
174
  </script>
136
175
 
137
176
  <template>
@@ -141,82 +180,131 @@ async function removeWorktree(path: string) {
141
180
  <header class="git-head">
142
181
  <strong>git</strong>
143
182
  <code class="cwd" :title="cwd">{{ cwd }}</code>
144
- <button :disabled="!!busy" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
183
+ <button :disabled="!!busy || loading || refreshing" title="refresh" @click="refresh()"><RefreshCw :size="14" /></button>
145
184
  </header>
146
185
 
147
186
  <p v-if="loading" class="empty">loading git…</p>
148
- <p v-if="refreshing" class="muted note">refreshing…</p>
149
-
150
- <p v-if="error" class="error">
151
- {{ error }}
152
- <button :disabled="!!busy || !!loading" @click="refresh()">retry</button>
153
- </p>
154
-
155
- <div v-if="status" :class="{ stale: !!error || refreshing }">
156
- <p v-if="status && !status.repo" class="empty">
157
- Not a git repository — run <code>git init</code> (or open a session inside a repo) to see branch, status and worktrees here.
158
- </p>
159
-
160
- <template v-else-if="status">
161
- <dl class="facts">
162
- <div><dt>branch</dt><dd>{{ status.branch ?? "detached" }}</dd></div>
163
- <div v-if="status.upstream"><dt>upstream</dt><dd>{{ status.upstream }}</dd></div>
164
- <div><dt>changes</dt><dd>+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }}</dd></div>
165
- <div v-if="status.ahead || status.behind"><dt>sync</dt><dd>↑{{ status.ahead ?? 0 }} ↓{{ status.behind ?? 0 }}</dd></div>
166
- <div><dt>head</dt><dd>{{ status.head }} {{ status.subject }}</dd></div>
167
- <div v-if="status.worktreeOf" class="wt-of"><dt>worktree of</dt><dd>{{ status.worktreeOf }}</dd></div>
168
- </dl>
169
-
170
- <div class="row">
171
- <button :disabled="!!busy" @click="op('fetch')">fetch</button>
172
- <button :disabled="!!busy" @click="op('pull')">pull</button>
173
- <button :disabled="!!busy" @click="op('log')">log</button>
174
- <button :disabled="!!busy" @click="op('branch')">branches</button>
175
- <button :disabled="!!busy" @click="op('diff')">diff</button>
176
- </div>
177
-
178
- <div class="row">
179
- <input v-model="commitMsg" placeholder="commit message…" @keyup.enter="commit" />
180
- <button :disabled="!!busy || !commitMsg.trim()" @click="commit">commit -am</button>
181
- </div>
182
-
183
- <div class="row">
184
- <input v-model="wtName" placeholder="branch name…" @keyup.enter="addWorktree" />
185
- <button :disabled="!!busy || !wtName.trim()" @click="addWorktree">+ worktree</button>
186
- </div>
187
- <ul v-if="(status.worktrees?.length ?? 0) > 1" class="wts">
188
- <li v-for="w in status.worktrees" :key="w.path">
189
- <code>{{ w.path }}</code>
190
- <span class="muted">{{ w.branch ?? w.head }}</span>
191
- <button v-if="w.path !== status.root" :disabled="!!busy" @click="removeWorktree(w.path)">remove</button>
192
- </li>
193
- </ul>
194
-
195
- <pre v-if="out" class="out">{{ out.slice(0, 6000) }}</pre>
196
- </template>
187
+ <p v-else-if="refreshing" class="muted note">refreshing…</p>
188
+ <p v-if="error" class="error">{{ error }} <button :disabled="!!busy" @click="refresh()">retry</button></p>
189
+
190
+ <div v-if="status" :class="{ stale: !!error || !!refreshing }">
191
+ <p v-if="!status.repo" class="empty">Not a git repository — run <code>git init</code> (or open a session inside a repo).</p>
192
+
193
+ <template v-else>
194
+ <!-- branch bar: PICK, never type -->
195
+ <div class="branch-bar">
196
+ <button class="branch-btn" :disabled="!!busy" @click="branchMenu = !branchMenu">
197
+ <strong>{{ status.branch ?? "detached" }}</strong>
198
+ <span v-if="status.upstream" class="muted">→ {{ status.upstream }}</span>
199
+ <span v-if="status.ahead || status.behind" class="muted">↑{{ status.ahead ?? 0 }} ↓{{ status.behind ?? 0 }}</span>
200
+ <ChevronDown :size="14" />
201
+ </button>
202
+ <span class="muted">+{{ status.staged ?? 0 }} ~{{ status.unstaged ?? 0 }} ?{{ status.untracked ?? 0 }} · {{ status.head }}</span>
203
+ <div v-if="branchMenu" class="menu">
204
+ <button v-for="b in status.branches ?? []" :key="b.name" :class="{ cur: b.current }" @click="switchBranch(b.name)">
205
+ <span>{{ b.name }}</span>
206
+ <span class="muted">{{ b.current ? "● current" : b.upstream ? `→ ${b.upstream}` : "" }}</span>
207
+ </button>
208
+ <p v-if="!(status.branches ?? []).length" class="muted pad">no local branches</p>
209
+ </div>
210
+ </div>
211
+
212
+ <div class="row ops">
213
+ <button :disabled="!!busy" @click="op('fetch')">fetch</button>
214
+ <button :disabled="!!busy" @click="op('pull')">pull</button>
215
+ <button :disabled="!!busy" @click="op('log')">log</button>
216
+ <button :disabled="!!busy" @click="op('branch')">branches</button>
217
+ <button :disabled="!!busy" @click="op('diff')">diff</button>
218
+ </div>
219
+
220
+ <!-- commit: IntelliJ — big box + file checkboxes -->
221
+ <section v-if="files.length" class="commit">
222
+ <div class="files">
223
+ <label class="file all">
224
+ <input type="checkbox" :checked="someChecked" @change="toggleAll" />
225
+ <span class="muted">{{ checked.size }}/{{ files.length }} changed files</span>
226
+ </label>
227
+ <label v-for="f in files" :key="f.path" class="file">
228
+ <input type="checkbox" :checked="checked.has(f.path)" @change="toggle(f.path)" />
229
+ <code class="xy">{{ f.x }}{{ f.y }}</code>
230
+ <span class="path" :title="f.path">{{ f.path }}</span>
231
+ </label>
232
+ </div>
233
+ <textarea v-model="commitMsg" rows="4" placeholder="Commit message…" />
234
+ <button class="primary" :disabled="!!busy || !commitMsg.trim()" @click="commit">
235
+ Commit{{ checked.size ? ` ${checked.size} file${checked.size > 1 ? "s" : ""}` : " -am" }}
236
+ </button>
237
+ </section>
238
+
239
+ <!-- worktrees: local + linked, one click to a session -->
240
+ <section class="wts">
241
+ <div class="wt-head">
242
+ <strong>Worktrees</strong>
243
+ <button :disabled="!!busy" @click="wtMenu = !wtMenu">+ worktree</button>
244
+ <div v-if="wtMenu" class="menu">
245
+ <button v-for="b in (status.branches ?? []).filter((x) => !x.current)" :key="b.name" @click="addWorktree(b.name)">
246
+ <span>{{ b.name }}</span>
247
+ <span class="muted">existing branch</span>
248
+ </button>
249
+ <div class="new-branch">
250
+ <input v-model="newBranch" placeholder="or a NEW branch name…" @keyup.enter="addWorktree(newBranch.trim() || 'wt')" />
251
+ <button :disabled="!newBranch.trim()" @click="addWorktree(newBranch.trim())">create</button>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ <div v-for="w in worktrees" :key="w.path" class="wt" :class="{ main: w.path === status.root }">
256
+ <code :title="w.path">{{ w.path }}</code>
257
+ <span class="muted">{{ w.branch ?? w.head }}</span>
258
+ <span class="grow" />
259
+ <button :disabled="!!busy" title="create a session on this worktree" @click="sessionOn(w.path, w.branch)">session</button>
260
+ <button v-if="w.path !== status.root" :disabled="!!busy" @click="removeWorktree(w.path)"><X :size="12" /></button>
261
+ <Check v-if="w.path === status.root" :size="12" class="muted" />
262
+ </div>
263
+ </section>
264
+
265
+ <pre v-if="out" class="out">{{ out.slice(0, 6000) }}</pre>
266
+ </template>
197
267
  </div>
198
268
  </template>
199
269
  </div>
200
270
  </template>
201
271
 
202
272
  <style scoped>
203
- .git-tab { display: grid; gap: 8px; font-size: 12px; padding: 8px 4px; }
273
+ .git-tab { display: grid; gap: 10px; font-size: 12px; padding: 8px 4px; }
204
274
  .git-head { display: flex; align-items: center; gap: 8px; }
205
275
  .git-head .cwd { font-size: 11px; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
206
- .facts { display: grid; gap: 2px; margin: 0; }
207
- .facts > div { display: flex; gap: 8px; }
208
- .facts dt { min-width: 72px; opacity: 0.6; }
209
- .facts dd { margin: 0; word-break: break-all; }
276
+ .branch-bar { position: relative; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
277
+ .branch-btn { display: inline-flex; gap: 6px; align-items: center; padding: 4px 10px; font-size: 12px; }
278
+ .menu {
279
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; min-width: 260px; max-height: 260px; overflow: auto;
280
+ background: var(--bg-2, #1c1c22); border: 1px solid var(--border, #333); border-radius: 8px; padding: 4px;
281
+ box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
282
+ }
283
+ .menu button { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 5px 8px; border: 0; background: none; color: inherit; text-align: left; cursor: pointer; }
284
+ .menu button:hover, .menu button.cur { background: rgb(127 127 127 / 15%); }
285
+ .menu .pad { padding: 6px 8px; margin: 0; }
210
286
  .row { display: flex; gap: 6px; }
211
- .row input { flex: 1; min-width: 0; }
212
- .wts { list-style: none; padding: 0; margin: 0; display: grid; gap: 2px; }
213
- .wts li { display: flex; gap: 8px; align-items: center; }
214
- .wts code { font-size: 11px; }
287
+ .ops { flex-wrap: wrap; }
288
+ .commit { display: grid; gap: 6px; }
289
+ .files { max-height: 180px; overflow: auto; border: 1px solid var(--border, #2a2a30); border-radius: 6px; padding: 2px 6px; }
290
+ .file { display: flex; gap: 6px; align-items: center; padding: 2px 0; }
291
+ .file.all { border-bottom: 1px solid var(--border, #2a2a30); }
292
+ .file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
293
+ .xy { opacity: 0.7; font-size: 10px; }
294
+ textarea { width: 100%; resize: vertical; min-height: 64px; font: inherit; }
295
+ button.primary { justify-self: start; }
296
+ .wts { display: grid; gap: 4px; }
297
+ .wt-head { position: relative; display: flex; align-items: center; gap: 8px; }
298
+ .wt { display: flex; gap: 8px; align-items: center; }
299
+ .wt code { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 46%; }
300
+ .wt.main code { font-weight: 600; }
301
+ .grow { flex: 1; }
302
+ .new-branch { display: flex; gap: 6px; padding: 6px 8px; border-top: 1px solid var(--border, #2a2a30); }
303
+ .new-branch input { flex: 1; min-width: 0; }
215
304
  .muted { opacity: 0.65; }
216
- .wt-of dd { color: var(--info, #58a6ff); }
217
- .out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; font-size: 11px; }
218
- .empty { opacity: 0.75; }
219
305
  .note { font-size: 11px; }
220
306
  .stale { opacity: 0.55; transition: opacity 0.2s; }
307
+ .out { max-height: 300px; overflow: auto; background: rgb(0 0 0 / 25%); padding: 8px; border-radius: 6px; font-size: 11px; }
308
+ .empty { opacity: 0.75; }
221
309
  .error { color: var(--danger, #f85149); }
222
310
  </style>
@@ -16,6 +16,7 @@ import {
16
16
  import DeviceIcon from "./DeviceIcon.vue";
17
17
  import type { TreeNode } from "@sdk/models";
18
18
  import { ago } from "../client/store";
19
+ import { branchOf } from "../client/session-git";
19
20
 
20
21
  const props = defineProps<{
21
22
  node: TreeNode;
@@ -60,6 +61,10 @@ const dot = computed(() => {
60
61
  const s = device.value?.status;
61
62
  return s === "online" ? "on" : s === "checking" ? "chk" : s === "offline" ? "off" : "err";
62
63
  });
64
+ const branch = computed(() => {
65
+ const n = props.node;
66
+ return n.type === "session" ? branchOf(n.deviceId, n.session.cwd) : undefined;
67
+ });
63
68
  const label = computed(
64
69
  () => device.value?.name ?? device.value?.id ?? session.value?.name ?? agent.value?.name ?? "",
65
70
  );
@@ -137,6 +142,7 @@ const title = computed(() => {
137
142
  <FolderOpen class="ico" :size="16" />
138
143
  <b class="name">{{ label }}</b>
139
144
  <span class="path">…/{{ session.cwd.split("/").filter(Boolean).at(-1) }}</span>
145
+ <span v-if="branch" class="branch" :title="`git branch of ${session.cwd}`">⎇ {{ branch }}</span>
140
146
  <i v-if="busy" class="dot work" title="Working" />
141
147
  <span v-if="background" class="bg-count" :title="`${background} background task(s)`"
142
148
  ><i class="dot bg" />{{ background }} bg</span
@@ -53,6 +53,8 @@ export interface GitStatus {
53
53
  subject?: string;
54
54
  worktreeOf?: string;
55
55
  worktrees?: { path: string; branch: string | null; head: string }[];
56
+ files?: { path: string; x: string; y: string }[];
57
+ branches?: { name: string; current?: boolean; upstream?: string }[];
56
58
  }
57
59
  export type GitOpName =
58
60
  | "diff"
@@ -175,9 +177,17 @@ export class DpiClient {
175
177
  return this.request<GitStatus>("GET", `/v1/git/status?path=${enc(path)}`, undefined, options);
176
178
  }
177
179
  /** One whitelisted git operation; see the device's git.ts for the op list. */
178
- gitOp(path: string, op: GitOpName, opts: { ref?: string; branch?: string; message?: string; target?: string } = {}) {
180
+ gitOp(
181
+ path: string,
182
+ op: GitOpName,
183
+ opts: { ref?: string; branch?: string; message?: string; target?: string; files?: string[] } = {},
184
+ ) {
179
185
  return this.request<{ text: string }>("POST", "/v1/git", { path, op, ...opts });
180
186
  }
187
+ /** Many paths, one round-trip — session lists want branch-per-cwd. */
188
+ gitStatusBatch(paths: string[], options: { timeoutMs?: number } = {}) {
189
+ return this.request<Record<string, GitStatus>>("POST", "/v1/git/status-batch", { paths }, options);
190
+ }
181
191
 
182
192
  // ------------------------------------------------------------ sessions
183
193
  sessions() {
@@ -15,6 +15,7 @@ const ROUTES: readonly (readonly [method: string, pattern: string])[] = [
15
15
  ["GET", "/fs/dirs"],
16
16
  ["GET", "/git/status"],
17
17
  ["POST", "/git"],
18
+ ["POST", "/git/status-batch"],
18
19
  ["GET", "/docs/:kind"],
19
20
 
20
21
  ["GET", "/sessions"],
@@ -172,6 +172,27 @@ export class App {
172
172
  private gitOf(cwd: string | undefined): GitStatus | undefined {
173
173
  return cwd ? this.gitCache.get(cwd)?.s : undefined;
174
174
  }
175
+
176
+ /** ⎇ per herd ROW: one batched status for all distinct cwds, 60s TTL. */
177
+ private branchCache = new Map<string, { at: number; branch?: string }>();
178
+ private refreshBranches() {
179
+ const cwds = [...new Set(this.sessions.map((s) => s.cwd))];
180
+ const stale = cwds.filter((c) => {
181
+ const hit = this.branchCache.get(c);
182
+ return !hit || Date.now() - hit.at > 60_000;
183
+ });
184
+ if (!stale.length) return;
185
+ for (const c of stale) this.branchCache.set(c, { at: Date.now() });
186
+ void this.client
187
+ .gitStatusBatch(stale)
188
+ .then((map) => {
189
+ for (const [c, s] of Object.entries(map))
190
+ this.branchCache.set(c, { at: Date.now(), branch: s.repo ? (s.branch ?? s.head) : undefined });
191
+ this.requestRender();
192
+ })
193
+ .catch(() => {});
194
+ }
195
+ private branchOf = (cwd: string): string | undefined => this.branchCache.get(cwd)?.branch;
175
196
  private devicesCache: DeviceRow[] = [];
176
197
  private deviceOnline = false;
177
198
  private events: { at: string; glyph: string; text: string }[] = [];
@@ -345,6 +366,7 @@ export class App {
345
366
  this.cursor = 0;
346
367
  this.scrollOffset = 0;
347
368
  if (view === "herd") void this.refreshSessions();
369
+ if (view === "herd") this.refreshBranches();
348
370
  if (view === "network") this.devicesCache = this.networkDevices();
349
371
  this.requestRender();
350
372
  }
@@ -391,7 +413,7 @@ export class App {
391
413
  deviceId?: string;
392
414
  label: string;
393
415
  }> {
394
- if (this.view === "herd") return herdRows(this.sessions, this.filter);
416
+ if (this.view === "herd") return herdRows(this.sessions, this.filter, this.branchOf);
395
417
  if (this.view === "network") return networkRows(this.devicesCache, this.device.id, this.filter);
396
418
  return [];
397
419
  }
@@ -981,10 +1003,11 @@ export class App {
981
1003
  // ------------------------------------------------------------------ herd
982
1004
 
983
1005
  private herdBody(w: number, h: number): string[] {
984
- const rows = herdRows(this.sessions, this.filter);
1006
+ const rows = herdRows(this.sessions, this.filter, this.branchOf);
985
1007
  const wide = w >= 104;
986
1008
  const panelW = wide ? Math.min(44, Math.max(34, Math.round(w * 0.32))) : 0;
987
1009
  const tableW = wide ? w - panelW - 1 : w;
1010
+ this.refreshBranches();
988
1011
  const table = renderHerd(tableW, rows, this.sessions, {
989
1012
  selectedId: this.selectedId ?? this.sessionId,
990
1013
  cursor: this.cursor,
@@ -65,10 +65,12 @@ export interface HerdRow {
65
65
  kind: "group" | "session";
66
66
  label: string;
67
67
  sessionId?: string;
68
+ /** ⎇ branch of the session's cwd, when the batch fetch knows it. */
69
+ branch?: string;
68
70
  }
69
71
 
70
72
  /** Group sessions by working directory (herdr groups by repo) and apply the / filter. */
71
- export function herdRows(sessions: SessionSummary[], filter = ""): HerdRow[] {
73
+ export function herdRows(sessions: SessionSummary[], filter = "", branchOf?: (cwd: string) => string | undefined): HerdRow[] {
72
74
  const needle = filter.trim().toLowerCase();
73
75
  const kept = needle
74
76
  ? sessions.filter((s) =>
@@ -87,7 +89,8 @@ export function herdRows(sessions: SessionSummary[], filter = ""): HerdRow[] {
87
89
  }
88
90
  for (const [dir, list] of groups) {
89
91
  out.push({ kind: "group", label: dir });
90
- for (const s of list) out.push({ kind: "session", label: s.name, sessionId: s.id });
92
+ for (const s of list)
93
+ out.push({ kind: "session", label: s.name, sessionId: s.id, branch: branchOf?.(s.cwd) });
91
94
  }
92
95
  return out;
93
96
  }
@@ -136,8 +139,9 @@ export function renderHerd(
136
139
  const forks = s.agents.filter((a) => a.kind === "fork").length;
137
140
  const subs = s.agents.filter((a) => a.kind === "subagent").length;
138
141
  const agents = `${s.agents.length}${forks ? `⑂${forks}` : ""}${subs ? `↳${subs}` : ""}`;
142
+ const branch = row.branch ? ` ${c.muted(`⎇ ${fit(row.branch, 10)}`)}` : "";
139
143
  let line =
140
- `${marker}${fit(selected ? c.bold(s.name) : s.name, cols.name)} ${fit(state, cols.state)}` +
144
+ `${marker}${fit(selected ? c.bold(s.name) : s.name, cols.name - visibleWidth(branch))}${branch} ${fit(state, cols.state)}` +
141
145
  (showModel ? ` ${fit(c.muted(basename(s.model) || s.model), cols.model)}` : "") +
142
146
  ` ${fit(c.muted(agents), cols.agents)}`;
143
147
  if (showAge) line += ` ${fit(c.muted(ageOf(s.createdAt)), cols.age)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "description": "Durable Pi \u2014 the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
5
5
  "license": "MIT",
6
6
  "repository": {