broapp 0.2.0 → 0.4.0

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.
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Where conversations live.
3
+ *
4
+ * `<dataDir>/ai/threads.sqlite`, beside the settings and the secrets. A
5
+ * conversation is the user's own writing, so nothing here asks whether a
6
+ * provider is configured: somebody who has just deleted their key still owns
7
+ * what they typed and must still be able to read and delete it.
8
+ *
9
+ * The host stores messages and never interprets them. Parts are the AI SDK's
10
+ * shape, written as JSON and handed back to the browser unread, with one
11
+ * deliberate exception on the way in — see {@link withoutImages}.
12
+ */
13
+ import { Database } from 'bun:sqlite';
14
+ import { mkdirSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { publicError } from '../../shared/errors.ts';
18
+ import type { StoredMessage, Thread } from '../shared/types.ts';
19
+
20
+ /**
21
+ * The migrations, in order.
22
+ *
23
+ * Each is applied once and `user_version` is set to its index. Adding a column
24
+ * later means appending here — never editing an entry that has shipped,
25
+ * because a database that already ran the old version will never run the new
26
+ * one.
27
+ */
28
+ const MIGRATIONS: readonly string[] = [
29
+ `CREATE TABLE threads (
30
+ id TEXT PRIMARY KEY,
31
+ title TEXT NOT NULL,
32
+ model_id TEXT,
33
+ created_at INTEGER NOT NULL,
34
+ updated_at INTEGER NOT NULL
35
+ );
36
+ CREATE INDEX threads_updated_at ON threads (updated_at DESC);
37
+ CREATE TABLE messages (
38
+ thread_id TEXT NOT NULL REFERENCES threads (id) ON DELETE CASCADE,
39
+ position INTEGER NOT NULL,
40
+ json TEXT NOT NULL,
41
+ PRIMARY KEY (thread_id, position)
42
+ );`,
43
+ /*
44
+ * A monotonic sequence, because a timestamp is not one.
45
+ *
46
+ * `updated_at` is milliseconds, and two writes inside one millisecond used
47
+ * to be ordered by whichever random id sorted higher — so a list could come
48
+ * back in a different order for the same history (report 07). `seq` is
49
+ * bumped by every write that touches a conversation, so "most recently
50
+ * changed first" means what it says. The backfill puts existing rows in the
51
+ * order they were last shown in, `rowid` breaking the ties the old query
52
+ * could not.
53
+ */
54
+ `ALTER TABLE threads ADD COLUMN seq INTEGER NOT NULL DEFAULT 0;
55
+ UPDATE threads SET seq = (
56
+ SELECT COUNT(*) FROM threads AS earlier
57
+ WHERE earlier.updated_at < threads.updated_at
58
+ OR (earlier.updated_at = threads.updated_at AND earlier.rowid <= threads.rowid)
59
+ );
60
+ CREATE INDEX threads_seq ON threads (seq DESC);`,
61
+ ];
62
+
63
+ /** What a conversation is called until it has been named. */
64
+ export const DEFAULT_THREAD_TITLE = 'New conversation';
65
+
66
+ /** How much of the first message becomes the title. */
67
+ const TITLE_CHARS = 60;
68
+
69
+ /** The most conversations one listing returns, matching the contract's bound. */
70
+ const MAX_THREADS = 500;
71
+
72
+ /**
73
+ * The most JSON one save may write.
74
+ *
75
+ * The contract bounds the *number* of messages and parts but not their size,
76
+ * because a part is `unknown` by design. This is the bound on the amount: four
77
+ * megabytes is far more than a conversation of 200 messages needs and far less
78
+ * than a browser could use to fill somebody's disk.
79
+ */
80
+ const MAX_SAVE_CHARS = 4_000_000;
81
+
82
+ /** A conversation and everything in it. */
83
+ export interface ThreadStore {
84
+ readonly path: string;
85
+ /** Most recently changed first, capped at 500. */
86
+ list(): Thread[];
87
+ create(input: { title?: string | undefined; modelId?: string | null | undefined }): Thread;
88
+ get(id: string): { thread: Thread; messages: StoredMessage[] };
89
+ /** Replaces the messages whole and bumps `updatedAt`. */
90
+ save(input: {
91
+ id: string;
92
+ messages: readonly StoredMessage[];
93
+ title?: string | undefined;
94
+ }): Thread;
95
+ update(input: {
96
+ id: string;
97
+ title?: string | undefined;
98
+ modelId?: string | null | undefined;
99
+ }): Thread;
100
+ remove(id: string): boolean;
101
+ /** Every conversation. Returns how many were deleted. */
102
+ clear(): number;
103
+ close(): void;
104
+ }
105
+
106
+ /** A row of `threads`, joined with its message count. */
107
+ interface ThreadRow {
108
+ id: string;
109
+ title: string;
110
+ model_id: string | null;
111
+ created_at: number;
112
+ updated_at: number;
113
+ message_count: number;
114
+ }
115
+
116
+ function toThread(row: ThreadRow): Thread {
117
+ return {
118
+ id: row.id,
119
+ title: row.title,
120
+ modelId: row.model_id,
121
+ createdAt: row.created_at,
122
+ updatedAt: row.updated_at,
123
+ messageCount: row.message_count,
124
+ };
125
+ }
126
+
127
+ /** An id matching the contract's pattern: 32 hex characters. */
128
+ function newThreadId(): string {
129
+ return crypto.randomUUID().replace(/-/g, '');
130
+ }
131
+
132
+ /** True for a part the AI SDK would render as an attachment. */
133
+ function isFilePart(part: unknown): part is { type: 'file'; filename?: unknown; mediaType?: unknown } {
134
+ return typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'file';
135
+ }
136
+
137
+ /**
138
+ * A message with its images replaced by the line that names them.
139
+ *
140
+ * A `file` part carries a data URL, so storing one would put a second copy of
141
+ * the picture in SQLite that nobody asked to keep and nothing ever deletes.
142
+ * The placeholder is the same line the browser already puts in `history`, so
143
+ * a reloaded conversation says exactly what the model was told on the turns
144
+ * after the one the image arrived on.
145
+ */
146
+ function withoutImages(message: StoredMessage): StoredMessage {
147
+ if (!message.parts.some(isFilePart)) return message;
148
+ const parts = message.parts.map((part) => {
149
+ if (!isFilePart(part)) return part;
150
+ const name =
151
+ typeof part.filename === 'string'
152
+ ? part.filename
153
+ : typeof part.mediaType === 'string'
154
+ ? part.mediaType
155
+ : 'image';
156
+ return { type: 'text', text: `[image: ${name}]` };
157
+ });
158
+ return message.metadata === undefined
159
+ ? { id: message.id, role: message.role, parts }
160
+ : { id: message.id, role: message.role, parts, metadata: message.metadata };
161
+ }
162
+
163
+ /** The text of a message, for deriving a title. */
164
+ function textOf(message: StoredMessage): string {
165
+ const lines: string[] = [];
166
+ for (const part of message.parts) {
167
+ if (typeof part !== 'object' || part === null) continue;
168
+ const typed = part as { type?: unknown; text?: unknown };
169
+ if (typed.type === 'text' && typeof typed.text === 'string') lines.push(typed.text);
170
+ }
171
+ return lines.join(' ');
172
+ }
173
+
174
+ /** The first user message's opening words, or null when there is nothing to use. */
175
+ function derivedTitle(messages: readonly StoredMessage[]): string | null {
176
+ const first = messages.find((message) => message.role === 'user');
177
+ if (first === undefined) return null;
178
+ const collapsed = textOf(first).replace(/\s+/g, ' ').trim();
179
+ if (collapsed === '') return null;
180
+ return collapsed.slice(0, TITLE_CHARS);
181
+ }
182
+
183
+ /** Open the conversation store for one data directory, migrating it as needed. */
184
+ export function openThreads(dataDir: string): ThreadStore {
185
+ const directory = join(dataDir, 'ai');
186
+ // The same mode the settings store uses: this directory holds what somebody
187
+ // wrote to their assistant, which is nobody else's business.
188
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
189
+ const path = join(directory, 'threads.sqlite');
190
+ const db = new Database(path, { create: true, strict: true });
191
+
192
+ db.exec('PRAGMA journal_mode = WAL');
193
+ // Without this, `ON DELETE CASCADE` is decoration: SQLite does not enforce a
194
+ // foreign key unless it is asked to.
195
+ db.exec('PRAGMA foreign_keys = ON');
196
+ db.exec('PRAGMA busy_timeout = 5000');
197
+
198
+ migrate(db);
199
+
200
+ const THREAD_COLUMNS = `t.id AS id, t.title AS title, t.model_id AS model_id,
201
+ t.created_at AS created_at, t.updated_at AS updated_at,
202
+ (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count`;
203
+
204
+ const statements = {
205
+ list: db.query<ThreadRow, [number]>(
206
+ // `seq` only: it is unique and monotonic, so no tie-break is needed and
207
+ // none can disagree with the order the writes actually happened in.
208
+ `SELECT ${THREAD_COLUMNS} FROM threads t ORDER BY t.seq DESC LIMIT ?`,
209
+ ),
210
+ byId: db.query<ThreadRow, [string]>(
211
+ `SELECT ${THREAD_COLUMNS} FROM threads t WHERE t.id = ?`,
212
+ ),
213
+ /*
214
+ * Both writes take the next sequence in the same statement that changes
215
+ * the row, so the number a conversation is ordered by is decided inside
216
+ * whatever transaction is writing it — never by a second round trip that
217
+ * another write could interleave with.
218
+ */
219
+ insert: db.query<unknown, [string, string, string | null, number, number]>(
220
+ `INSERT INTO threads (id, title, model_id, created_at, updated_at, seq)
221
+ VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM threads))`,
222
+ ),
223
+ touch: db.query<unknown, [string, string | null, number, string]>(
224
+ `UPDATE threads SET title = ?, model_id = ?, updated_at = ?,
225
+ seq = (SELECT COALESCE(MAX(seq), 0) + 1 FROM threads)
226
+ WHERE id = ?`,
227
+ ),
228
+ messages: db.query<{ json: string }, [string]>(
229
+ 'SELECT json FROM messages WHERE thread_id = ? ORDER BY position ASC',
230
+ ),
231
+ deleteMessages: db.query<unknown, [string]>('DELETE FROM messages WHERE thread_id = ?'),
232
+ insertMessage: db.query<unknown, [string, number, string]>(
233
+ 'INSERT INTO messages (thread_id, position, json) VALUES (?, ?, ?)',
234
+ ),
235
+ remove: db.query<{ id: string }, [string]>('DELETE FROM threads WHERE id = ? RETURNING id'),
236
+ count: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM threads'),
237
+ clear: db.query<unknown, []>('DELETE FROM threads'),
238
+ };
239
+
240
+ /** The row, or the sentence a browser shows when a conversation is gone. */
241
+ function mustGet(id: string): ThreadRow {
242
+ const row = statements.byId.get(id);
243
+ if (row === null) throw publicError.notFound('That conversation is gone.');
244
+ return row;
245
+ }
246
+
247
+ const store: ThreadStore = {
248
+ path,
249
+
250
+ list() {
251
+ return statements.list.all(MAX_THREADS).map(toThread);
252
+ },
253
+
254
+ create({ title, modelId }) {
255
+ const now = Date.now();
256
+ const id = newThreadId();
257
+ statements.insert.run(id, title ?? DEFAULT_THREAD_TITLE, modelId ?? null, now, now);
258
+ return toThread(mustGet(id));
259
+ },
260
+
261
+ get(id) {
262
+ const thread = toThread(mustGet(id));
263
+ const messages = statements.messages.all(id).map((row) => {
264
+ // Written by this process, from a value the contract validated. A
265
+ // parse failure would mean the file was edited by hand or corrupted,
266
+ // and the conversation is unreadable either way.
267
+ return JSON.parse(row.json) as StoredMessage;
268
+ });
269
+ return { thread, messages };
270
+ },
271
+
272
+ save({ id, messages, title }) {
273
+ const row = mustGet(id);
274
+ const stored = messages.map(withoutImages);
275
+ const encoded = stored.map((message) => JSON.stringify(message));
276
+ const characters = encoded.reduce((total, json) => total + json.length, 0);
277
+ if (characters > MAX_SAVE_CHARS) {
278
+ throw publicError.invalidInput('That conversation is too large to save.');
279
+ }
280
+ // A title given wins; otherwise a conversation still carrying the
281
+ // default name takes one from what the person actually asked.
282
+ const named =
283
+ title ?? (row.title === DEFAULT_THREAD_TITLE ? (derivedTitle(stored) ?? row.title) : row.title);
284
+ const now = Date.now();
285
+ // One transaction: a save that failed half way would leave a
286
+ // conversation holding the first few messages of the new list and none
287
+ // of the old.
288
+ db.transaction(() => {
289
+ statements.deleteMessages.run(id);
290
+ for (let index = 0; index < encoded.length; index += 1) {
291
+ const json = encoded[index];
292
+ if (json === undefined) continue;
293
+ statements.insertMessage.run(id, index, json);
294
+ }
295
+ statements.touch.run(named, row.model_id, now, id);
296
+ })();
297
+ return toThread(mustGet(id));
298
+ },
299
+
300
+ update({ id, title, modelId }) {
301
+ const row = mustGet(id);
302
+ statements.touch.run(
303
+ title ?? row.title,
304
+ modelId === undefined ? row.model_id : modelId,
305
+ Date.now(),
306
+ id,
307
+ );
308
+ return toThread(mustGet(id));
309
+ },
310
+
311
+ remove(id) {
312
+ return statements.remove.get(id) !== null;
313
+ },
314
+
315
+ clear() {
316
+ const before = statements.count.get()?.n ?? 0;
317
+ statements.clear.run();
318
+ return before;
319
+ },
320
+
321
+ close() {
322
+ // Checkpointing folds the WAL back into the main file, so what is left
323
+ // behind is one complete database rather than one that needs its
324
+ // sidecars to be readable.
325
+ try {
326
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
327
+ } catch {
328
+ // A checkpoint that fails is not a reason to leave the handle open.
329
+ }
330
+ db.close();
331
+ },
332
+ };
333
+
334
+ return store;
335
+ }
336
+
337
+ function currentVersion(db: Database): number {
338
+ const row = db.query<{ user_version: number }, []>('PRAGMA user_version').get();
339
+ return row?.user_version ?? 0;
340
+ }
341
+
342
+ /**
343
+ * Bring the schema up to date.
344
+ *
345
+ * Each migration and its version bump happen in one transaction, so an
346
+ * interrupted upgrade leaves the database at the last version that fully
347
+ * applied — never half-way through one.
348
+ */
349
+ function migrate(db: Database): void {
350
+ const from = currentVersion(db);
351
+ if (from > MIGRATIONS.length) {
352
+ throw new Error(
353
+ `the conversation store's schema version ${String(from)} is newer than this build understands (${String(MIGRATIONS.length)})`,
354
+ );
355
+ }
356
+ for (let version = from; version < MIGRATIONS.length; version += 1) {
357
+ const statement = MIGRATIONS[version];
358
+ if (statement === undefined) continue;
359
+ db.transaction(() => {
360
+ db.exec(statement);
361
+ // PRAGMA does not accept a bound parameter, so the value is interpolated.
362
+ // It is a loop index, not input.
363
+ db.exec(`PRAGMA user_version = ${String(version + 1)}`);
364
+ })();
365
+ }
366
+ }
@@ -4,17 +4,73 @@
4
4
  * Kept apart from `create-ai.ts` so that `from-contract.ts` and `run.ts` can
5
5
  * share these without either importing the other's module graph.
6
6
  */
7
+ import type { Envelope, Gate } from '../../host/gate.ts';
8
+ import type { Effect } from '../../shared/contract.ts';
7
9
  import type { JsonSchema } from '../../shared/schema.ts';
8
- import type { ToolPermission } from '../shared/types.ts';
9
10
 
10
11
  /** One thing a model may do. */
11
12
  export interface AiTool {
12
13
  readonly description: string;
13
14
  /** JSON Schema for the input. Use `schema.toJsonSchema()` or write it by hand. */
14
15
  readonly inputSchema: JsonSchema;
15
- /** `read` runs immediately; `confirm` asks the user first. */
16
- readonly permission: ToolPermission;
17
- execute(input: unknown, signal: AbortSignal): Promise<unknown>;
16
+ /** What running it does to the world. The gate decides from this and the channel. */
17
+ readonly effect: Effect;
18
+ /**
19
+ * Run it.
20
+ *
21
+ * The envelope comes from the run loop, which built it from what it knows
22
+ * rather than from what the model said. An implementation passes it on; it
23
+ * does not invent one.
24
+ */
25
+ execute(input: unknown, envelope: Envelope, signal: AbortSignal): Promise<unknown>;
26
+ }
27
+
28
+ /**
29
+ * The brand that says a tool's `execute` reaches the gate.
30
+ *
31
+ * A hand-written tool is ordinary host code: nothing about its type says
32
+ * whether it asked anybody before doing what it does. Rather than trust that
33
+ * every application remembers, `createAi` refuses a tool without this symbol,
34
+ * and the only way to get one is {@link guardedTool}, which does the asking.
35
+ */
36
+ export const GUARDED: unique symbol = Symbol('broapp.guarded');
37
+
38
+ /** An {@link AiTool} whose calls are known to pass the gate. */
39
+ export interface GuardedTool extends AiTool {
40
+ readonly [GUARDED]: true;
41
+ }
42
+
43
+ /** What {@link guardedTool} needs to know about the thing it is wrapping. */
44
+ export interface GuardedToolDefinition {
45
+ /** The tool's name, which is also the route in the gate's records. */
46
+ readonly name: string;
47
+ readonly description: string;
48
+ readonly inputSchema: JsonSchema;
49
+ readonly effect: Effect;
50
+ /** The envelope is the run loop's, never the model's; a tool may record it and must not act on its channel. */
51
+ run(input: unknown, signal: AbortSignal, envelope?: Envelope): Promise<unknown>;
52
+ }
53
+
54
+ /**
55
+ * Wrap a hand-written tool so its calls pass the gate.
56
+ *
57
+ * This is the supported way to give a model something an application's
58
+ * contract does not describe — a search over a third-party index, a shell
59
+ * command, a mail send. The wrapping is the whole point: the model's request
60
+ * arrives with the run loop's envelope, the gate decides, and only then does
61
+ * `run` happen.
62
+ */
63
+ export function guardedTool(gate: Gate, tool: GuardedToolDefinition): GuardedTool {
64
+ return {
65
+ [GUARDED]: true,
66
+ description: tool.description,
67
+ inputSchema: tool.inputSchema,
68
+ effect: tool.effect,
69
+ execute: (input, envelope) =>
70
+ gate.guard({ ...envelope, route: tool.name, effect: tool.effect, input }, (signal) =>
71
+ tool.run(input, signal, envelope),
72
+ ),
73
+ };
18
74
  }
19
75
 
20
76
  /** A record the model may be shown, named but not loaded. */
@@ -33,57 +89,13 @@ export interface ContextDocument {
33
89
 
34
90
  /** Where the model's knowledge of the application's data comes from. */
35
91
  export interface AiContextProviders {
36
- /** Records relevant to a query. Return refs and short snippets, not full content. */
37
- search?(query: { text: string; limit: number }, signal: AbortSignal): Promise<ContextRef[]>;
92
+ /**
93
+ * Records relevant to a query. Return refs and short snippets, not full content.
94
+ *
95
+ * `runId` is the turn asking, so a provider can write down what it offered
96
+ * to which run. It identifies; it grants nothing.
97
+ */
98
+ search?(query: { text: string; limit: number; runId?: string }, signal: AbortSignal): Promise<ContextRef[]>;
38
99
  /** Full content for named refs. Unknown refs are skipped, not errors. */
39
100
  resolve?(refs: readonly string[], signal: AbortSignal): Promise<ContextDocument[]>;
40
101
  }
41
-
42
- /**
43
- * The table a waiting tool call and `ai.chatConfirm` meet in.
44
- *
45
- * One per `Ai`, because a confirmation belongs to a run, and a run belongs to
46
- * a stream that may be one of several open at once.
47
- */
48
- export interface Confirmations {
49
- wait(runId: string, callId: string, timeoutMs: number, signal: AbortSignal): Promise<boolean>;
50
- /** Called by `ai.chatConfirm`. Returns false when nobody is waiting. */
51
- answer(runId: string, callId: string, approve: boolean): boolean;
52
- }
53
-
54
- /** Build the confirmation table. */
55
- export function createConfirmations(): Confirmations {
56
- const waiting = new Map<string, (approved: boolean) => void>();
57
- const key = (runId: string, callId: string): string => `${runId} ${callId}`;
58
-
59
- return {
60
- wait(runId, callId, timeoutMs, signal) {
61
- const id = key(runId, callId);
62
- return new Promise<boolean>((resolve) => {
63
- let settled = false;
64
- const finish = (approved: boolean): void => {
65
- if (settled) return;
66
- settled = true;
67
- waiting.delete(id);
68
- clearTimeout(timer);
69
- signal.removeEventListener('abort', onAbort);
70
- resolve(approved);
71
- };
72
- // A question nobody answers is a denial, not a hung stream: the user
73
- // may have closed the tab, and the tool must not run unattended.
74
- const timer = setTimeout(() => finish(false), timeoutMs);
75
- const onAbort = (): void => finish(false);
76
- signal.addEventListener('abort', onAbort, { once: true });
77
- if (signal.aborted) finish(false);
78
- else waiting.set(id, finish);
79
- });
80
- },
81
-
82
- answer(runId, callId, approve) {
83
- const resolve = waiting.get(key(runId, callId));
84
- if (resolve === undefined) return false;
85
- resolve(approve);
86
- return true;
87
- },
88
- };
89
- }
@@ -10,6 +10,8 @@
10
10
  */
11
11
  import * as React from 'react';
12
12
 
13
+ import { countdown, isUrgent } from '../../shared/countdown.ts';
14
+
13
15
  import { useAiChat, type AiChatOptions, type ToolCallState } from './use-ai-chat.ts';
14
16
  import { useAiSettings } from './use-ai-settings.ts';
15
17
 
@@ -21,6 +23,27 @@ export interface AiChatProps {
21
23
  readonly emptyText?: string;
22
24
  /** Called when a tool call settles, so the application can refetch. */
23
25
  readonly onToolResult?: AiChatOptions['onToolResult'];
26
+ /**
27
+ * How many tool calls are waiting for the person, whenever that changes.
28
+ *
29
+ * The panel does not act on this itself: a tab that renamed itself or raised
30
+ * a notification without being asked would do it in every application that
31
+ * embeds a chat. The launcher asks, because its questions arrive after ten
32
+ * minutes of silence and the person is not looking at the tab.
33
+ */
34
+ readonly onAwaiting?: (pending: number) => void;
35
+ }
36
+
37
+ /** Re-render once a second while `active`, so a countdown counts. */
38
+ function useTick(active: boolean): number {
39
+ const [now, setNow] = React.useState(() => Date.now());
40
+ React.useEffect(() => {
41
+ if (!active) return undefined;
42
+ setNow(Date.now());
43
+ const timer = setInterval(() => setNow(Date.now()), 1_000);
44
+ return () => clearInterval(timer);
45
+ }, [active]);
46
+ return now;
24
47
  }
25
48
 
26
49
  function ToolCall({
@@ -30,6 +53,9 @@ function ToolCall({
30
53
  call: ToolCallState;
31
54
  onConfirm: (callId: string, approve: boolean) => void;
32
55
  }): React.ReactElement {
56
+ const waiting = call.status === 'awaiting-confirmation' && call.expiresAt !== undefined;
57
+ const now = useTick(waiting);
58
+ const urgent = call.expiresAt !== undefined && waiting && isUrgent(call.expiresAt, now);
33
59
  return (
34
60
  <div className="ai-chat__tool">
35
61
  <details>
@@ -42,8 +68,15 @@ function ToolCall({
42
68
  )}
43
69
  </details>
44
70
  {call.status !== 'awaiting-confirmation' ? null : (
45
- <div className="ai-chat__confirm" role="group" aria-label={`Allow ${call.tool}?`}>
71
+ <div
72
+ className={`ai-chat__confirm${urgent ? ' ai-chat__confirm--urgent' : ''}`}
73
+ role="group"
74
+ aria-label={`Allow ${call.tool}?`}
75
+ >
46
76
  <span>Allow this?</span>
77
+ {call.expiresAt === undefined ? null : (
78
+ <span className="ai-chat__expires">expires in {countdown(call.expiresAt, now)}</span>
79
+ )}
47
80
  <button
48
81
  className="button button--primary"
49
82
  type="button"
@@ -65,6 +98,7 @@ export function AiChat({
65
98
  placeholder,
66
99
  emptyText,
67
100
  onToolResult,
101
+ onAwaiting,
68
102
  }: AiChatProps): React.ReactElement {
69
103
  const { settings } = useAiSettings();
70
104
  const chat = useAiChat({
@@ -75,6 +109,20 @@ export function AiChat({
75
109
  const input = React.useRef<HTMLTextAreaElement | null>(null);
76
110
  const busy = chat.status === 'streaming' || chat.status === 'awaiting-confirmation';
77
111
 
112
+ const waiting = chat.messages.reduce(
113
+ (count, message) =>
114
+ message.role === 'assistant'
115
+ ? count +
116
+ message.toolCalls.filter((call) => call.status === 'awaiting-confirmation').length
117
+ : count,
118
+ 0,
119
+ );
120
+ const announce = React.useRef(onAwaiting);
121
+ announce.current = onAwaiting;
122
+ React.useEffect(() => {
123
+ announce.current?.(waiting);
124
+ }, [waiting]);
125
+
78
126
  // Back to the box when the turn ends, so a conversation can be carried on
79
127
  // without reaching for the mouse.
80
128
  React.useEffect(() => {
@@ -101,11 +101,17 @@ export function AiSettings(): React.ReactElement {
101
101
  </div>
102
102
  )}
103
103
 
104
- {!provider.needs.apiKey ? null : (
104
+ {provider.needs.apiKey === 'none' ? null : (
105
105
  <div className="form__row">
106
106
  <label className="form__label" htmlFor="ai-key">
107
- API key
107
+ API key{provider.needs.apiKey === 'optional' ? ' (optional)' : ''}
108
108
  </label>
109
+ {provider.needs.apiKey === 'optional' ? (
110
+ <p className="form__hint">
111
+ Needed for a hosted service such as OpenRouter. Leave empty for a server on this
112
+ computer that does not ask for one.
113
+ </p>
114
+ ) : null}
109
115
  <div className="ai-settings__key">
110
116
  <input
111
117
  className="input"
@@ -109,6 +109,20 @@
109
109
  color: var(--text, #1b1a18);
110
110
  }
111
111
 
112
+ /* Under a minute left. Amber and a little heavier, so a card that is about to
113
+ expire is not the same shape as one with nine minutes on it. */
114
+ .ai-chat__confirm--urgent {
115
+ border-color: var(--warning, #b4690e);
116
+ background: var(--warning-bg, #fdf3e3);
117
+ }
118
+
119
+ .ai-chat__expires {
120
+ margin-left: auto;
121
+ color: var(--text-muted, #6b6862);
122
+ font-size: 0.8rem;
123
+ font-variant-numeric: tabular-nums;
124
+ }
125
+
112
126
  .ai-chat__typing {
113
127
  display: inline-block;
114
128
  margin-left: 0.25rem;
@@ -29,7 +29,10 @@ export type {
29
29
  AiSettings as AiSettingsValue,
30
30
  BroappModel,
31
31
  ChatEvent,
32
+ ChatFile,
32
33
  ChatTurn,
33
34
  ProviderInfo,
35
+ StoredMessage,
36
+ Thread,
34
37
  ToolPermission,
35
38
  } from '../shared/index.ts';
@@ -22,6 +22,8 @@ export interface ToolCallState {
22
22
  readonly input: unknown;
23
23
  readonly status: 'running' | 'awaiting-confirmation' | 'done' | 'denied';
24
24
  readonly output?: unknown;
25
+ /** While awaiting confirmation: when the question stops waiting. */
26
+ readonly expiresAt?: number;
25
27
  }
26
28
 
27
29
  /** One message in the transcript. */
@@ -153,7 +155,13 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
153
155
  patchPending((message) => ({
154
156
  ...message,
155
157
  toolCalls: message.toolCalls.map((call) =>
156
- call.callId === event.callId ? { ...call, status: 'awaiting-confirmation' } : call,
158
+ call.callId === event.callId
159
+ ? {
160
+ ...call,
161
+ status: 'awaiting-confirmation',
162
+ ...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
163
+ }
164
+ : call,
157
165
  ),
158
166
  }));
159
167
  break;