botholomew 0.14.0 → 0.15.0
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/package.json +1 -1
- package/src/chat/session.ts +4 -0
- package/src/commands/chat.ts +14 -6
- package/src/commands/context.ts +33 -8
- package/src/context/fetcher-errors.ts +8 -0
- package/src/context/fetcher.ts +96 -27
- package/src/context/markdown-converter.ts +186 -0
- package/src/tui/App.tsx +75 -86
- package/src/tui/components/ContextPanel.tsx +325 -151
- package/src/tui/components/HelpPanel.tsx +42 -43
- package/src/tui/components/SchedulePanel.tsx +98 -97
- package/src/tui/components/Scrollbar.tsx +73 -0
- package/src/tui/components/TabBar.tsx +13 -13
- package/src/tui/components/TaskPanel.tsx +86 -95
- package/src/tui/components/ThreadPanel.tsx +133 -120
- package/src/tui/components/ToolPanel.tsx +84 -85
- package/src/tui/components/WorkerPanel.tsx +77 -77
- package/src/tui/listDetailKeys.ts +124 -0
- package/src/tui/useLatestRef.ts +18 -0
- package/src/utils/frontmatter.ts +10 -2
- package/src/worker/prompt.ts +10 -1
|
@@ -6,7 +6,14 @@ import {
|
|
|
6
6
|
type Task,
|
|
7
7
|
} from "../../tasks/schema.ts";
|
|
8
8
|
import { deleteTask, listTasks } from "../../tasks/store.ts";
|
|
9
|
+
import {
|
|
10
|
+
detailPaneBorderProps,
|
|
11
|
+
type FocusState,
|
|
12
|
+
handleListDetailKey,
|
|
13
|
+
} from "../listDetailKeys.ts";
|
|
9
14
|
import { ansi, theme } from "../theme.ts";
|
|
15
|
+
import { useLatestRef } from "../useLatestRef.ts";
|
|
16
|
+
import { Scrollbar } from "./Scrollbar.tsx";
|
|
10
17
|
|
|
11
18
|
interface TaskPanelProps {
|
|
12
19
|
projectDir: string;
|
|
@@ -32,14 +39,6 @@ const STATUS_COLORS: Record<Task["status"], string> = {
|
|
|
32
39
|
complete: theme.success,
|
|
33
40
|
};
|
|
34
41
|
|
|
35
|
-
const STATUS_ANSI: Record<Task["status"], string> = {
|
|
36
|
-
pending: ansi.muted,
|
|
37
|
-
in_progress: ansi.accent,
|
|
38
|
-
waiting: ansi.info,
|
|
39
|
-
failed: ansi.error,
|
|
40
|
-
complete: ansi.success,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
42
|
const PRIORITY_LABELS: Record<Task["priority"], string> = {
|
|
44
43
|
high: "HI",
|
|
45
44
|
medium: "MD",
|
|
@@ -52,12 +51,6 @@ const PRIORITY_COLORS: Record<Task["priority"], string> = {
|
|
|
52
51
|
low: theme.muted,
|
|
53
52
|
};
|
|
54
53
|
|
|
55
|
-
const PRIORITY_ANSI: Record<Task["priority"], string> = {
|
|
56
|
-
high: ansi.error,
|
|
57
|
-
medium: ansi.accent,
|
|
58
|
-
low: ansi.muted,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
54
|
function formatTimestamp(iso: string): string {
|
|
62
55
|
const d = new Date(iso);
|
|
63
56
|
if (Number.isNaN(d.getTime())) return iso;
|
|
@@ -72,29 +65,11 @@ function formatTimestamp(iso: string): string {
|
|
|
72
65
|
function buildTaskDetailAnsi(task: Task): string {
|
|
73
66
|
const lines: string[] = [];
|
|
74
67
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const statusAnsi = STATUS_ANSI[task.status];
|
|
79
|
-
lines.push(
|
|
80
|
-
`${ansi.bold}${ansi.primary}Status${ansi.reset} ${statusAnsi}${STATUS_ICONS[task.status]} ${task.status}${ansi.reset}`,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
const priorityAnsi = PRIORITY_ANSI[task.priority];
|
|
84
|
-
lines.push(
|
|
85
|
-
`${ansi.bold}${ansi.primary}Priority${ansi.reset} ${priorityAnsi}${task.priority}${ansi.reset}`,
|
|
86
|
-
);
|
|
87
|
-
|
|
68
|
+
// Body only — name/status/priority/claim/timestamps are rendered in the
|
|
69
|
+
// panel header.
|
|
88
70
|
lines.push(
|
|
89
71
|
`${ansi.bold}${ansi.primary}Claimed${ansi.reset} ${task.claimed_by ? task.claimed_by : `${ansi.dim}(unclaimed)${ansi.reset}`}`,
|
|
90
72
|
);
|
|
91
|
-
|
|
92
|
-
lines.push(
|
|
93
|
-
`${ansi.bold}${ansi.primary}Created${ansi.reset} ${ansi.dim}${formatTimestamp(task.created_at)}${ansi.reset}`,
|
|
94
|
-
);
|
|
95
|
-
lines.push(
|
|
96
|
-
`${ansi.bold}${ansi.primary}Updated${ansi.reset} ${ansi.dim}${formatTimestamp(task.updated_at)}${ansi.reset}`,
|
|
97
|
-
);
|
|
98
73
|
lines.push("");
|
|
99
74
|
|
|
100
75
|
if (task.description) {
|
|
@@ -149,6 +124,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
149
124
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
150
125
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
151
126
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
127
|
+
const [focus, setFocus] = useState<FocusState>("list");
|
|
152
128
|
const [statusFilter, setStatusFilter] = useState<Task["status"] | null>(null);
|
|
153
129
|
const [priorityFilter, setPriorityFilter] = useState<Task["priority"] | null>(
|
|
154
130
|
null,
|
|
@@ -218,49 +194,24 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
218
194
|
setRefreshTick((t) => t + 1);
|
|
219
195
|
}, []);
|
|
220
196
|
|
|
197
|
+
const itemCountRef = useLatestRef(tasks.length);
|
|
198
|
+
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
199
|
+
const selectedTaskRef = useLatestRef(selectedTask);
|
|
200
|
+
const focusRef = useLatestRef(focus);
|
|
201
|
+
|
|
221
202
|
useInput(
|
|
222
203
|
(input, key) => {
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
} else {
|
|
235
|
-
setSelectedIndex((i) => Math.min(tasks.length - 1, i + 1));
|
|
236
|
-
}
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (input === "j") {
|
|
241
|
-
setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
if (input === "k") {
|
|
245
|
-
setDetailScroll((s) => Math.max(0, s - 1));
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
if (input === "J") {
|
|
249
|
-
setDetailScroll((s) =>
|
|
250
|
-
Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
|
|
251
|
-
);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
if (input === "K") {
|
|
255
|
-
setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
if (input === "g") {
|
|
259
|
-
setDetailScroll(0);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
if (input === "G") {
|
|
263
|
-
setDetailScroll(maxDetailScroll);
|
|
204
|
+
if (
|
|
205
|
+
handleListDetailKey(input, key, {
|
|
206
|
+
focusRef,
|
|
207
|
+
setFocus,
|
|
208
|
+
itemCountRef,
|
|
209
|
+
maxDetailScrollRef,
|
|
210
|
+
setSelectedIndex,
|
|
211
|
+
setDetailScroll,
|
|
212
|
+
pageScrollLines: PAGE_SCROLL_LINES,
|
|
213
|
+
})
|
|
214
|
+
) {
|
|
264
215
|
return;
|
|
265
216
|
}
|
|
266
217
|
|
|
@@ -272,8 +223,10 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
272
223
|
setPriorityFilter((f) => cycleFilter(f, TASK_PRIORITIES));
|
|
273
224
|
return;
|
|
274
225
|
}
|
|
275
|
-
if (input === "d"
|
|
276
|
-
|
|
226
|
+
if (input === "d") {
|
|
227
|
+
const t = selectedTaskRef.current;
|
|
228
|
+
if (!t) return;
|
|
229
|
+
deleteTask(projectDir, t.id).then(() => {
|
|
277
230
|
forceRefresh();
|
|
278
231
|
});
|
|
279
232
|
return;
|
|
@@ -383,29 +336,67 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
383
336
|
flexGrow={1}
|
|
384
337
|
height={visibleRows + 1}
|
|
385
338
|
paddingX={1}
|
|
339
|
+
{...detailPaneBorderProps(focus)}
|
|
386
340
|
overflow="hidden"
|
|
387
341
|
>
|
|
388
|
-
{
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
</Text>
|
|
342
|
+
{selectedTask && <TaskDetailHeader task={selectedTask} />}
|
|
343
|
+
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
344
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
345
|
+
{detailVisible.map((line, i) => {
|
|
346
|
+
const lineNum = detailScroll + i;
|
|
347
|
+
return (
|
|
348
|
+
<Text key={lineNum} wrap="truncate-end">
|
|
349
|
+
{line || " "}
|
|
350
|
+
</Text>
|
|
351
|
+
);
|
|
352
|
+
})}
|
|
400
353
|
</Box>
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
354
|
+
<Scrollbar
|
|
355
|
+
total={detailLines.length}
|
|
356
|
+
visible={visibleRows - 3}
|
|
357
|
+
offset={detailScroll}
|
|
358
|
+
height={visibleRows - 3}
|
|
359
|
+
focused={focus === "detail"}
|
|
360
|
+
/>
|
|
361
|
+
</Box>
|
|
362
|
+
<Text dimColor>
|
|
363
|
+
{focus === "detail"
|
|
364
|
+
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
365
|
+
: "↑↓ select · → enter detail · f filter · p priority · d delete · r refresh"}
|
|
366
|
+
</Text>
|
|
367
|
+
</Box>
|
|
368
|
+
</Box>
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
function TaskDetailHeader({ task }: { task: Task }) {
|
|
373
|
+
return (
|
|
374
|
+
<Box flexDirection="column">
|
|
375
|
+
<Box>
|
|
376
|
+
<Text bold color={theme.info} wrap="truncate-end">
|
|
377
|
+
{task.name}
|
|
378
|
+
</Text>
|
|
379
|
+
</Box>
|
|
380
|
+
<Box>
|
|
381
|
+
<Text wrap="truncate-end">
|
|
382
|
+
<Text color={STATUS_COLORS[task.status]}>
|
|
383
|
+
{STATUS_ICONS[task.status]} {task.status}
|
|
384
|
+
</Text>
|
|
385
|
+
<Text dimColor> · </Text>
|
|
386
|
+
<Text color={PRIORITY_COLORS[task.priority]}>
|
|
387
|
+
{PRIORITY_LABELS[task.priority]}
|
|
388
|
+
</Text>
|
|
404
389
|
<Text dimColor>
|
|
405
|
-
|
|
390
|
+
{" · created "}
|
|
391
|
+
{formatTimestamp(task.created_at)}
|
|
392
|
+
{" · updated "}
|
|
393
|
+
{formatTimestamp(task.updated_at)}
|
|
406
394
|
</Text>
|
|
407
|
-
|
|
395
|
+
</Text>
|
|
396
|
+
</Box>
|
|
397
|
+
<Box>
|
|
398
|
+
<Text dimColor>{"─".repeat(2)}</Text>
|
|
408
399
|
</Box>
|
|
409
400
|
</Box>
|
|
410
401
|
);
|
|
411
|
-
}
|
|
402
|
+
}
|
|
@@ -9,7 +9,14 @@ import {
|
|
|
9
9
|
listThreads,
|
|
10
10
|
type Thread,
|
|
11
11
|
} from "../../threads/store.ts";
|
|
12
|
+
import {
|
|
13
|
+
detailPaneBorderProps,
|
|
14
|
+
type FocusState,
|
|
15
|
+
handleListDetailKey,
|
|
16
|
+
} from "../listDetailKeys.ts";
|
|
12
17
|
import { ansi, theme } from "../theme.ts";
|
|
18
|
+
import { useLatestRef } from "../useLatestRef.ts";
|
|
19
|
+
import { Scrollbar } from "./Scrollbar.tsx";
|
|
13
20
|
|
|
14
21
|
interface ThreadPanelProps {
|
|
15
22
|
projectDir: string;
|
|
@@ -40,11 +47,6 @@ const TYPE_COLORS: Record<Thread["type"], string> = {
|
|
|
40
47
|
chat_session: theme.info,
|
|
41
48
|
};
|
|
42
49
|
|
|
43
|
-
const TYPE_ANSI: Record<Thread["type"], string> = {
|
|
44
|
-
worker_tick: ansi.accent,
|
|
45
|
-
chat_session: ansi.info,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
50
|
const ROLE_ANSI: Record<string, string> = {
|
|
49
51
|
user: ansi.success,
|
|
50
52
|
assistant: ansi.info,
|
|
@@ -76,44 +78,18 @@ function formatDate(d: Date): string {
|
|
|
76
78
|
function buildThreadDetailAnsi(
|
|
77
79
|
thread: Thread,
|
|
78
80
|
interactions: Interaction[],
|
|
79
|
-
|
|
81
|
+
_isActiveThread: boolean,
|
|
80
82
|
): string {
|
|
81
83
|
const lines: string[] = [];
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
`${ansi.bold}${ansi.italic}${ansi.info}${thread.title || "(untitled)"}${ansi.reset}`,
|
|
85
|
-
);
|
|
86
|
-
lines.push("");
|
|
87
|
-
|
|
88
|
-
const typeAnsi = TYPE_ANSI[thread.type];
|
|
89
|
-
lines.push(
|
|
90
|
-
`${ansi.bold}${ansi.primary}Type${ansi.reset} ${typeAnsi}${TYPE_ICONS[thread.type]} ${TYPE_LABELS[thread.type]}${ansi.reset}`,
|
|
91
|
-
);
|
|
92
|
-
|
|
85
|
+
// Body only — title/type/timing live in the panel header.
|
|
93
86
|
if (thread.task_id) {
|
|
94
87
|
lines.push(
|
|
95
88
|
`${ansi.bold}${ansi.primary}Task${ansi.reset} ${ansi.dim}${thread.task_id}${ansi.reset}`,
|
|
96
89
|
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
lines.push(
|
|
100
|
-
`${ansi.bold}${ansi.primary}Started${ansi.reset} ${ansi.dim}${formatDate(thread.started_at)}${ansi.reset}`,
|
|
101
|
-
);
|
|
102
|
-
lines.push(
|
|
103
|
-
`${ansi.bold}${ansi.primary}Ended${ansi.reset} ${thread.ended_at ? `${ansi.dim}${formatDate(thread.ended_at)}${ansi.reset}` : `${ansi.success}ongoing${ansi.reset}`}`,
|
|
104
|
-
);
|
|
105
|
-
lines.push(
|
|
106
|
-
`${ansi.bold}${ansi.primary}Duration${ansi.reset} ${ansi.dim}${formatDuration(thread.started_at, thread.ended_at)}${ansi.reset}`,
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
if (isActiveThread) {
|
|
110
90
|
lines.push("");
|
|
111
|
-
lines.push(
|
|
112
|
-
`${ansi.bold}${ansi.success}★ Current session thread${ansi.reset}`,
|
|
113
|
-
);
|
|
114
91
|
}
|
|
115
92
|
|
|
116
|
-
lines.push("");
|
|
117
93
|
lines.push(
|
|
118
94
|
`${ansi.bold}${ansi.primary}Interactions${ansi.reset} ${interactions.length} total`,
|
|
119
95
|
);
|
|
@@ -189,6 +165,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
189
165
|
const [threads, setThreads] = useState<Thread[]>([]);
|
|
190
166
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
191
167
|
const [detailScroll, setDetailScroll] = useState(0);
|
|
168
|
+
const [focus, setFocus] = useState<FocusState>("list");
|
|
192
169
|
const [typeFilter, setTypeFilter] = useState<Thread["type"] | null>(null);
|
|
193
170
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
194
171
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
@@ -346,10 +323,21 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
346
323
|
setRefreshTick((t) => t + 1);
|
|
347
324
|
}, []);
|
|
348
325
|
|
|
326
|
+
// Mirror state into refs to dodge Ink's stale-closure bug.
|
|
327
|
+
const itemCountRef = useLatestRef(filteredThreads.length);
|
|
328
|
+
const maxDetailScrollRef = useLatestRef(maxDetailScroll);
|
|
329
|
+
const selectedThreadRef = useLatestRef(selectedThread);
|
|
330
|
+
const selectedDetailRef = useLatestRef(selectedDetail);
|
|
331
|
+
const searchingRef = useLatestRef(searching);
|
|
332
|
+
const confirmDeleteRef = useLatestRef(confirmDelete);
|
|
333
|
+
const isActiveSelectedRef = useLatestRef(isActiveSelected);
|
|
334
|
+
const followingRef = useLatestRef(following);
|
|
335
|
+
const focusRef = useLatestRef(focus);
|
|
336
|
+
|
|
349
337
|
useInput(
|
|
350
338
|
(input, key) => {
|
|
351
339
|
// Search mode: capture typed characters
|
|
352
|
-
if (
|
|
340
|
+
if (searchingRef.current) {
|
|
353
341
|
if (key.escape) {
|
|
354
342
|
setSearching(false);
|
|
355
343
|
setSearchQuery("");
|
|
@@ -373,10 +361,11 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
373
361
|
}
|
|
374
362
|
|
|
375
363
|
// Delete confirmation mode
|
|
376
|
-
if (
|
|
364
|
+
if (confirmDeleteRef.current) {
|
|
377
365
|
if (input === "y" || input === "d") {
|
|
378
|
-
|
|
379
|
-
|
|
366
|
+
const t = selectedThreadRef.current;
|
|
367
|
+
if (t && !isActiveSelectedRef.current) {
|
|
368
|
+
deleteThread(projectDir, t.id).then(() => {
|
|
380
369
|
forceRefresh();
|
|
381
370
|
});
|
|
382
371
|
}
|
|
@@ -387,47 +376,17 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
387
376
|
return;
|
|
388
377
|
}
|
|
389
378
|
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
} else {
|
|
402
|
-
setSelectedIndex((i) => Math.min(filteredThreads.length - 1, i + 1));
|
|
403
|
-
}
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (input === "j") {
|
|
408
|
-
setDetailScroll((s) => Math.min(maxDetailScroll, s + 1));
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (input === "k") {
|
|
412
|
-
setDetailScroll((s) => Math.max(0, s - 1));
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
if (input === "J") {
|
|
416
|
-
setDetailScroll((s) =>
|
|
417
|
-
Math.min(maxDetailScroll, s + PAGE_SCROLL_LINES),
|
|
418
|
-
);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
if (input === "K") {
|
|
422
|
-
setDetailScroll((s) => Math.max(0, s - PAGE_SCROLL_LINES));
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
if (input === "g") {
|
|
426
|
-
setDetailScroll(0);
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
if (input === "G") {
|
|
430
|
-
setDetailScroll(maxDetailScroll);
|
|
379
|
+
if (
|
|
380
|
+
handleListDetailKey(input, key, {
|
|
381
|
+
focusRef,
|
|
382
|
+
setFocus,
|
|
383
|
+
itemCountRef,
|
|
384
|
+
maxDetailScrollRef,
|
|
385
|
+
setSelectedIndex,
|
|
386
|
+
setDetailScroll,
|
|
387
|
+
pageScrollLines: PAGE_SCROLL_LINES,
|
|
388
|
+
})
|
|
389
|
+
) {
|
|
431
390
|
return;
|
|
432
391
|
}
|
|
433
392
|
|
|
@@ -435,8 +394,8 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
435
394
|
setTypeFilter((f) => cycleFilter(f, THREAD_TYPES));
|
|
436
395
|
return;
|
|
437
396
|
}
|
|
438
|
-
if (input === "d" &&
|
|
439
|
-
if (
|
|
397
|
+
if (input === "d" && selectedThreadRef.current) {
|
|
398
|
+
if (isActiveSelectedRef.current) return; // Can't delete active thread
|
|
440
399
|
setConfirmDelete(true);
|
|
441
400
|
return;
|
|
442
401
|
}
|
|
@@ -449,14 +408,17 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
449
408
|
setSearchQuery("");
|
|
450
409
|
return;
|
|
451
410
|
}
|
|
452
|
-
if (input === "w"
|
|
453
|
-
|
|
411
|
+
if (input === "w") {
|
|
412
|
+
const t = selectedThreadRef.current;
|
|
413
|
+
if (!t) return;
|
|
414
|
+
if (followingRef.current) {
|
|
454
415
|
setFollowing(false);
|
|
455
|
-
} else if (!
|
|
456
|
-
const maxSeq =
|
|
416
|
+
} else if (!t.ended_at) {
|
|
417
|
+
const maxSeq =
|
|
418
|
+
selectedDetailRef.current?.interactions.at(-1)?.sequence ?? 0;
|
|
457
419
|
lastSeenSequenceRef.current = maxSeq;
|
|
458
420
|
setFollowing(true);
|
|
459
|
-
setDetailScroll(
|
|
421
|
+
setDetailScroll(maxDetailScrollRef.current);
|
|
460
422
|
}
|
|
461
423
|
return;
|
|
462
424
|
}
|
|
@@ -583,46 +545,97 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
583
545
|
flexGrow={1}
|
|
584
546
|
height={visibleRows + 1}
|
|
585
547
|
paddingX={1}
|
|
548
|
+
{...detailPaneBorderProps(focus)}
|
|
586
549
|
overflow="hidden"
|
|
587
550
|
>
|
|
588
|
-
{
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
<Box>
|
|
594
|
-
{following && (
|
|
595
|
-
<Text color={theme.success} bold>
|
|
596
|
-
{" "}
|
|
597
|
-
FOLLOWING{" "}
|
|
598
|
-
</Text>
|
|
599
|
-
)}
|
|
600
|
-
<Text dimColor>
|
|
601
|
-
s search · f filter · ↑↓ select · j/k scroll · d delete ·
|
|
602
|
-
{selectedThread && !selectedThread.ended_at ? " w follow ·" : ""}{" "}
|
|
603
|
-
r refresh · [{detailScroll + 1}–
|
|
604
|
-
{Math.min(detailScroll + visibleRows, detailLines.length)} of{" "}
|
|
605
|
-
{detailLines.length}]
|
|
606
|
-
</Text>
|
|
607
|
-
</Box>
|
|
551
|
+
{selectedThread && (
|
|
552
|
+
<ThreadDetailHeader
|
|
553
|
+
thread={selectedThread}
|
|
554
|
+
isActiveThread={isActiveSelected}
|
|
555
|
+
/>
|
|
608
556
|
)}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
{
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
s search · f filter · ↑↓ select · d delete ·
|
|
620
|
-
{selectedThread && !selectedThread.ended_at ? " w follow ·" : ""}{" "}
|
|
621
|
-
r refresh
|
|
622
|
-
</Text>
|
|
557
|
+
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
|
558
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
559
|
+
{detailVisible.map((line, i) => {
|
|
560
|
+
const lineNum = detailScroll + i;
|
|
561
|
+
return (
|
|
562
|
+
<Text key={lineNum} wrap="truncate-end">
|
|
563
|
+
{line || " "}
|
|
564
|
+
</Text>
|
|
565
|
+
);
|
|
566
|
+
})}
|
|
623
567
|
</Box>
|
|
624
|
-
|
|
568
|
+
<Scrollbar
|
|
569
|
+
total={detailLines.length}
|
|
570
|
+
visible={visibleRows - 3}
|
|
571
|
+
offset={detailScroll}
|
|
572
|
+
height={visibleRows - 3}
|
|
573
|
+
focused={focus === "detail"}
|
|
574
|
+
/>
|
|
575
|
+
</Box>
|
|
576
|
+
<Box>
|
|
577
|
+
{following && (
|
|
578
|
+
<Text color={theme.success} bold>
|
|
579
|
+
{" "}
|
|
580
|
+
FOLLOWING{" "}
|
|
581
|
+
</Text>
|
|
582
|
+
)}
|
|
583
|
+
<Text dimColor>
|
|
584
|
+
{focus === "detail"
|
|
585
|
+
? "↑↓ scroll · ⇧↑↓ page · g/G top/bot · ← back to list"
|
|
586
|
+
: `↑↓ select · → enter detail · s search · f filter · d delete${selectedThread && !selectedThread.ended_at ? " · w follow" : ""} · r refresh`}
|
|
587
|
+
</Text>
|
|
588
|
+
</Box>
|
|
625
589
|
</Box>
|
|
626
590
|
</Box>
|
|
627
591
|
);
|
|
628
592
|
});
|
|
593
|
+
|
|
594
|
+
function ThreadDetailHeader({
|
|
595
|
+
thread,
|
|
596
|
+
isActiveThread,
|
|
597
|
+
}: {
|
|
598
|
+
thread: Thread;
|
|
599
|
+
isActiveThread: boolean;
|
|
600
|
+
}) {
|
|
601
|
+
return (
|
|
602
|
+
<Box flexDirection="column">
|
|
603
|
+
<Box>
|
|
604
|
+
<Text wrap="truncate-end">
|
|
605
|
+
<Text bold italic color={theme.info}>
|
|
606
|
+
{thread.title || "(untitled)"}
|
|
607
|
+
</Text>
|
|
608
|
+
{isActiveThread && (
|
|
609
|
+
<Text bold color={theme.success}>
|
|
610
|
+
{" ★"}
|
|
611
|
+
</Text>
|
|
612
|
+
)}
|
|
613
|
+
</Text>
|
|
614
|
+
</Box>
|
|
615
|
+
<Box>
|
|
616
|
+
<Text wrap="truncate-end">
|
|
617
|
+
<Text color={TYPE_COLORS[thread.type]}>
|
|
618
|
+
{TYPE_ICONS[thread.type]} {TYPE_LABELS[thread.type]}
|
|
619
|
+
</Text>
|
|
620
|
+
<Text dimColor>
|
|
621
|
+
{" · started "}
|
|
622
|
+
{formatDate(thread.started_at)}
|
|
623
|
+
{" · "}
|
|
624
|
+
</Text>
|
|
625
|
+
{thread.ended_at ? (
|
|
626
|
+
<Text dimColor>ended {formatDate(thread.ended_at)}</Text>
|
|
627
|
+
) : (
|
|
628
|
+
<Text color={theme.success}>ongoing</Text>
|
|
629
|
+
)}
|
|
630
|
+
<Text dimColor>
|
|
631
|
+
{" · "}
|
|
632
|
+
{formatDuration(thread.started_at, thread.ended_at)}
|
|
633
|
+
</Text>
|
|
634
|
+
</Text>
|
|
635
|
+
</Box>
|
|
636
|
+
<Box>
|
|
637
|
+
<Text dimColor>{"─".repeat(2)}</Text>
|
|
638
|
+
</Box>
|
|
639
|
+
</Box>
|
|
640
|
+
);
|
|
641
|
+
}
|