tuiboard 0.13.0 → 0.13.2
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.
- package/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/src/app.tsx +5 -0
- package/src/store/agent-adapters/claude-code.ts +17 -3
- package/src/store/agents.ts +44 -4
- package/src/store/herdr.ts +16 -3
- package/src/ui/AgentRow.tsx +2 -1
- package/src/ui/AgentsBar.tsx +10 -7
- package/src/ui/agent-cursor.ts +49 -0
- package/src/ui/clock.ts +21 -0
- package/src/views/AgentsOnly.tsx +20 -14
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.2] - 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **A calmer Agents list while agents work** (#58).
|
|
14
|
+
- A failed herdr poll no longer drops every link at once (states briefly
|
|
15
|
+
reverting, rows jumping); herdr only counts as gone after a few misses.
|
|
16
|
+
- A Claude Code PID file read mid-write reuses its last good read instead of
|
|
17
|
+
making the session's state flicker.
|
|
18
|
+
- Live sessions are ordered by their activity at their last state change,
|
|
19
|
+
so a working agent moves once when it starts or finishes, not on every
|
|
20
|
+
write; closed sessions still sort by last activity.
|
|
21
|
+
- Ages tick on one shared clock and read `now` under a minute instead of
|
|
22
|
+
jumping through seconds.
|
|
23
|
+
|
|
24
|
+
## [0.13.1] - 2026-09-17
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **Agents list stays in order when sessions move** (#52). A new session
|
|
28
|
+
appearing on top could show twice, leave blank rows, or draw rows in the
|
|
29
|
+
wrong order past the panel border; the lists now keep one stable row per
|
|
30
|
+
position. The cursor also stays on the session you selected while the list
|
|
31
|
+
reorders, instead of silently landing on another one (so Enter, `H` and `c`
|
|
32
|
+
act on the session you picked).
|
|
33
|
+
- **`--view=agents` / `--view=timeline` / `--view=board` start on their own
|
|
34
|
+
zone** (#54), so the cursor and keys work right away instead of acting on
|
|
35
|
+
the hidden planner.
|
|
36
|
+
|
|
10
37
|
## [0.13.0] - 2026-09-17
|
|
11
38
|
|
|
12
39
|
tuiboard and [herdr](https://herdr.dev) now work as one: the Agents zone shows
|
|
@@ -474,6 +501,8 @@ First public release on npm. This entry captures the full feature set at launch.
|
|
|
474
501
|
|
|
475
502
|
Built with [OpenTUI](https://opentui.com) + SolidJS on Bun.
|
|
476
503
|
|
|
504
|
+
[0.13.2]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.2
|
|
505
|
+
[0.13.1]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.1
|
|
477
506
|
[0.13.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.13.0
|
|
478
507
|
[0.12.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.12.0
|
|
479
508
|
[0.11.0]: https://github.com/NazzarenoGiannelli/tuiboard/releases/tag/v0.11.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tuiboard",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"description": "Terminal kanban for markdown task boards, with optional Today/Tomorrow planner, 24h agenda + calendar overlay, and a live view of your coding-agent sessions (Claude Code, Codex, OpenCode, Pi). Use only the panels you want.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/app.tsx
CHANGED
|
@@ -148,6 +148,11 @@ if (view === "planner") {
|
|
|
148
148
|
store.setActiveZone("planner");
|
|
149
149
|
store.setZoomed(true);
|
|
150
150
|
}
|
|
151
|
+
// The single-zone views show one zone; keys must act on it. Without this the
|
|
152
|
+
// planner (the default landing zone above) stayed active behind them.
|
|
153
|
+
if (view === "agents" || view === "timeline" || view === "board") {
|
|
154
|
+
store.setActiveZone(view);
|
|
155
|
+
}
|
|
151
156
|
|
|
152
157
|
// ─── App shell ──────────────────────────────────────────────────────────────
|
|
153
158
|
|
|
@@ -207,6 +207,13 @@ export function parseTranscript(content: string): TranscriptParseResult {
|
|
|
207
207
|
|
|
208
208
|
// ─── Discovery ──────────────────────────────────────────────────────────────
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Last successfully parsed record per PID file. Claude Code rewrites these
|
|
212
|
+
* files in place, so a read can land mid-write; reusing the previous record
|
|
213
|
+
* keeps the session's live state from flickering for one scan.
|
|
214
|
+
*/
|
|
215
|
+
const lastGoodPid = new Map<string, { sessionId: string; record: LivePidRecord }>();
|
|
216
|
+
|
|
210
217
|
function discoverLivePids(): Map<string, LivePidRecord> {
|
|
211
218
|
const out = new Map<string, LivePidRecord>();
|
|
212
219
|
if (!existsSync(SESSIONS_DIR)) return out;
|
|
@@ -216,25 +223,32 @@ function discoverLivePids(): Map<string, LivePidRecord> {
|
|
|
216
223
|
} catch {
|
|
217
224
|
return out;
|
|
218
225
|
}
|
|
226
|
+
const seen = new Set<string>();
|
|
219
227
|
for (const f of entries) {
|
|
220
228
|
if (!f.endsWith(".json")) continue;
|
|
221
229
|
const path = join(SESSIONS_DIR, f);
|
|
230
|
+
seen.add(path);
|
|
222
231
|
try {
|
|
223
232
|
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
224
233
|
const sid = raw.sessionId;
|
|
225
234
|
if (!sid) continue;
|
|
226
235
|
const stat = statSync(path);
|
|
227
|
-
|
|
236
|
+
const record: LivePidRecord = {
|
|
228
237
|
mtimeMs: stat.mtimeMs,
|
|
229
238
|
status: raw.status?.toLowerCase(),
|
|
230
239
|
pid: raw.pid,
|
|
231
240
|
version: raw.version,
|
|
232
241
|
cwd: raw.cwd,
|
|
233
|
-
}
|
|
242
|
+
};
|
|
243
|
+
lastGoodPid.set(path, { sessionId: sid, record });
|
|
244
|
+
out.set(sid, record);
|
|
234
245
|
} catch {
|
|
235
|
-
//
|
|
246
|
+
// Mid-write (or vanished): fall back to the last good read of this file.
|
|
247
|
+
const prev = lastGoodPid.get(path);
|
|
248
|
+
if (prev) out.set(prev.sessionId, prev.record);
|
|
236
249
|
}
|
|
237
250
|
}
|
|
251
|
+
for (const path of lastGoodPid.keys()) if (!seen.has(path)) lastGoodPid.delete(path);
|
|
238
252
|
return out;
|
|
239
253
|
}
|
|
240
254
|
|
package/src/store/agents.ts
CHANGED
|
@@ -73,6 +73,11 @@ export interface AgentSession {
|
|
|
73
73
|
resumeArgv: string[];
|
|
74
74
|
/** Set when the session is open in a herdr pane. */
|
|
75
75
|
herdr?: HerdrLink;
|
|
76
|
+
/**
|
|
77
|
+
* Position in the list (newest first). Set by the store: last activity for
|
|
78
|
+
* closed sessions, last state change for live ones — see withSortKeys.
|
|
79
|
+
*/
|
|
80
|
+
sortKeyMs?: number;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
/** One agent CLI's session source. */
|
|
@@ -113,11 +118,11 @@ export function filterSessions(arr: AgentSession[], filter: AgentsFilter): Agent
|
|
|
113
118
|
return filter === "all" ? arr : arr.filter((s) => s.provider === filter);
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/** Compact human-readable age
|
|
121
|
+
/** Compact human-readable age; under a minute reads `now` (no ticking seconds). */
|
|
117
122
|
export function formatAge(ts: number, now: number): string {
|
|
118
123
|
if (!ts) return "—";
|
|
119
124
|
const delta = (now - ts) / 1000;
|
|
120
|
-
if (delta < 60) return
|
|
125
|
+
if (delta < 60) return "now";
|
|
121
126
|
if (delta < 3600) return `${Math.floor(delta / 60)}m`;
|
|
122
127
|
if (delta < 86_400) return `${Math.floor(delta / 3600)}h`;
|
|
123
128
|
return `${Math.floor(delta / 86_400)}d`;
|
|
@@ -144,15 +149,48 @@ export function isLive(status: AgentStatus): boolean {
|
|
|
144
149
|
* cursor indices aligned with the full list.
|
|
145
150
|
*/
|
|
146
151
|
export function sortSessions(arr: AgentSession[]): AgentSession[] {
|
|
152
|
+
const key = (s: AgentSession) => s.sortKeyMs ?? s.lastActivityMs;
|
|
147
153
|
return arr.slice().sort((a, b) => {
|
|
148
154
|
const archived = Number(a.status === "archived") - Number(b.status === "archived");
|
|
149
155
|
if (archived !== 0) return archived;
|
|
150
|
-
const recency = b
|
|
156
|
+
const recency = key(b) - key(a);
|
|
151
157
|
if (recency !== 0) return recency;
|
|
152
158
|
return STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
153
159
|
});
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
export interface StateMemory {
|
|
163
|
+
status: AgentStatus;
|
|
164
|
+
since: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sort keys that don't churn. A working agent writes its session file all
|
|
169
|
+
* the time; sorting by that made rows swap on every write. Live sessions are
|
|
170
|
+
* keyed by their last activity *as of their last state change* — they move
|
|
171
|
+
* once when they start or finish (both write), not on every write — and
|
|
172
|
+
* closed ones by their last activity. Using activity rather than the wall
|
|
173
|
+
* clock means a "change" that is only new information (herdr answering after
|
|
174
|
+
* the first scan) doesn't reshuffle the list.
|
|
175
|
+
* `memory` carries state across refreshes (keyed by provider + session id).
|
|
176
|
+
*/
|
|
177
|
+
export function withSortKeys(
|
|
178
|
+
sessions: AgentSession[],
|
|
179
|
+
memory: Map<string, StateMemory>,
|
|
180
|
+
): AgentSession[] {
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
const out = sessions.map((s) => {
|
|
183
|
+
const id = `${s.provider}:${s.sessionId}`;
|
|
184
|
+
seen.add(id);
|
|
185
|
+
const prev = memory.get(id);
|
|
186
|
+
const since = prev && prev.status === s.status ? prev.since : s.lastActivityMs;
|
|
187
|
+
memory.set(id, { status: s.status, since });
|
|
188
|
+
return { ...s, sortKeyMs: isLive(s.status) ? since : s.lastActivityMs };
|
|
189
|
+
});
|
|
190
|
+
for (const id of memory.keys()) if (!seen.has(id)) memory.delete(id);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
156
194
|
// ─── Reactive store ─────────────────────────────────────────────────────────
|
|
157
195
|
|
|
158
196
|
export interface AgentsStore {
|
|
@@ -195,9 +233,11 @@ export function createAgentsStore(
|
|
|
195
233
|
}
|
|
196
234
|
}
|
|
197
235
|
|
|
236
|
+
const stateMemory = new Map<string, StateMemory>();
|
|
198
237
|
function publish(): void {
|
|
199
238
|
const all = [...byAdapter.values()].flat();
|
|
200
|
-
|
|
239
|
+
const linked = linkHerdrSessions(all, herdr?.snapshot());
|
|
240
|
+
setSessions(sortSessions(withSortKeys(linked, stateMemory)));
|
|
201
241
|
}
|
|
202
242
|
herdr?.onChange(publish);
|
|
203
243
|
|
package/src/store/herdr.ts
CHANGED
|
@@ -213,6 +213,11 @@ export interface HerdrSource {
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
export const HERDR_POLL_MS = 2000;
|
|
216
|
+
/**
|
|
217
|
+
* Consecutive failed polls before herdr counts as gone. A single slow or
|
|
218
|
+
* failed call used to drop every link at once, making states flicker.
|
|
219
|
+
*/
|
|
220
|
+
export const HERDR_MISSES_BEFORE_CLEAR = 3;
|
|
216
221
|
|
|
217
222
|
/**
|
|
218
223
|
* Poll `herdr api snapshot` in the background. Inert when herdr isn't on
|
|
@@ -220,7 +225,11 @@ export const HERDR_POLL_MS = 2000;
|
|
|
220
225
|
* without noise.
|
|
221
226
|
*/
|
|
222
227
|
export function createHerdrSource(
|
|
223
|
-
{
|
|
228
|
+
{
|
|
229
|
+
pollMs = HERDR_POLL_MS,
|
|
230
|
+
bin = Bun.which("herdr"),
|
|
231
|
+
missesBeforeClear = HERDR_MISSES_BEFORE_CLEAR,
|
|
232
|
+
}: { pollMs?: number; bin?: string | null; missesBeforeClear?: number } = {},
|
|
224
233
|
): HerdrSource {
|
|
225
234
|
let current: HerdrSnapshot | undefined;
|
|
226
235
|
let lastRaw = "";
|
|
@@ -229,6 +238,7 @@ export function createHerdrSource(
|
|
|
229
238
|
|
|
230
239
|
let inFlight = false;
|
|
231
240
|
let disposed = false;
|
|
241
|
+
let misses = 0;
|
|
232
242
|
const poll = async () => {
|
|
233
243
|
if (inFlight || disposed) return;
|
|
234
244
|
inFlight = true;
|
|
@@ -242,7 +252,9 @@ export function createHerdrSource(
|
|
|
242
252
|
const out = await new Response(proc.stdout).text();
|
|
243
253
|
clearTimeout(timer);
|
|
244
254
|
const code = await proc.exited;
|
|
245
|
-
|
|
255
|
+
let snap = code === 0 ? parseHerdrSnapshot(out) : undefined;
|
|
256
|
+
if (snap) misses = 0;
|
|
257
|
+
else if (++misses < missesBeforeClear) snap = current; // ride out a blip
|
|
246
258
|
// Only the parts we use decide whether anything changed.
|
|
247
259
|
const key = snap
|
|
248
260
|
? JSON.stringify([snap.panes, [...snap.tabs], [...snap.workspaces]])
|
|
@@ -253,7 +265,8 @@ export function createHerdrSource(
|
|
|
253
265
|
for (const cb of listeners) cb();
|
|
254
266
|
}
|
|
255
267
|
} catch {
|
|
256
|
-
// herdr vanished mid-call — try again next tick
|
|
268
|
+
// herdr vanished mid-call — counts as a miss, try again next tick
|
|
269
|
+
misses++;
|
|
257
270
|
} finally {
|
|
258
271
|
inFlight = false;
|
|
259
272
|
}
|
package/src/ui/AgentRow.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { Show, createMemo, createSignal, onMount } from "solid-js";
|
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
|
|
17
17
|
import { layoutCardDetails, layoutCardName, layoutLine } from "~/ui/agent-line";
|
|
18
|
+
import { clockNow } from "~/ui/clock";
|
|
18
19
|
import { T } from "~/ui/glyphs";
|
|
19
20
|
import { herdrPlace, type IndicatorStyle } from "~/store/herdr";
|
|
20
21
|
import {
|
|
@@ -95,7 +96,7 @@ interface AgentRowProps {
|
|
|
95
96
|
|
|
96
97
|
export function AgentRow(props: AgentRowProps) {
|
|
97
98
|
const ageStr = createMemo(() =>
|
|
98
|
-
formatAge(props.session.lastActivityMs,
|
|
99
|
+
formatAge(props.session.lastActivityMs, clockNow()),
|
|
99
100
|
);
|
|
100
101
|
const nameMax = () => props.nameMaxChars ?? 40;
|
|
101
102
|
const displayName = createMemo(() => {
|
package/src/ui/AgentsBar.tsx
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* visible. Clicking a row sets activeZone + agent cursor.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { Index, Show, createEffect, createMemo } from "solid-js";
|
|
12
12
|
|
|
13
13
|
import { AgentRow } from "~/ui/AgentRow";
|
|
14
|
+
import { useStickyAgentCursor } from "~/ui/agent-cursor";
|
|
14
15
|
import { T } from "~/ui/glyphs";
|
|
15
16
|
import { HARNESS } from "~/store/agents";
|
|
16
17
|
import type { TuiStore } from "~/store/index";
|
|
@@ -42,6 +43,7 @@ export function AgentsBar(props: AgentsBarProps) {
|
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
46
|
+
useStickyAgentCursor(props.store, allShown);
|
|
45
47
|
|
|
46
48
|
// Keep the cursor row visible as j/k moves it (mouse wheel scrolls freely
|
|
47
49
|
// via the scrollbox itself). setTimeout(0) waits for layout to commit.
|
|
@@ -96,22 +98,23 @@ export function AgentsBar(props: AgentsBarProps) {
|
|
|
96
98
|
scrollbarOptions: { visible: false },
|
|
97
99
|
}}
|
|
98
100
|
>
|
|
99
|
-
<
|
|
101
|
+
{/* <Index>: stable rows by position — see AgentsOnly (#52). */}
|
|
102
|
+
<Index each={allShown()}>
|
|
100
103
|
{(session, i) => (
|
|
101
|
-
<box id={agentRowId(i
|
|
104
|
+
<box id={agentRowId(i)}>
|
|
102
105
|
<AgentRow
|
|
103
106
|
indicators={props.store.agentIndicators}
|
|
104
|
-
session={session}
|
|
105
|
-
cursor={isActive() && i
|
|
107
|
+
session={session()}
|
|
108
|
+
cursor={isActive() && i === agentRow()}
|
|
106
109
|
nameMaxChars={48}
|
|
107
110
|
onClick={() => {
|
|
108
111
|
props.store.setActiveZone("agents");
|
|
109
|
-
props.store.setCursor(0, i
|
|
112
|
+
props.store.setCursor(0, i);
|
|
110
113
|
}}
|
|
111
114
|
/>
|
|
112
115
|
</box>
|
|
113
116
|
)}
|
|
114
|
-
</
|
|
117
|
+
</Index>
|
|
115
118
|
</scrollbox>
|
|
116
119
|
</Show>
|
|
117
120
|
</box>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the Agents cursor on the same session while the list reorders.
|
|
3
|
+
*
|
|
4
|
+
* The cursor is a row index (`ui.row`), and sessions move all the time —
|
|
5
|
+
* sorted by recency, a new one lands on top, a working one climbs. Without
|
|
6
|
+
* this, the highlighted row silently became a different session and Enter /
|
|
7
|
+
* H / c acted on it. Call once inside the component that renders the list.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createEffect } from "solid-js";
|
|
11
|
+
|
|
12
|
+
import type { AgentSession } from "~/store/agents";
|
|
13
|
+
import type { TuiStore } from "~/store/index";
|
|
14
|
+
|
|
15
|
+
/** Pure decision, for tests: where the cursor should be after a change. */
|
|
16
|
+
export function followSelection(
|
|
17
|
+
prev: { list: readonly AgentSession[] | undefined; row: number; id: string | undefined },
|
|
18
|
+
list: readonly AgentSession[],
|
|
19
|
+
row: number,
|
|
20
|
+
): number {
|
|
21
|
+
// Only a list change (not a keypress) may move the cursor.
|
|
22
|
+
if (list === prev.list || row !== prev.row || !prev.id) return row;
|
|
23
|
+
if (list[row]?.sessionId === prev.id) return row;
|
|
24
|
+
const idx = list.findIndex((s) => s.sessionId === prev.id);
|
|
25
|
+
return idx >= 0 ? idx : Math.min(row, Math.max(0, list.length - 1));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useStickyAgentCursor(store: TuiStore, list: () => readonly AgentSession[]): void {
|
|
29
|
+
const prev: { list: readonly AgentSession[] | undefined; row: number; id: string | undefined } = {
|
|
30
|
+
list: undefined,
|
|
31
|
+
row: -1,
|
|
32
|
+
id: undefined,
|
|
33
|
+
};
|
|
34
|
+
createEffect(() => {
|
|
35
|
+
const items = list();
|
|
36
|
+
const row = store.state.ui.row;
|
|
37
|
+
// `ui.row` belongs to whichever zone is active.
|
|
38
|
+
if (store.state.ui.activeZone !== "agents") {
|
|
39
|
+
prev.list = undefined;
|
|
40
|
+
prev.id = undefined;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const next = followSelection(prev, items, row);
|
|
44
|
+
prev.list = items;
|
|
45
|
+
prev.row = next;
|
|
46
|
+
prev.id = items[next]?.sessionId;
|
|
47
|
+
if (next !== row) store.setCursor(0, next);
|
|
48
|
+
});
|
|
49
|
+
}
|
package/src/ui/clock.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shared, coarse clock for relative times ("5m", "3h"). Rows used to compute
|
|
3
|
+
* their age only when their data changed, so ages jumped irregularly while
|
|
4
|
+
* agents worked; one timer for the whole UI keeps them ticking evenly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createSignal } from "solid-js";
|
|
8
|
+
|
|
9
|
+
export const CLOCK_TICK_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
const [now, setNow] = createSignal(Date.now());
|
|
12
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
13
|
+
|
|
14
|
+
/** Current time, updated every CLOCK_TICK_MS. Starts on first use. */
|
|
15
|
+
export function clockNow(): number {
|
|
16
|
+
if (!timer) {
|
|
17
|
+
timer = setInterval(() => setNow(Date.now()), CLOCK_TICK_MS);
|
|
18
|
+
timer.unref?.();
|
|
19
|
+
}
|
|
20
|
+
return now();
|
|
21
|
+
}
|
package/src/views/AgentsOnly.tsx
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* scrollChildIntoView (same trick used in BoardView for active columns).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { Index, Show, createEffect, createMemo } from "solid-js";
|
|
9
9
|
|
|
10
10
|
import { AgentRow } from "~/ui/AgentRow";
|
|
11
|
+
import { useStickyAgentCursor } from "~/ui/agent-cursor";
|
|
11
12
|
import { T } from "~/ui/glyphs";
|
|
12
13
|
import { HARNESS } from "~/store/agents";
|
|
13
14
|
import type { TuiStore } from "~/store/index";
|
|
@@ -16,10 +17,9 @@ interface ScrollBoxLike {
|
|
|
16
17
|
scrollChildIntoView(id: string): void;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return `tuiboard-agent-${sessionId.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
20
|
+
/** Rows are keyed by position (see the <Index> below), and so are their ids. */
|
|
21
|
+
function rowId(index: number): string {
|
|
22
|
+
return `tuiboard-agent-card-${index}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function AgentsOnly(props: { store: TuiStore }) {
|
|
@@ -29,17 +29,17 @@ export function AgentsOnly(props: { store: TuiStore }) {
|
|
|
29
29
|
const filter = () => props.store.state.ui.agentsFilter;
|
|
30
30
|
const filterTag = () => (filter() === "all" ? "" : ` · ${HARNESS[filter() as keyof typeof HARNESS].code}`);
|
|
31
31
|
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
32
|
+
useStickyAgentCursor(props.store, sessions);
|
|
32
33
|
|
|
33
34
|
// Auto-scroll the list so the active row is visible. setTimeout(0) waits
|
|
34
35
|
// for OpenTUI to finish layout before requesting scroll.
|
|
35
36
|
createEffect(() => {
|
|
36
37
|
const row = agentRow();
|
|
37
38
|
if (!isActive() || !scrollBoxRef) return;
|
|
38
|
-
|
|
39
|
-
if (!target) return;
|
|
39
|
+
if (row >= sessions().length) return;
|
|
40
40
|
setTimeout(() => {
|
|
41
41
|
try {
|
|
42
|
-
scrollBoxRef?.scrollChildIntoView(rowId(
|
|
42
|
+
scrollBoxRef?.scrollChildIntoView(rowId(row));
|
|
43
43
|
} catch {
|
|
44
44
|
// Child not mounted yet — harmless.
|
|
45
45
|
}
|
|
@@ -85,23 +85,29 @@ export function AgentsOnly(props: { store: TuiStore }) {
|
|
|
85
85
|
scrollbarOptions: { visible: false },
|
|
86
86
|
}}
|
|
87
87
|
>
|
|
88
|
-
|
|
88
|
+
{/*
|
|
89
|
+
<Index>, not <For>: one stable row per position whose data
|
|
90
|
+
updates in place. Sessions are re-created on every refresh and
|
|
91
|
+
reorder by recency, and moving children inside OpenTUI's
|
|
92
|
+
scrollbox left stale/duplicate rows on screen (#52).
|
|
93
|
+
*/}
|
|
94
|
+
<Index each={sessions()}>
|
|
89
95
|
{(session, i) => (
|
|
90
|
-
<box id={rowId(
|
|
96
|
+
<box id={rowId(i)}>
|
|
91
97
|
<AgentRow
|
|
92
|
-
session={session}
|
|
93
|
-
cursor={isActive() && i
|
|
98
|
+
session={session()}
|
|
99
|
+
cursor={isActive() && i === agentRow()}
|
|
94
100
|
nameMaxChars={120}
|
|
95
101
|
variant="card"
|
|
96
102
|
indicators={props.store.agentIndicators}
|
|
97
103
|
onClick={() => {
|
|
98
104
|
props.store.setActiveZone("agents");
|
|
99
|
-
props.store.setCursor(0, i
|
|
105
|
+
props.store.setCursor(0, i);
|
|
100
106
|
}}
|
|
101
107
|
/>
|
|
102
108
|
</box>
|
|
103
109
|
)}
|
|
104
|
-
</
|
|
110
|
+
</Index>
|
|
105
111
|
</scrollbox>
|
|
106
112
|
</Show>
|
|
107
113
|
</box>
|