broapp 0.4.0 → 0.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
6
6
  "license": "MIT",
@@ -71,20 +71,24 @@ function ToolCall({
71
71
  <div
72
72
  className={`ai-chat__confirm${urgent ? ' ai-chat__confirm--urgent' : ''}`}
73
73
  role="group"
74
- aria-label={`Allow ${call.tool}?`}
74
+ aria-label={`Allow ${call.asks ?? call.tool}?`}
75
75
  >
76
- <span>Allow this?</span>
76
+ {/* A question about one of this call's own steps says which step. */}
77
+ <span>{call.asks === undefined ? 'Allow this?' : `Allow ${call.asks}?`}</span>
78
+ {call.asks === undefined || call.asksInput === undefined ? null : (
79
+ <pre className="ai-chat__json">{JSON.stringify(call.asksInput, null, 2)}</pre>
80
+ )}
77
81
  {call.expiresAt === undefined ? null : (
78
82
  <span className="ai-chat__expires">expires in {countdown(call.expiresAt, now)}</span>
79
83
  )}
80
84
  <button
81
85
  className="button button--primary"
82
86
  type="button"
83
- onClick={() => onConfirm(call.callId, true)}
87
+ onClick={() => onConfirm(call.confirmId ?? call.callId, true)}
84
88
  >
85
89
  Allow
86
90
  </button>
87
- <button className="button" type="button" onClick={() => onConfirm(call.callId, false)}>
91
+ <button className="button" type="button" onClick={() => onConfirm(call.confirmId ?? call.callId, false)}>
88
92
  Decline
89
93
  </button>
90
94
  </div>
@@ -24,6 +24,56 @@ export interface ToolCallState {
24
24
  readonly output?: unknown;
25
25
  /** While awaiting confirmation: when the question stops waiting. */
26
26
  readonly expiresAt?: number;
27
+ /**
28
+ * While awaiting confirmation for one of this call's own steps: the id the
29
+ * answer goes to (`<callId>.<step>`), what that step runs and its input.
30
+ * Absent when the question is about the call itself.
31
+ */
32
+ readonly confirmId?: string;
33
+ readonly asks?: string;
34
+ readonly asksInput?: unknown;
35
+ }
36
+
37
+ /**
38
+ * Put a question on the card it belongs to.
39
+ *
40
+ * A question about the call itself names the call's own id. A tool that runs
41
+ * several gated steps — the launcher's `candidate.cycle` builds and previews
42
+ * after the patch it was called with — asks once per step, as `<callId>.<step>`,
43
+ * so each answer is to exactly one action. Those questions go on the parent's
44
+ * card, which then says what it is asking about.
45
+ */
46
+ export function attachConfirm(
47
+ calls: readonly ToolCallState[],
48
+ event: { readonly callId?: string; readonly tool?: string; readonly input?: unknown; readonly expiresAt?: number },
49
+ ): ToolCallState[] {
50
+ const id = event.callId ?? '';
51
+ const exact = calls.some((call) => call.callId === id);
52
+ return calls.map((call) => {
53
+ const own = call.callId === id;
54
+ const step = !exact && id.startsWith(`${call.callId}.`);
55
+ if (!own && !step) return call;
56
+ const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, ...rest } = call;
57
+ return {
58
+ ...rest,
59
+ status: 'awaiting-confirmation',
60
+ ...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
61
+ ...(step ? { confirmId: id, asks: event.tool ?? '', asksInput: event.input } : {}),
62
+ };
63
+ });
64
+ }
65
+
66
+ /**
67
+ * An answer was accepted: the card goes back to running until its result or
68
+ * its next question arrives. Without this a card whose tool asks again later
69
+ * would keep offering buttons for a question already answered.
70
+ */
71
+ export function settleAnswer(calls: readonly ToolCallState[], answeredId: string): ToolCallState[] {
72
+ return calls.map((call) => {
73
+ if (call.status !== 'awaiting-confirmation' || (call.callId !== answeredId && call.confirmId !== answeredId)) return call;
74
+ const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, expiresAt: _expiresAt, ...rest } = call;
75
+ return { ...rest, status: 'running' };
76
+ });
27
77
  }
28
78
 
29
79
  /** One message in the transcript. */
@@ -152,18 +202,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
152
202
  break;
153
203
  case 'confirm':
154
204
  setStatus('awaiting-confirmation');
155
- patchPending((message) => ({
156
- ...message,
157
- toolCalls: message.toolCalls.map((call) =>
158
- call.callId === event.callId
159
- ? {
160
- ...call,
161
- status: 'awaiting-confirmation',
162
- ...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
163
- }
164
- : call,
165
- ),
166
- }));
205
+ patchPending((message) => ({ ...message, toolCalls: attachConfirm(message.toolCalls, event) }));
167
206
  break;
168
207
  case 'tool-result': {
169
208
  setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
@@ -296,12 +335,15 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
296
335
  // Nobody was waiting: the turn timed out or was cancelled while the
297
336
  // question was on screen.
298
337
  setError('That request has expired.');
338
+ return;
299
339
  }
340
+ patchPending((message) => ({ ...message, toolCalls: settleAnswer(message.toolCalls, callId) }));
341
+ setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
300
342
  } catch (cause) {
301
343
  setError(cause instanceof BroappError ? cause.message : 'That answer could not be sent.');
302
344
  }
303
345
  },
304
- [shared],
346
+ [shared, patchPending],
305
347
  );
306
348
 
307
349
  const clear = React.useCallback((): void => {