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.
@@ -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-B-8NBGtJ.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-J2NH6FvE.css">
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.89",
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",
@@ -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
- const screenMap = useMemo(() => buildScreenMap(messages), [messages]);
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
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
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
- {messages.map((msg, idx) => (
142
- <MessageRow key={msg.id} message={msg} messageIndex={idx} isLast={idx === messages.length - 1} showDetails={showDetails} onPermissionRespond={onPermissionRespond} onQuestionRespond={onQuestionRespond} onReplay={onReplay} onEdit={onEdit} onFork={onFork} onImageClick={setLightbox} busy={busy && idx === messages.length - 1} screenMap={screenMap} multiParticipant={multiParticipant} />
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
- function buildScreenMap(messages: ChatMessage[]): Map<string, string[]> {
511
- const map = new Map<string, string[]>();
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 map;
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
- const cleaned = output.trim().replace(/\[Image #\d+\]\s*/g, '').trim();
629
- const trimmed = stripLineNumbers(cleaned);
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
- // Only trust the recorded model when we also know which engine recorded it the pair, or neither.
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
- (input.actualEngine ? input.actualModel : undefined) ||
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, userUid, userEmail, unreads = {}, busySessions = new Set(), socket }: Props) {
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
- const scopeFiltered = useMemo(() =>
107
- sessions.filter((s) => filter === 'all' || isMine(s, userUid, userEmail)),
108
- [sessions, filter, userUid, userEmail],
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
- const visibleUnreadCount = useMemo(() =>
112
- scopeFiltered.filter((s) => unreads[s.sessionId]).length,
113
- [scopeFiltered, unreads],
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
- useEffect(() => {
126
- acRef.current?.abort();
127
- const ac = new AbortController();
128
- acRef.current = ac;
129
-
130
- getToken().then((token) => {
131
- if (!token || ac.signal.aborted) return;
132
- fetch('/api/sessions', { signal: ac.signal, headers: { Authorization: `Bearer ${token}` } })
133
- .then((r) => (r.ok ? r.json() : []))
134
- .then((data) => { if (Array.isArray(data)) setSessions(data); })
135
- .catch(() => {});
136
- });
137
-
138
- return () => ac.abort();
139
- }, [getToken, refreshKey]);
140
-
141
- useEffect(() => {
142
- if (activeRef.current) {
143
- activeRef.current.scrollIntoView({ block: 'nearest' });
144
- }
145
- }, [activeSessionId]);
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{visibleUnreadCount > 0 && <span className="inline-block w-1.5 h-1.5 ml-1 rounded-full bg-blue-500 align-middle" />}
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
- {unreadOnly ? 'No unread conversations' : sessions.length === 0 ? 'No conversations yet' : 'No conversations match this filter'}
200
+ {loading ? 'Loading…' : unreadOnly ? 'No unread conversations' : 'No conversations yet'}
186
201
  </p>
187
202
  )}
188
- {filtered.map((s) => {
189
- const unread = unreads[s.sessionId];
190
- const isBusy = busySessions.has(s.sessionId) || s.runStatus === 'running' || s.scheduleRunStatus === 'running';
191
- const isError = !isBusy && (!!s.lastStopReason || s.scheduleRunStatus === 'error' || s.scheduleRunStatus === 'aborted');
192
- const borderColor = isBusy ? 'border-amber-500' : isError ? 'border-red-500' : unread ? 'border-blue-500' : '';
193
- const hasBorder = !!(isBusy || isError || unread);
194
- return (
195
- <button
196
- key={s.sessionId}
197
- ref={s.sessionId === activeSessionId ? activeRef : undefined}
198
- onClick={() => onSelect(s.sessionId, s.title)}
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
+ }