shraga 0.1.89 → 0.1.91
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/defaults/scripts/backfill-slack-usernames.ts +5 -0
- package/dist/client/assets/index-B--gTLyT.css +10 -0
- package/dist/client/assets/{index-B-8NBGtJ.js → index-C3l1Wz_q.js} +234 -234
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/client/App.tsx +0 -2
- package/src/client/components/ChatView.tsx +78 -18
- package/src/client/components/ConversationHeader.tsx +7 -4
- package/src/client/components/Sidebar.tsx +91 -100
- package/src/client/hooks/useSessionList.ts +169 -0
- package/src/server/boot.ts +37 -2
- package/src/server/claude.ts +14 -2
- package/src/server/data-sync.ts +43 -3
- package/src/server/directives.ts +28 -7
- package/src/server/engine/index.ts +17 -0
- package/src/server/sessions.ts +167 -10
- package/dist/client/assets/index-J2NH6FvE.css +0 -10
package/dist/client/index.html
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-C3l1Wz_q.js"></script>
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B--gTLyT.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
20
20
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.91",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"ws": "^8.18.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
|
+
"@happy-dom/global-registrator": "^20.14.0",
|
|
85
86
|
"@types/bun": "latest",
|
|
86
87
|
"@types/express": "^5.0.0",
|
|
87
88
|
"@types/react": "^19.0.0",
|
package/src/client/App.tsx
CHANGED
|
@@ -358,8 +358,6 @@ function AppInner() {
|
|
|
358
358
|
refreshKey={sidebarRefresh}
|
|
359
359
|
workspaceRefreshKey={workspaceRefreshKey}
|
|
360
360
|
onRefreshWorkspace={refreshWorkspace}
|
|
361
|
-
userUid={user.uid}
|
|
362
|
-
userEmail={user.email || ''}
|
|
363
361
|
unreads={unreads}
|
|
364
362
|
busySessions={busySessions}
|
|
365
363
|
socket={socket}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import ReactMarkdown from 'react-markdown';
|
|
3
3
|
import remarkGfm from 'remark-gfm';
|
|
4
4
|
import rehypeHighlight from 'rehype-highlight';
|
|
@@ -84,6 +84,9 @@ interface Props {
|
|
|
84
84
|
|
|
85
85
|
const SHOW_DETAILS_KEY = 'shraga:showDetails';
|
|
86
86
|
|
|
87
|
+
/** Messages mounted by default; older ones stay out of the DOM behind a "show earlier" button. */
|
|
88
|
+
const VISIBLE_MESSAGES = 150;
|
|
89
|
+
|
|
87
90
|
export function ChatView({ messages, busy, connectionStatus, onPermissionRespond, onQuestionRespond, onReplay, onEdit, onFork, multiParticipant, statusItems }: Props) {
|
|
88
91
|
const slots = useSlots();
|
|
89
92
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
@@ -92,17 +95,46 @@ export function ChatView({ messages, busy, connectionStatus, onPermissionRespond
|
|
|
92
95
|
const [showDetails, setShowDetails] = useState(() => localStorage.getItem(SHOW_DETAILS_KEY) === 'true');
|
|
93
96
|
const [lightbox, setLightbox] = useState<string | null>(null);
|
|
94
97
|
|
|
98
|
+
// Callers pass fresh closures every render (`onFork={(i) => …}`, handlers bound to a new `conv`
|
|
99
|
+
// object). Route them through a ref so MessageRow's props stay reference-stable.
|
|
100
|
+
const cbs = useRef({ onPermissionRespond, onQuestionRespond, onReplay, onEdit, onFork });
|
|
101
|
+
cbs.current = { onPermissionRespond, onQuestionRespond, onReplay, onEdit, onFork };
|
|
102
|
+
const permissionRespond = useCallback((id: string, allow: boolean, allowAll?: boolean) => cbs.current.onPermissionRespond?.(id, allow, allowAll), []);
|
|
103
|
+
const questionRespond = useCallback((id: string, answers: QuestionAnswers) => cbs.current.onQuestionRespond?.(id, answers), []);
|
|
104
|
+
const replay = useCallback((id: string, text: string, att?: Attachment[]) => cbs.current.onReplay?.(id, text, att), []);
|
|
105
|
+
const edit = useCallback((id: string, text: string, att?: Attachment[]) => cbs.current.onEdit?.(id, text, att), []);
|
|
106
|
+
const fork = useCallback((idx: number) => cbs.current.onFork?.(idx), []);
|
|
107
|
+
|
|
95
108
|
const handleScroll = () => {
|
|
96
109
|
const el = scrollRef.current;
|
|
97
110
|
if (!el) return;
|
|
98
111
|
isNearBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
99
112
|
};
|
|
100
113
|
|
|
101
|
-
|
|
114
|
+
// Built INCREMENTALLY into a stable Map: a full rebuild JSON.parsed every tool_result in the
|
|
115
|
+
// thread on every streamed token. Identity never changes, so it can't break MessageRow's memo —
|
|
116
|
+
// but that cuts both ways: a row whose tool_use renders a screen filled in by a LATER tool_result
|
|
117
|
+
// would stay memo-blocked on stale content forever. `screenVersion` bumps only when the map
|
|
118
|
+
// actually changes, and is passed down purely so memo sees a changed prop then (and only then).
|
|
119
|
+
const screenMapRef = useRef<Map<string, string[]>>(new Map());
|
|
120
|
+
const scannedBlocks = useRef<WeakSet<object>>(new WeakSet());
|
|
121
|
+
const screenVersionRef = useRef(0);
|
|
122
|
+
const screenVersion = useMemo(() => {
|
|
123
|
+
if (updateScreenMap(screenMapRef.current, scannedBlocks.current, messages)) screenVersionRef.current++;
|
|
124
|
+
return screenVersionRef.current;
|
|
125
|
+
}, [messages]);
|
|
126
|
+
const screenMap = screenMapRef.current;
|
|
127
|
+
|
|
128
|
+
// Only the last slice is mounted — an agent thread runs to thousands of blocks and the DOM
|
|
129
|
+
// (not React) is what goes sluggish. `showAll` mounts the rest on demand.
|
|
130
|
+
const [showAll, setShowAll] = useState(false);
|
|
131
|
+
const hiddenCount = showAll ? 0 : Math.max(0, messages.length - VISIBLE_MESSAGES);
|
|
132
|
+
const visibleMessages = hiddenCount > 0 ? messages.slice(hiddenCount) : messages;
|
|
102
133
|
|
|
103
134
|
useEffect(() => {
|
|
104
135
|
if (isNearBottom.current) {
|
|
105
|
-
|
|
136
|
+
// Smooth scrolling on every streamed token is a per-frame layout cost; snap while busy.
|
|
137
|
+
bottomRef.current?.scrollIntoView({ behavior: busy ? 'auto' : 'smooth' });
|
|
106
138
|
}
|
|
107
139
|
}, [messages, busy]);
|
|
108
140
|
|
|
@@ -138,9 +170,19 @@ export function ChatView({ messages, busy, connectionStatus, onPermissionRespond
|
|
|
138
170
|
Details
|
|
139
171
|
</button>
|
|
140
172
|
</div>
|
|
141
|
-
{
|
|
142
|
-
<
|
|
143
|
-
|
|
173
|
+
{hiddenCount > 0 && (
|
|
174
|
+
<div className="flex justify-center pb-2">
|
|
175
|
+
<button onClick={() => setShowAll(true)} className="text-xs px-3 py-1 rounded-full border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
176
|
+
Show {hiddenCount} earlier message{hiddenCount === 1 ? '' : 's'}
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
{visibleMessages.map((msg, i) => {
|
|
181
|
+
const idx = hiddenCount + i;
|
|
182
|
+
return (
|
|
183
|
+
<MessageRow key={msg.id} message={msg} messageIndex={idx} isLast={idx === messages.length - 1} showDetails={showDetails} onPermissionRespond={permissionRespond} onQuestionRespond={questionRespond} onReplay={replay} onEdit={edit} onFork={fork} onImageClick={setLightbox} busy={busy && idx === messages.length - 1} screenMap={screenMap} screenVersion={screenVersion} multiParticipant={multiParticipant} />
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
144
186
|
|
|
145
187
|
{busy && messages[messages.length - 1]?.role !== 'assistant' && (
|
|
146
188
|
<div className="flex gap-3 py-4">
|
|
@@ -188,7 +230,7 @@ export function ChatView({ messages, busy, connectionStatus, onPermissionRespond
|
|
|
188
230
|
);
|
|
189
231
|
}
|
|
190
232
|
|
|
191
|
-
function MessageRow({
|
|
233
|
+
const MessageRow = memo(function MessageRow({
|
|
192
234
|
message,
|
|
193
235
|
messageIndex,
|
|
194
236
|
isLast,
|
|
@@ -215,6 +257,8 @@ function MessageRow({
|
|
|
215
257
|
onImageClick?: (src: string) => void;
|
|
216
258
|
busy?: boolean;
|
|
217
259
|
screenMap?: Map<string, string[]>;
|
|
260
|
+
/** Not read here — declared so memo() re-renders the row when screenMap's CONTENTS change. */
|
|
261
|
+
screenVersion?: number;
|
|
218
262
|
multiParticipant?: boolean;
|
|
219
263
|
}) {
|
|
220
264
|
const compactBlock = message.blocks.find((b) => b.type === 'compact_marker');
|
|
@@ -359,7 +403,7 @@ function MessageRow({
|
|
|
359
403
|
</div>
|
|
360
404
|
</div>
|
|
361
405
|
);
|
|
362
|
-
}
|
|
406
|
+
});
|
|
363
407
|
|
|
364
408
|
function CompactMarkerDivider({ summary, compactedCount }: { summary: string; compactedCount: number }) {
|
|
365
409
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -507,20 +551,30 @@ function ThinkingBlock({ text }: { text: string }) {
|
|
|
507
551
|
);
|
|
508
552
|
}
|
|
509
553
|
|
|
510
|
-
|
|
511
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Fold any new pty-screen tool_results into `map` in place; returns true if anything was written.
|
|
556
|
+
* Each block is parsed at most once
|
|
557
|
+
* (tracked by object identity) and only when its text can plausibly hold a screen — a blind
|
|
558
|
+
* JSON.parse of every result in the thread, on every token, was the streaming hot spot.
|
|
559
|
+
*/
|
|
560
|
+
function updateScreenMap(map: Map<string, string[]>, scanned: WeakSet<object>, messages: ChatMessage[]): boolean {
|
|
561
|
+
let changed = false;
|
|
512
562
|
for (const msg of messages) {
|
|
513
563
|
for (const block of msg.blocks) {
|
|
514
564
|
if (block.type !== 'tool_result' || !block.output) continue;
|
|
565
|
+
if (scanned.has(block)) continue;
|
|
566
|
+
scanned.add(block);
|
|
567
|
+
if (!block.output.includes('"screen"')) continue;
|
|
515
568
|
try {
|
|
516
569
|
const parsed = JSON.parse(block.output);
|
|
517
570
|
if (typeof parsed?.sessionId === 'string' && Array.isArray(parsed?.screen)) {
|
|
518
571
|
map.set(parsed.sessionId, parsed.screen);
|
|
572
|
+
changed = true;
|
|
519
573
|
}
|
|
520
574
|
} catch {}
|
|
521
575
|
}
|
|
522
576
|
}
|
|
523
|
-
return
|
|
577
|
+
return changed;
|
|
524
578
|
}
|
|
525
579
|
|
|
526
580
|
function parseAnswersFromResult(result: string): Record<string, string> {
|
|
@@ -625,16 +679,22 @@ function stripLineNumbers(text: string): string {
|
|
|
625
679
|
|
|
626
680
|
function ToolResultBlock({ output }: { output: string }) {
|
|
627
681
|
const [expanded, setExpanded] = useState(false);
|
|
628
|
-
|
|
629
|
-
const trimmed =
|
|
682
|
+
// Regex + split + JSON.parse over the FULL output; keyed to the text so a re-render is free.
|
|
683
|
+
const { trimmed, isError, chartData, isLong, preview } = useMemo(() => {
|
|
684
|
+
const cleaned = output.trim().replace(/\[Image #\d+\]\s*/g, '').trim();
|
|
685
|
+
const t = stripLineNumbers(cleaned);
|
|
686
|
+
const err = /^(Error|ERROR)|"status"\s*:\s*[45]\d\d|ENOENT|EACCES|Permission denied|command not found|No such file/i.test(t);
|
|
687
|
+
return {
|
|
688
|
+
trimmed: t,
|
|
689
|
+
isError: err,
|
|
690
|
+
chartData: err ? null : tryParseChartData(t),
|
|
691
|
+
isLong: t.length > 200,
|
|
692
|
+
preview: t.slice(0, 120).replace(/\n/g, ' '),
|
|
693
|
+
};
|
|
694
|
+
}, [output]);
|
|
630
695
|
|
|
631
696
|
if (!trimmed) return null;
|
|
632
697
|
|
|
633
|
-
const isError = /^(Error|ERROR)|"status"\s*:\s*[45]\d\d|ENOENT|EACCES|Permission denied|command not found|No such file/i.test(trimmed);
|
|
634
|
-
const chartData = !isError ? tryParseChartData(trimmed) : null;
|
|
635
|
-
const isLong = trimmed.length > 200;
|
|
636
|
-
const preview = trimmed.slice(0, 120).replace(/\n/g, ' ');
|
|
637
|
-
|
|
638
698
|
const Icon = isError ? XCircle : Check;
|
|
639
699
|
const borderCls = isError ? 'border-red-200 dark:border-red-900' : 'border-green-200 dark:border-green-900';
|
|
640
700
|
const bgCls = isError
|
|
@@ -33,11 +33,14 @@ export function deriveRuntimeBadges(input: {
|
|
|
33
33
|
// other than where this session currently asks to run.
|
|
34
34
|
const engineMismatch = input.actualEngine && input.actualEngine !== requestedEngine ? requestedEngine : undefined;
|
|
35
35
|
const engineIsNative = engine === 'claude-code' || engine === 'cursor';
|
|
36
|
-
//
|
|
36
|
+
// "The pair, or neither" is right for the ENGINE, not for the provider: a `provider/` prefix on the
|
|
37
|
+
// recorded model is direct evidence of what actually ran and was billed, and needs no engine to read.
|
|
38
|
+
// Only a BARE recorded id is unreadable alone (it belongs to whichever engine recorded it), so only
|
|
39
|
+
// that one is dropped. Sessions written before lastEngine existed carry a prefixed model and nothing
|
|
40
|
+
// else — dropping it made the UI report the requested provider, the very claim this must never make.
|
|
41
|
+
const recordedModel = input.actualEngine || input.actualModel?.includes('/') ? input.actualModel : undefined;
|
|
37
42
|
const rawModel =
|
|
38
|
-
|
|
39
|
-
input.requestedModel ||
|
|
40
|
-
(engine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
|
|
43
|
+
recordedModel || input.requestedModel || (engine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
|
|
41
44
|
// Provider = the model's prefix; a bare id belongs to the engine that ran it (claude-code ⇒ anthropic,
|
|
42
45
|
// an add-on engine ⇒ that engine's own provider) — never assume anthropic just because a prefix is absent.
|
|
43
46
|
const billingProvider = rawModel.includes('/') ? rawModel.split('/')[0] : engine === 'claude-code' ? 'anthropic' : engine;
|
|
@@ -8,30 +8,7 @@ import { useSlots } from '@/lib/slots';
|
|
|
8
8
|
import { MachineStats } from './MachineStats';
|
|
9
9
|
import type { UnreadSession } from '@/hooks/useUnread';
|
|
10
10
|
import type { AgentSocket } from '@/lib/ws';
|
|
11
|
-
|
|
12
|
-
interface Session {
|
|
13
|
-
sessionId: string;
|
|
14
|
-
title: string;
|
|
15
|
-
userEmail: string;
|
|
16
|
-
userName: string;
|
|
17
|
-
uid: string;
|
|
18
|
-
createdAt: number;
|
|
19
|
-
lastModified: number;
|
|
20
|
-
scope?: 'system' | 'user';
|
|
21
|
-
visibleTo?: string[];
|
|
22
|
-
slackContext?: { type: 'dm' | 'channel' | 'mention'; channelName?: string; userName?: string };
|
|
23
|
-
runStatus?: 'running' | 'idle';
|
|
24
|
-
lastStopReason?: 'max_turns_reached' | 'error' | 'aborted';
|
|
25
|
-
scheduleRunStatus?: 'running' | 'ok' | 'error' | 'aborted';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
type ChatsFilter = 'mine' | 'all';
|
|
29
|
-
|
|
30
|
-
function isMine(s: Session, uid: string, email: string): boolean {
|
|
31
|
-
if (s.uid === uid) return true;
|
|
32
|
-
if (s.visibleTo?.includes(email.toLowerCase())) return true;
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
11
|
+
import { useSessionList, type SessionRow as Session, type ChatsFilter } from '@/hooks/useSessionList';
|
|
35
12
|
|
|
36
13
|
interface Props {
|
|
37
14
|
getToken: () => Promise<string | null>;
|
|
@@ -41,8 +18,6 @@ interface Props {
|
|
|
41
18
|
refreshKey?: number;
|
|
42
19
|
workspaceRefreshKey?: number;
|
|
43
20
|
onRefreshWorkspace: () => void;
|
|
44
|
-
userUid: string;
|
|
45
|
-
userEmail: string;
|
|
46
21
|
unreads?: Record<string, UnreadSession>;
|
|
47
22
|
busySessions?: Set<string>;
|
|
48
23
|
socket?: AgentSocket | null;
|
|
@@ -80,16 +55,25 @@ function slackLabel(s: Session): string | null {
|
|
|
80
55
|
return null;
|
|
81
56
|
}
|
|
82
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Unread rows the current scope should show. Exported so the scoping is testable on its own: it is
|
|
60
|
+
* what the old client-side `scopeFiltered`/`visibleUnreadCount` did, moved onto the server-stamped
|
|
61
|
+
* `mine` flag because the trimmed list row no longer carries uid/visibleTo.
|
|
62
|
+
*/
|
|
63
|
+
export function scopeUnread(unreads: Record<string, unknown>, byId: Map<string, Session>, filter: ChatsFilter): Session[] {
|
|
64
|
+
return Object.keys(unreads)
|
|
65
|
+
.map((id) => byId.get(id))
|
|
66
|
+
.filter((s): s is Session => !!s && (filter === 'all' || s.mine !== false));
|
|
67
|
+
}
|
|
68
|
+
|
|
83
69
|
const FILTER_KEY = 'chats-filter';
|
|
84
70
|
const UNREAD_FILTER_KEY = 'chats-unread-filter';
|
|
85
71
|
|
|
86
|
-
export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey, workspaceRefreshKey, onRefreshWorkspace,
|
|
72
|
+
export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey, workspaceRefreshKey, onRefreshWorkspace, unreads = {}, busySessions = new Set(), socket }: Props) {
|
|
87
73
|
const slots = useSlots();
|
|
88
|
-
const [sessions, setSessions] = useState<Session[]>([]);
|
|
89
74
|
const [filter, setFilter] = useState<ChatsFilter>(() => (localStorage.getItem(FILTER_KEY) as ChatsFilter) || 'mine');
|
|
90
75
|
const [unreadOnly, setUnreadOnly] = useState(() => localStorage.getItem(UNREAD_FILTER_KEY) === 'true');
|
|
91
76
|
const [version, setVersion] = useState<string>('');
|
|
92
|
-
const acRef = useRef<AbortController | null>(null);
|
|
93
77
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
94
78
|
|
|
95
79
|
function changeFilter(f: ChatsFilter) {
|
|
@@ -103,46 +87,77 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
|
|
|
103
87
|
localStorage.setItem(UNREAD_FILTER_KEY, String(next));
|
|
104
88
|
}
|
|
105
89
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
[
|
|
90
|
+
// ── Data ───────────────────────────────────────────────────────────────────
|
|
91
|
+
const hydrateIds = useMemo(
|
|
92
|
+
() => [activeSessionId, ...Object.keys(unreads)].filter((id): id is string => !!id),
|
|
93
|
+
[activeSessionId, unreads],
|
|
109
94
|
);
|
|
95
|
+
const { sessions, cursor, loading, byId, loadMore } = useSessionList({ getToken, filter, refreshKey, hydrateIds });
|
|
96
|
+
|
|
97
|
+
// Both the unread VIEW and the unread DOT are scoped by `mine`, which the server stamps on every
|
|
98
|
+
// row (the trimmed list item no longer carries uid/visibleTo, so the client cannot re-derive it).
|
|
99
|
+
// Without this an owner replying in someone else's session lights the dot and lists that row
|
|
100
|
+
// under "mine" — the old client-side scopeFiltered/visibleUnreadCount did scope it.
|
|
101
|
+
// `?ids=` hydration stays UNSCOPED on purpose: the active row must render whatever the filter is.
|
|
102
|
+
const unreadRows = useMemo(() => scopeUnread(unreads, byId, filter), [unreads, byId, filter]);
|
|
103
|
+
const unreadCount = unreadRows.length;
|
|
104
|
+
|
|
105
|
+
const filtered = useMemo(() => {
|
|
106
|
+
if (!unreadOnly) return sessions;
|
|
107
|
+
return [...unreadRows].sort((a, b) => b.lastModified - a.lastModified);
|
|
108
|
+
}, [unreadOnly, unreadRows, sessions]);
|
|
109
|
+
|
|
110
|
+
// The open conversation is ALWAYS rendered: a deep link, an unread toast or any older thread lands
|
|
111
|
+
// outside the window, and a list without the active row highlights nothing and never scrolls to it.
|
|
112
|
+
const activeRow = activeSessionId && !filtered.some((s) => s.sessionId === activeSessionId)
|
|
113
|
+
? byId.get(activeSessionId)
|
|
114
|
+
: undefined;
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const filtered = useMemo(() =>
|
|
117
|
-
unreadOnly ? scopeFiltered.filter((s) => unreads[s.sessionId]) : scopeFiltered,
|
|
118
|
-
[scopeFiltered, unreadOnly, unreads],
|
|
119
|
-
);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (activeRef.current) {
|
|
118
|
+
activeRef.current.scrollIntoView({ block: 'nearest' });
|
|
119
|
+
}
|
|
120
|
+
}, [activeSessionId, filtered, activeRow]);
|
|
120
121
|
|
|
121
122
|
useEffect(() => {
|
|
122
123
|
getToken().then(t => t ? fetch('/api/version', { headers: { Authorization: `Bearer ${t}` } }) : null).then(r => r?.json()).then(d => d && setVersion(d.version)).catch(() => {});
|
|
123
124
|
}, []);
|
|
124
125
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
.
|
|
134
|
-
.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
126
|
+
function renderRow(s: Session) {
|
|
127
|
+
const unread = unreads[s.sessionId];
|
|
128
|
+
const isBusy = busySessions.has(s.sessionId) || s.runStatus === 'running' || s.scheduleRunStatus === 'running';
|
|
129
|
+
const isError = !isBusy && (!!s.lastStopReason || s.scheduleRunStatus === 'error' || s.scheduleRunStatus === 'aborted');
|
|
130
|
+
const borderColor = isBusy ? 'border-amber-500' : isError ? 'border-red-500' : unread ? 'border-blue-500' : '';
|
|
131
|
+
const hasBorder = !!(isBusy || isError || unread);
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
key={s.sessionId}
|
|
135
|
+
ref={s.sessionId === activeSessionId ? activeRef : undefined}
|
|
136
|
+
onClick={() => onSelect(s.sessionId, s.title)}
|
|
137
|
+
className={cn(
|
|
138
|
+
'w-full text-left px-3 py-2.5 text-sm transition-colors hover:bg-accent/50 group',
|
|
139
|
+
hasBorder ? `rounded-r-lg border-l-[3px] ${borderColor}` : 'rounded-lg',
|
|
140
|
+
activeSessionId === s.sessionId && 'bg-accent',
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
<div className="flex items-start gap-2">
|
|
144
|
+
{slackIcon(s)}
|
|
145
|
+
<div className="min-w-0 flex-1">
|
|
146
|
+
<span className={cn('block truncate text-sm leading-snug', unread && 'font-semibold')}>
|
|
147
|
+
{s.title || 'New session'}
|
|
148
|
+
</span>
|
|
149
|
+
<div className="flex items-center gap-1.5 mt-0.5">
|
|
150
|
+
<span className="text-[10px] text-muted-foreground font-medium">
|
|
151
|
+
{slackLabel(s) || s.userName}
|
|
152
|
+
</span>
|
|
153
|
+
<span className="text-[10px] text-muted-foreground/50">·</span>
|
|
154
|
+
<span className="text-[10px] text-muted-foreground">{formatTime(s.lastModified)}</span>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</button>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
146
161
|
|
|
147
162
|
return (
|
|
148
163
|
<div className="flex flex-col h-full bg-muted/30">
|
|
@@ -173,53 +188,29 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
|
|
|
173
188
|
unreadOnly ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-muted-foreground/60 hover:text-muted-foreground',
|
|
174
189
|
)}
|
|
175
190
|
>
|
|
176
|
-
unread{
|
|
191
|
+
unread{unreadCount > 0 && <span className="inline-block w-1.5 h-1.5 ml-1 rounded-full bg-blue-500 align-middle" />}
|
|
177
192
|
</button>
|
|
178
193
|
</div>
|
|
179
194
|
</div>
|
|
180
195
|
|
|
181
196
|
<ScrollArea className="flex-1">
|
|
182
197
|
<div className="px-2 pb-2 space-y-0.5">
|
|
183
|
-
{filtered.length === 0 && (
|
|
198
|
+
{filtered.length === 0 && !activeRow && (
|
|
184
199
|
<p className="text-xs text-muted-foreground px-3 py-6 text-center">
|
|
185
|
-
{
|
|
200
|
+
{loading ? 'Loading…' : unreadOnly ? 'No unread conversations' : 'No conversations yet'}
|
|
186
201
|
</p>
|
|
187
202
|
)}
|
|
188
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
className={cn(
|
|
200
|
-
'w-full text-left px-3 py-2.5 text-sm transition-colors hover:bg-accent/50 group',
|
|
201
|
-
hasBorder ? `rounded-r-lg border-l-[3px] ${borderColor}` : 'rounded-lg',
|
|
202
|
-
activeSessionId === s.sessionId && 'bg-accent',
|
|
203
|
-
)}
|
|
204
|
-
>
|
|
205
|
-
<div className="flex items-start gap-2">
|
|
206
|
-
{slackIcon(s)}
|
|
207
|
-
<div className="min-w-0 flex-1">
|
|
208
|
-
<span className={cn('block truncate text-sm leading-snug', unread && 'font-semibold')}>
|
|
209
|
-
{s.title || 'New session'}
|
|
210
|
-
</span>
|
|
211
|
-
<div className="flex items-center gap-1.5 mt-0.5">
|
|
212
|
-
<span className="text-[10px] text-muted-foreground font-medium">
|
|
213
|
-
{slackLabel(s) || s.userName}
|
|
214
|
-
</span>
|
|
215
|
-
<span className="text-[10px] text-muted-foreground/50">·</span>
|
|
216
|
-
<span className="text-[10px] text-muted-foreground">{formatTime(s.lastModified)}</span>
|
|
217
|
-
</div>
|
|
218
|
-
</div>
|
|
219
|
-
</div>
|
|
220
|
-
</button>
|
|
221
|
-
);
|
|
222
|
-
})}
|
|
203
|
+
{activeRow && renderRow(activeRow)}
|
|
204
|
+
{filtered.map(renderRow)}
|
|
205
|
+
{!unreadOnly && cursor && (
|
|
206
|
+
<button
|
|
207
|
+
onClick={loadMore}
|
|
208
|
+
disabled={loading}
|
|
209
|
+
className="w-full text-[11px] text-muted-foreground hover:text-foreground py-2 rounded-lg hover:bg-accent/50 transition-colors disabled:opacity-50"
|
|
210
|
+
>
|
|
211
|
+
{loading ? 'Loading…' : 'Show older'}
|
|
212
|
+
</button>
|
|
213
|
+
)}
|
|
223
214
|
</div>
|
|
224
215
|
</ScrollArea>
|
|
225
216
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Mirrors the server's SessionListItem — the trimmed row shape /api/sessions returns. */
|
|
4
|
+
export interface SessionRow {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
title: string;
|
|
7
|
+
userName: string;
|
|
8
|
+
lastModified: number;
|
|
9
|
+
slackContext?: { type: 'dm' | 'channel' | 'mention'; channelName?: string; userName?: string };
|
|
10
|
+
runStatus?: 'running' | 'idle';
|
|
11
|
+
lastStopReason?: 'max_turns_reached' | 'error' | 'aborted';
|
|
12
|
+
scheduleRunStatus?: 'running' | 'ok' | 'error' | 'aborted';
|
|
13
|
+
/** Server-stamped: owned by (or explicitly shared with) the caller. */
|
|
14
|
+
mine?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SessionPage {
|
|
18
|
+
sessions: SessionRow[];
|
|
19
|
+
nextCursor: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ChatsFilter = 'mine' | 'all';
|
|
23
|
+
|
|
24
|
+
/** Chat rows fetched per request — the list is unbounded and every row is real DOM. */
|
|
25
|
+
export const PAGE_SIZE = 50;
|
|
26
|
+
|
|
27
|
+
interface Options {
|
|
28
|
+
getToken: () => Promise<string | null>;
|
|
29
|
+
filter: ChatsFilter;
|
|
30
|
+
refreshKey?: number;
|
|
31
|
+
/** Ids that must have a row even when they sit outside the loaded window (active + unread). */
|
|
32
|
+
hydrateIds: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The conversation list's paging/refresh state machine, kept out of the component so it can be
|
|
37
|
+
* driven directly by a test. /api/sessions is paged and trimmed (it used to ship the whole 6.76 MB
|
|
38
|
+
* index); `sessions` is the loaded window, newest first, scoped to `filter` by the SERVER — `mine`
|
|
39
|
+
* is a predicate over the whole 12k index and cannot be honestly evaluated inside one page.
|
|
40
|
+
*/
|
|
41
|
+
export function useSessionList({ getToken, filter, refreshKey, hydrateIds }: Options) {
|
|
42
|
+
const [sessions, setSessions] = useState<SessionRow[]>([]);
|
|
43
|
+
const [cursor, setCursor] = useState<string | null>(null);
|
|
44
|
+
const [loading, setLoading] = useState(false);
|
|
45
|
+
// Rows fetched by id because they sit OUTSIDE the loaded window: the active conversation, and any
|
|
46
|
+
// unread one. The unread map arrives complete over the socket (`unread_sync`), so hydrating by id
|
|
47
|
+
// is what keeps the unread filter from silently searching only the first page.
|
|
48
|
+
const [extras, setExtras] = useState<Record<string, SessionRow>>({});
|
|
49
|
+
const requestedRef = useRef<Set<string>>(new Set());
|
|
50
|
+
const acRef = useRef<AbortController | null>(null);
|
|
51
|
+
const moreAcRef = useRef<AbortController | null>(null);
|
|
52
|
+
// Read inside async resolves, where the captured `filter` may already be stale.
|
|
53
|
+
const filterRef = useRef(filter);
|
|
54
|
+
filterRef.current = filter;
|
|
55
|
+
// How many rows the user has actually paged into view. A refresh must reload THIS much, not just
|
|
56
|
+
// page 1: `session_messages_changed` is broadcast to EVERY socket (any Slack message, any
|
|
57
|
+
// scheduler run, anyone's turn ending), so on a busy box a page-1 reset fires near-continuously
|
|
58
|
+
// and collapses a user who clicked "Show older" five times back to 50 rows.
|
|
59
|
+
const depthRef = useRef(PAGE_SIZE);
|
|
60
|
+
// Reset by the consumer when the scope changes — a different scope is a different list.
|
|
61
|
+
const prevFilter = useRef(filter);
|
|
62
|
+
if (prevFilter.current !== filter) { prevFilter.current = filter; depthRef.current = PAGE_SIZE; }
|
|
63
|
+
|
|
64
|
+
const fetchSessions = useCallback(async (query: Record<string, string>, signal?: AbortSignal): Promise<SessionPage | null> => {
|
|
65
|
+
const token = await getToken();
|
|
66
|
+
if (!token || signal?.aborted) return null;
|
|
67
|
+
const r = await fetch(`/api/sessions?${new URLSearchParams(query)}`, { signal, headers: { Authorization: `Bearer ${token}` } });
|
|
68
|
+
if (!r.ok) return null;
|
|
69
|
+
const page = (await r.json()) as SessionPage;
|
|
70
|
+
return Array.isArray(page?.sessions) ? page : null;
|
|
71
|
+
}, [getToken]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Re-read the first `depth` rows of the current scope, walking as many server pages as that takes
|
|
75
|
+
* (the server caps `limit`, so a depth beyond one page is a short sequential walk — bounded by
|
|
76
|
+
* what the user actually paged in, never by the 12k index).
|
|
77
|
+
*/
|
|
78
|
+
const fetchWindow = useCallback(async (depth: number, f: ChatsFilter, signal: AbortSignal): Promise<SessionPage | null> => {
|
|
79
|
+
const acc: SessionRow[] = [];
|
|
80
|
+
let before: string | undefined;
|
|
81
|
+
let nextCursor: string | null = null;
|
|
82
|
+
while (acc.length < depth) {
|
|
83
|
+
const q: Record<string, string> = { filter: f, limit: String(Math.min(PAGE_SIZE, depth - acc.length)) };
|
|
84
|
+
if (before) q.before = before;
|
|
85
|
+
const page = await fetchSessions(q, signal);
|
|
86
|
+
if (!page) return null;
|
|
87
|
+
acc.push(...page.sessions);
|
|
88
|
+
nextCursor = page.nextCursor;
|
|
89
|
+
if (!nextCursor || !page.sessions.length) break;
|
|
90
|
+
before = nextCursor;
|
|
91
|
+
}
|
|
92
|
+
return { sessions: acc, nextCursor };
|
|
93
|
+
}, [fetchSessions]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
acRef.current?.abort();
|
|
97
|
+
moreAcRef.current?.abort(); // a "Show older" in flight belongs to the list we are replacing
|
|
98
|
+
const ac = new AbortController();
|
|
99
|
+
acRef.current = ac;
|
|
100
|
+
setLoading(true);
|
|
101
|
+
// Hydrated rows are re-read too: `extras` is fetch-time data (title, lastModified, runStatus)
|
|
102
|
+
// and without this a by-id row keeps its first snapshot for the life of the page. Cleared HERE,
|
|
103
|
+
// synchronously at commit, not in the fetch's .then() — doing it on resolve lost the mount race
|
|
104
|
+
// against the by-id hydration and left the deep-linked active session with no row at all.
|
|
105
|
+
requestedRef.current.clear();
|
|
106
|
+
setExtras({});
|
|
107
|
+
fetchWindow(depthRef.current, filter, ac.signal)
|
|
108
|
+
.then((page) => {
|
|
109
|
+
if (!page || ac.signal.aborted) return;
|
|
110
|
+
setSessions(page.sessions);
|
|
111
|
+
setCursor(page.nextCursor);
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {})
|
|
114
|
+
.finally(() => { if (!ac.signal.aborted) setLoading(false); });
|
|
115
|
+
return () => ac.abort();
|
|
116
|
+
}, [fetchWindow, filter, refreshKey]);
|
|
117
|
+
|
|
118
|
+
const loadMore = useCallback(() => {
|
|
119
|
+
if (!cursor || loading) return;
|
|
120
|
+
moreAcRef.current?.abort();
|
|
121
|
+
const ac = new AbortController();
|
|
122
|
+
moreAcRef.current = ac;
|
|
123
|
+
const forFilter = filterRef.current; // resolving under a different scope must not append
|
|
124
|
+
setLoading(true);
|
|
125
|
+
fetchSessions({ filter: forFilter, limit: String(PAGE_SIZE), before: cursor }, ac.signal)
|
|
126
|
+
.then((page) => {
|
|
127
|
+
if (!page || ac.signal.aborted || forFilter !== filterRef.current) return;
|
|
128
|
+
setSessions((prev) => {
|
|
129
|
+
const have = new Set(prev.map((s) => s.sessionId));
|
|
130
|
+
const next = [...prev, ...page.sessions.filter((s) => !have.has(s.sessionId))];
|
|
131
|
+
depthRef.current = Math.max(PAGE_SIZE, next.length);
|
|
132
|
+
return next;
|
|
133
|
+
});
|
|
134
|
+
setCursor(page.nextCursor);
|
|
135
|
+
})
|
|
136
|
+
.catch(() => {})
|
|
137
|
+
.finally(() => { if (!ac.signal.aborted) setLoading(false); });
|
|
138
|
+
}, [cursor, loading, fetchSessions]);
|
|
139
|
+
|
|
140
|
+
const byId = useMemo(() => {
|
|
141
|
+
const m = new Map<string, SessionRow>();
|
|
142
|
+
for (const s of Object.values(extras)) m.set(s.sessionId, s);
|
|
143
|
+
for (const s of sessions) m.set(s.sessionId, s); // a paged row wins over a hydrated one
|
|
144
|
+
return m;
|
|
145
|
+
}, [sessions, extras]);
|
|
146
|
+
|
|
147
|
+
// Hydrate the ids the loaded window doesn't cover. `requestedRef` stops this looping on an id the
|
|
148
|
+
// server never returns (e.g. a deleted session still in the unread map).
|
|
149
|
+
const hydrateKey = hydrateIds.join(',');
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const want = hydrateIds.filter((id) => !!id && !byId.has(id) && !requestedRef.current.has(id)).slice(0, 200);
|
|
152
|
+
if (!want.length) return;
|
|
153
|
+
for (const id of want) requestedRef.current.add(id);
|
|
154
|
+
let cancelled = false;
|
|
155
|
+
fetchSessions({ ids: want.join(',') })
|
|
156
|
+
.then((page) => {
|
|
157
|
+
if (!page || cancelled) return;
|
|
158
|
+
setExtras((prev) => {
|
|
159
|
+
const next = { ...prev };
|
|
160
|
+
for (const s of page.sessions) next[s.sessionId] = s;
|
|
161
|
+
return next;
|
|
162
|
+
});
|
|
163
|
+
})
|
|
164
|
+
.catch(() => {});
|
|
165
|
+
return () => { cancelled = true; };
|
|
166
|
+
}, [hydrateKey, byId, fetchSessions]);
|
|
167
|
+
|
|
168
|
+
return { sessions, cursor, loading, byId, loadMore };
|
|
169
|
+
}
|