shraga 0.1.97 → 0.1.99

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-BZnO7Mn0.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.99",
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>
@@ -209,6 +209,8 @@ export async function* streamChat(opts: {
209
209
  onPermissionRequest?: PermissionHandler;
210
210
  onDestructiveApproval?: PermissionHandler;
211
211
  onUserQuestion?: QuestionHandler;
212
+ /** See EngineStreamOpts.foregroundBashCommand. */
213
+ foregroundBashCommand?: string;
212
214
  conversationReset?: boolean;
213
215
  /** Opaque per-send bag from the client. Never interpreted here — handed to the turn-context seam,
214
216
  * where an add-on's contributor reads its own keys. */
@@ -401,6 +403,7 @@ export async function* streamChat(opts: {
401
403
  onPermissionRequest: opts.onPermissionRequest,
402
404
  onDestructiveApproval: opts.onDestructiveApproval,
403
405
  onUserQuestion: opts.onUserQuestion,
406
+ foregroundBashCommand: opts.foregroundBashCommand,
404
407
  turnHints: opts.turnHints,
405
408
  conversationReset: opts.conversationReset,
406
409
  context: opts.context,
@@ -268,7 +268,7 @@ export class ClaudeCodeEngine implements AgentEngine {
268
268
  skills: listSkills(),
269
269
  disallowedTools: ['Skill'],
270
270
  agents: loadAgents(),
271
- hooks: buildHooks(),
271
+ hooks: buildHooks({ exemptBashCommand: opts.foregroundBashCommand }),
272
272
  };
273
273
 
274
274
  const userHandler = opts.onPermissionRequest;
@@ -23,6 +23,9 @@ export interface EngineStreamOpts {
23
23
  onPermissionRequest?: PermissionHandler;
24
24
  onDestructiveApproval?: PermissionHandler;
25
25
  onUserQuestion?: QuestionHandler;
26
+ /** The single command this turn was spawned to run in the foreground (a `bash` schedule).
27
+ * Exempted from the long-running-script background guard — see server/hooks.ts. */
28
+ foregroundBashCommand?: string;
26
29
  /** Opaque per-send hints bag, forwarded verbatim from the client. The core interprets no key of it;
27
30
  * an add-on engine reads its own keys (e.g. a duplex engine's `voice` marker). */
28
31
  turnHints?: Record<string, unknown>;
@@ -19,7 +19,7 @@ const LONG_RUNNING_PATTERNS = [
19
19
  * which lets the CLI manage the process asynchronously (the model can
20
20
  * continue working and gets notified when the command finishes).
21
21
  */
22
- const forceBackgroundForScripts: HookCallback = async (input) => {
22
+ const forceBackgroundForScripts = (exemptCommand?: string): HookCallback => async (input) => {
23
23
  if (input.hook_event_name !== 'PreToolUse') return {};
24
24
  const { tool_name, tool_input } = input as PreToolUseHookInput;
25
25
  if (tool_name !== 'Bash') return {};
@@ -27,6 +27,10 @@ const forceBackgroundForScripts: HookCallback = async (input) => {
27
27
  const ti = tool_input as Record<string, unknown>;
28
28
  const cmd = ti.command as string | undefined;
29
29
  if (!cmd || ti.run_in_background) return {};
30
+ // The turn EXISTS to run this one command (a `bash` schedule): there is no other work to
31
+ // continue with, and its exit code is the run's verdict. Backgrounding it only hides that
32
+ // exit code behind a task id, so the guard must not fire on it.
33
+ if (exemptCommand && cmd === exemptCommand) return {};
30
34
 
31
35
  const isLongRunning = LONG_RUNNING_PATTERNS.some(p => p.test(cmd));
32
36
  if (!isLongRunning) return {};
@@ -130,11 +134,13 @@ const guardFirebaseReads: HookCallback = async (input) => {
130
134
  };
131
135
  };
132
136
 
133
- /** Build the hooks map to pass into SDK query() options. */
134
- export function buildHooks(): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
137
+ /** Build the hooks map to pass into SDK query() options.
138
+ * `exemptBashCommand` is the one command the turn was explicitly told to run in the foreground
139
+ * (a `bash` schedule's command) — see forceBackgroundForScripts. */
140
+ export function buildHooks(opts?: { exemptBashCommand?: string }): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
135
141
  return {
136
142
  PreToolUse: [
137
- { matcher: 'Bash', hooks: [forceBackgroundForScripts] },
143
+ { matcher: 'Bash', hooks: [forceBackgroundForScripts(opts?.exemptBashCommand)] },
138
144
  { matcher: 'mcp__mcp-slack-use__post_slack_.*', hooks: [resolveSlackMentions] },
139
145
  { matcher: 'mcp__mcp-firebase-(?:prod|lab)__get_db.*', hooks: [guardFirebaseReads] },
140
146
  ],
@@ -311,6 +311,7 @@ export async function runSchedule(
311
311
  mcpServers,
312
312
  abortController,
313
313
  onPermissionRequest,
314
+ foregroundBashCommand: allowedCmd ?? undefined,
314
315
  })) {
315
316
  onEvent({ type: 'session_stream', sessionId, event: ev });
316
317
  if (ev.type === 'text_delta') {
@@ -331,7 +332,12 @@ export async function runSchedule(
331
332
  } else if (ev.type === 'tool_result') {
332
333
  assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
333
334
  const use = toolUses.get(ev.toolUseId);
334
- if (ev.isError && allowedCmd && use?.tool === 'Bash' && (use.input as any)?.command === allowedCmd) bashFailure = ev.output;
335
+ // LAST result wins, failures and successes alike. A latched first failure survived the
336
+ // agent's own successful retry of the same command, so a run that ended green was
337
+ // reported as a failed schedule and paged a human about a healthy check.
338
+ if (allowedCmd && use?.tool === 'Bash' && (use.input as any)?.command === allowedCmd) {
339
+ bashFailure = ev.isError ? ev.output : undefined;
340
+ }
335
341
  } else if (ev.type === 'done') {
336
342
  break;
337
343
  } else if (ev.type === 'error') {