shraga 0.1.97 → 0.1.98

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,7 +13,7 @@
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-QkJewVCL.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-DPy-vyyF.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-DlgCLI89.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.97",
3
+ "version": "0.1.98",
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",
@@ -127,10 +127,41 @@ export function ChatView({ messages, busy, connectionStatus, onPermissionRespond
127
127
  const screenMap = screenMapRef.current;
128
128
 
129
129
  // Only the last slice is mounted — an agent thread runs to thousands of blocks and the DOM
130
- // (not React) is what goes sluggish. `showAll` mounts the rest on demand.
131
- const [showAll, setShowAll] = useState(false);
132
- const hiddenCount = showAll ? 0 : Math.max(0, messages.length - VISIBLE_MESSAGES);
130
+ // (not React) is what goes sluggish. Scrolling to the top reveals the next slice, so a long
131
+ // thread walks backwards a page at a time instead of mounting 12k rows in one jump.
132
+ const [limit, setLimit] = useState(VISIBLE_MESSAGES);
133
+ const hiddenCount = Math.max(0, messages.length - limit);
133
134
  const visibleMessages = hiddenCount > 0 ? messages.slice(hiddenCount) : messages;
135
+ const showEarlier = useCallback(() => setLimit((n) => n + VISIBLE_MESSAGES), []);
136
+
137
+ const pendingAnchor = useRef<{ box: HTMLDivElement | null; before: number } | null>(null);
138
+ // Reveal-on-scroll, with the button below as the visible fallback. The scroll position is pinned
139
+ // to the same message afterwards: prepending rows above the viewport otherwise yanks the thread.
140
+ const topRef = useRef<HTMLDivElement>(null);
141
+ useEffect(() => {
142
+ const el = topRef.current;
143
+ if (!el || hiddenCount === 0) return;
144
+ const io = new IntersectionObserver((entries) => {
145
+ if (!entries.some((e) => e.isIntersecting)) return;
146
+ // On mount the thread is pinned to the bottom and the top sentinel can be "visible" simply
147
+ // because nothing has scrolled yet — expanding there would cascade the whole 12k thread into
148
+ // the DOM, which is exactly what the window exists to prevent. Only reveal on a real scroll up.
149
+ if (isNearBottom.current) return;
150
+ const box = scrollRef.current;
151
+ const before = box?.scrollHeight ?? 0;
152
+ pendingAnchor.current = { box, before };
153
+ showEarlier();
154
+ }, { rootMargin: '400px' });
155
+ io.observe(el);
156
+ return () => io.disconnect();
157
+ }, [hiddenCount, limit, showEarlier]);
158
+
159
+ useEffect(() => {
160
+ const a = pendingAnchor.current;
161
+ if (!a?.box) return;
162
+ pendingAnchor.current = null;
163
+ a.box.scrollTop += a.box.scrollHeight - a.before; // keep the reader on the same message
164
+ }, [limit]);
134
165
 
135
166
  useEffect(() => {
136
167
  if (isNearBottom.current) {
@@ -172,10 +203,10 @@ export function ChatView({ messages, busy, connectionStatus, onPermissionRespond
172
203
  </button>
173
204
  </div>
174
205
  {hiddenCount > 0 && (
175
- <div className="flex justify-center pb-2">
176
- <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">
177
- Show {hiddenCount} earlier message{hiddenCount === 1 ? '' : 's'}
178
- </button>
206
+ // Scroll-based, like the sidebar: scrolling to the top reveals the previous slice. A
207
+ // status row, not a control — the earlier button read as a dead box at the top of a thread.
208
+ <div ref={topRef} className="py-3 text-center text-[11px] text-muted-foreground/60">
209
+ {hiddenCount} earlier message{hiddenCount === 1 ? '' : 's'} · scroll up to load
179
210
  </div>
180
211
  )}
181
212
  {visibleMessages.map((msg, i) => {
@@ -120,6 +120,26 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
120
120
  }
121
121
  }, [activeSessionId, filtered, activeRow]);
122
122
 
123
+ // Load the next page when the end of the list comes into view. A list that simply stops when you
124
+ // scroll to the bottom reads as broken — the button below stays as the visible fallback, but it
125
+ // is not the mechanism. `root: null` still respects the Radix viewport's clipping, so the sentinel
126
+ // only intersects once the user has actually scrolled near the end.
127
+ const sentinelRef = useRef<HTMLDivElement | null>(null);
128
+ const loadMoreRef = useRef(loadMore);
129
+ loadMoreRef.current = loadMore;
130
+ useEffect(() => {
131
+ const el = sentinelRef.current;
132
+ if (!el || !cursor || unreadOnly) return;
133
+ const io = new IntersectionObserver(
134
+ (entries) => { if (entries.some((e) => e.isIntersecting)) loadMoreRef.current(); },
135
+ { rootMargin: '300px' },
136
+ );
137
+ io.observe(el);
138
+ return () => io.disconnect();
139
+ // `loading` is a dep so the observer re-arms after a page lands: if the end is STILL in view
140
+ // (a short page, a tall sidebar), re-observing fires again and walks on to the next page.
141
+ }, [cursor, loading, unreadOnly]);
142
+
123
143
  useEffect(() => {
124
144
  getToken().then(t => t ? fetch('/api/version', { headers: { Authorization: `Bearer ${t}` } }) : null).then(r => r?.json()).then(d => d && setVersion(d.version)).catch(() => {});
125
145
  }, []);
@@ -212,13 +232,11 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
212
232
  {activeRow && renderRow(activeRow)}
213
233
  {filtered.map(renderRow)}
214
234
  {!unreadOnly && cursor && (
215
- <button
216
- onClick={loadMore}
217
- disabled={loading}
218
- className="w-full text-[11px] text-muted-foreground hover:text-foreground py-2 rounded-lg hover:bg-accent/50 transition-colors disabled:opacity-50"
219
- >
220
- {loading ? 'Loading…' : 'Show older'}
221
- </button>
235
+ // Scroll-based: reaching the end of the list loads the next page. This is a status row,
236
+ // not a control — a "Show older" button read as a dead grey box at the end of the list.
237
+ <div ref={sentinelRef} className="py-3 text-center text-[11px] text-muted-foreground/60">
238
+ {loading ? 'Loading older…' : '···'}
239
+ </div>
222
240
  )}
223
241
  </div>
224
242
  </ScrollArea>