@workerdeck/react 0.7.0 → 0.11.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.
- package/README.md +32 -2
- package/build/index.d.mts +579 -8
- package/build/index.mjs +1099 -13
- package/build/index.mjs.map +1 -1
- package/package.json +6 -6
package/build/index.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
2
|
+
import { ENGINE_CAPABILITIES, PROTOCOL_VERSION } from "@workerdeck/protocol";
|
|
3
|
+
import { WorkerDeckError } from "@workerdeck/client";
|
|
2
4
|
//#region src/transcript.ts
|
|
3
5
|
const initialTranscriptState = {
|
|
4
6
|
status: "starting",
|
|
7
|
+
capabilities: ENGINE_CAPABILITIES.claude,
|
|
5
8
|
items: [],
|
|
6
9
|
pendingApprovals: [],
|
|
7
10
|
totalCostUsd: 0,
|
|
@@ -45,6 +48,7 @@ function upsert(items, item) {
|
|
|
45
48
|
* haven't set yet; the event stream stays authoritative.
|
|
46
49
|
*/
|
|
47
50
|
function seedFromSessionInfo(state, info) {
|
|
51
|
+
const engine = info.engine ?? state.engine;
|
|
48
52
|
return {
|
|
49
53
|
...state,
|
|
50
54
|
status: state.lastSeq === 0 ? info.status : state.status,
|
|
@@ -52,9 +56,29 @@ function seedFromSessionInfo(state, info) {
|
|
|
52
56
|
permissionMode: state.permissionMode ?? info.permissionMode,
|
|
53
57
|
cwd: state.cwd ?? info.cwd,
|
|
54
58
|
sdkSessionId: state.sdkSessionId ?? info.sdkSessionId,
|
|
55
|
-
engine
|
|
59
|
+
engine,
|
|
60
|
+
capabilities: info.capabilities ?? ENGINE_CAPABILITIES[engine ?? "claude"],
|
|
61
|
+
session: info
|
|
56
62
|
};
|
|
57
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The session's rate-limit windows in reading order: the session window, the
|
|
66
|
+
* weekly window, then whichever per-model weekly windows it reports.
|
|
67
|
+
*
|
|
68
|
+
* Discovered rather than hardcoded — the SDK's set of windows is an open union
|
|
69
|
+
* and has grown before — but ordered, so the first two always mean the same
|
|
70
|
+
* thing. A window with no `utilization` is *unknown*, not zero, and is dropped
|
|
71
|
+
* entirely rather than drawn as an empty bar that reads as "plenty left".
|
|
72
|
+
*/
|
|
73
|
+
function rateLimitWindows(state) {
|
|
74
|
+
const all = Object.entries(state.rateLimits ?? {}).filter(([, info]) => info.utilization !== void 0).map(([key, info]) => ({
|
|
75
|
+
key,
|
|
76
|
+
info
|
|
77
|
+
}));
|
|
78
|
+
const named = ["five_hour", "seven_day"].flatMap((key) => all.filter((w) => w.key === key));
|
|
79
|
+
const perModel = all.filter((w) => w.key.startsWith("seven_day_")).sort((a, b) => a.key.localeCompare(b.key));
|
|
80
|
+
return [...named, ...perModel];
|
|
81
|
+
}
|
|
58
82
|
function applyEvent(state, event) {
|
|
59
83
|
if (event.seq <= state.lastSeq) return state;
|
|
60
84
|
const base = {
|
|
@@ -77,7 +101,23 @@ function applyEvent(state, event) {
|
|
|
77
101
|
case "capabilities": return {
|
|
78
102
|
...base,
|
|
79
103
|
models: event.models,
|
|
80
|
-
commands: event.commands
|
|
104
|
+
commands: event.commands,
|
|
105
|
+
defaultModel: event.defaultModel ?? base.defaultModel
|
|
106
|
+
};
|
|
107
|
+
case "skills": return {
|
|
108
|
+
...base,
|
|
109
|
+
skills: event.skills
|
|
110
|
+
};
|
|
111
|
+
case "file_produced": return {
|
|
112
|
+
...base,
|
|
113
|
+
producedFiles: {
|
|
114
|
+
...base.producedFiles,
|
|
115
|
+
[event.path]: {
|
|
116
|
+
fileId: event.fileId,
|
|
117
|
+
...event.mediaType ? { mediaType: event.mediaType } : {},
|
|
118
|
+
...event.bytes !== void 0 ? { bytes: event.bytes } : {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
81
121
|
};
|
|
82
122
|
case "model_changed": return event.model === void 0 ? base : {
|
|
83
123
|
...base,
|
|
@@ -99,9 +139,14 @@ function applyEvent(state, event) {
|
|
|
99
139
|
rateLimits: {
|
|
100
140
|
...base.rateLimits,
|
|
101
141
|
[key]: event.info
|
|
102
|
-
}
|
|
142
|
+
},
|
|
143
|
+
rateLimitsUpdatedAt: event.ts
|
|
103
144
|
};
|
|
104
145
|
}
|
|
146
|
+
case "plan_info": return {
|
|
147
|
+
...base,
|
|
148
|
+
subscriptionType: event.subscriptionType
|
|
149
|
+
};
|
|
105
150
|
case "user_message": {
|
|
106
151
|
let items = base.items;
|
|
107
152
|
for (const block of contentToBlocks(event.message.content)) if (block.type === "tool_result") {
|
|
@@ -127,7 +172,8 @@ function applyEvent(state, event) {
|
|
|
127
172
|
else items = upsert(items, {
|
|
128
173
|
kind: "user",
|
|
129
174
|
id: event.uuid ?? `user-${event.seq}`,
|
|
130
|
-
text
|
|
175
|
+
text,
|
|
176
|
+
attachments: event.attachments
|
|
131
177
|
});
|
|
132
178
|
}
|
|
133
179
|
return {
|
|
@@ -298,10 +344,14 @@ function applyEvent(state, event) {
|
|
|
298
344
|
function reduce(state, action) {
|
|
299
345
|
return action.type === "attached" ? seedFromSessionInfo(state, action.session) : applyEvent(state, action);
|
|
300
346
|
}
|
|
347
|
+
/** Failed attempts in a row before "reconnecting…" stops being the honest word.
|
|
348
|
+
* Three is ~3.5s of backoff — past a blip. Matches the iOS client. */
|
|
349
|
+
const OFFLINE_AFTER_ATTEMPTS = 3;
|
|
301
350
|
/** Attach to a session and maintain live transcript state. Detaches on unmount. */
|
|
302
351
|
function useClaudeSession(client, sessionId, options) {
|
|
303
352
|
const [state, dispatch] = useReducer(reduce, initialTranscriptState);
|
|
304
|
-
const [
|
|
353
|
+
const [connection, setConnection] = useState("reconnecting");
|
|
354
|
+
const [protocolMismatch, setProtocolMismatch] = useState();
|
|
305
355
|
const [handleState, setHandleState] = useState();
|
|
306
356
|
const handleRef = useRef(null);
|
|
307
357
|
const onProtocolErrorRef = useRef(options?.onProtocolError);
|
|
@@ -312,8 +362,12 @@ function useClaudeSession(client, sessionId, options) {
|
|
|
312
362
|
handleRef.current = handle;
|
|
313
363
|
setHandleState(handle);
|
|
314
364
|
const offEvent = handle.on("event", (event) => dispatch(event));
|
|
315
|
-
const offAttached = handle.on("attached", (frame) =>
|
|
316
|
-
|
|
365
|
+
const offAttached = handle.on("attached", (frame) => {
|
|
366
|
+
dispatch(frame);
|
|
367
|
+
setProtocolMismatch(frame.protocolVersion === PROTOCOL_VERSION ? void 0 : frame.protocolVersion);
|
|
368
|
+
});
|
|
369
|
+
const offConn = handle.on("connectionChange", (open) => setConnection(open ? "live" : "reconnecting"));
|
|
370
|
+
const offRetry = handle.on("reconnectAttempt", (attempts) => setConnection(attempts >= OFFLINE_AFTER_ATTEMPTS ? "offline" : "reconnecting"));
|
|
317
371
|
const offProtocolError = handle.on("protocolError", (message) => {
|
|
318
372
|
onProtocolErrorRef.current?.(message);
|
|
319
373
|
});
|
|
@@ -321,28 +375,981 @@ function useClaudeSession(client, sessionId, options) {
|
|
|
321
375
|
offEvent();
|
|
322
376
|
offAttached();
|
|
323
377
|
offConn();
|
|
378
|
+
offRetry();
|
|
324
379
|
offProtocolError();
|
|
325
380
|
handle.detach();
|
|
326
381
|
handleRef.current = null;
|
|
327
382
|
setHandleState(void 0);
|
|
383
|
+
setConnection("reconnecting");
|
|
384
|
+
setProtocolMismatch(void 0);
|
|
328
385
|
};
|
|
329
386
|
}, [client, sessionId]);
|
|
387
|
+
const models = useProfileModelFallback(client, sessionId, state);
|
|
388
|
+
const connected = connection === "live";
|
|
389
|
+
const reconnectNow = useCallback(() => handleRef.current?.reconnectNow(), []);
|
|
330
390
|
return useMemo(() => ({
|
|
331
391
|
state,
|
|
332
392
|
connected,
|
|
393
|
+
connection,
|
|
394
|
+
protocolMismatch,
|
|
395
|
+
models,
|
|
396
|
+
effectiveModel: state.model ?? state.defaultModel,
|
|
333
397
|
handle: handleState,
|
|
334
|
-
send: (text) => handleRef.current?.send(text),
|
|
398
|
+
send: (text, attachmentIds) => handleRef.current?.send(text, attachmentIds),
|
|
335
399
|
approve: (requestId, updatedInput) => handleRef.current?.approve(requestId, updatedInput),
|
|
336
|
-
deny: (requestId, message) => handleRef.current?.deny(requestId, message),
|
|
400
|
+
deny: (requestId, message, interrupt) => handleRef.current?.deny(requestId, message, interrupt),
|
|
337
401
|
interrupt: () => handleRef.current?.interrupt(),
|
|
338
402
|
setPermissionMode: (mode) => handleRef.current?.setPermissionMode(mode),
|
|
339
403
|
setModel: (model) => handleRef.current?.setModel(model),
|
|
340
|
-
closeSession: () => handleRef.current?.closeSession()
|
|
404
|
+
closeSession: () => handleRef.current?.closeSession(),
|
|
405
|
+
reconnectNow
|
|
341
406
|
}), [
|
|
342
407
|
state,
|
|
343
408
|
connected,
|
|
344
|
-
|
|
409
|
+
connection,
|
|
410
|
+
protocolMismatch,
|
|
411
|
+
models,
|
|
412
|
+
handleState,
|
|
413
|
+
reconnectNow
|
|
414
|
+
]);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The session's profile catalog, fetched once and only when it could matter —
|
|
418
|
+
* i.e. when the engine has reported no models of its own.
|
|
419
|
+
*
|
|
420
|
+
* Fire-and-forget on purpose: an empty catalog is exactly the state a picker
|
|
421
|
+
* already handles, so a failed or 404'd `/profiles` (a server predating them)
|
|
422
|
+
* degrades to the old behaviour rather than raising an error about a list the
|
|
423
|
+
* operator may never open.
|
|
424
|
+
*/
|
|
425
|
+
function useProfileModelFallback(client, sessionId, state) {
|
|
426
|
+
const [catalog, setCatalog] = useState([]);
|
|
427
|
+
const profile = state.session?.profile;
|
|
428
|
+
const reported = state.models;
|
|
429
|
+
const hasReported = !!reported?.length;
|
|
430
|
+
useEffect(() => setCatalog([]), [sessionId]);
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (!profile || hasReported) return;
|
|
433
|
+
let cancelled = false;
|
|
434
|
+
client.listProfiles().then((response) => {
|
|
435
|
+
if (!cancelled) setCatalog(response.profiles.find((p) => p.name === profile)?.models ?? []);
|
|
436
|
+
}).catch(() => {});
|
|
437
|
+
return () => {
|
|
438
|
+
cancelled = true;
|
|
439
|
+
};
|
|
440
|
+
}, [
|
|
441
|
+
client,
|
|
442
|
+
profile,
|
|
443
|
+
hasReported
|
|
444
|
+
]);
|
|
445
|
+
return hasReported ? reported : catalog;
|
|
446
|
+
}
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/use-attachments.ts
|
|
449
|
+
/**
|
|
450
|
+
* How a media type reaches a model, in the capability record's vocabulary.
|
|
451
|
+
* `undefined` means this build can't classify it — the upload still goes,
|
|
452
|
+
* because the gateway's vocabulary is the authoritative one.
|
|
453
|
+
*/
|
|
454
|
+
function attachmentKind(mediaType) {
|
|
455
|
+
const type = mediaType.split(";")[0].trim().toLowerCase();
|
|
456
|
+
if (type.startsWith("image/")) return "image";
|
|
457
|
+
if (type === "application/pdf") return "pdf";
|
|
458
|
+
if (type.startsWith("text/")) return "text";
|
|
459
|
+
if (TEXTUAL_TYPES.has(type)) return "text";
|
|
460
|
+
}
|
|
461
|
+
/** Textual types whose media type doesn't start with `text/` — mirrors core. */
|
|
462
|
+
const TEXTUAL_TYPES = new Set([
|
|
463
|
+
"application/json",
|
|
464
|
+
"application/xml",
|
|
465
|
+
"application/yaml",
|
|
466
|
+
"application/x-yaml",
|
|
467
|
+
"application/toml",
|
|
468
|
+
"application/javascript",
|
|
469
|
+
"application/typescript",
|
|
470
|
+
"application/x-sh",
|
|
471
|
+
"application/sql"
|
|
472
|
+
]);
|
|
473
|
+
/** Longest edge an image is downscaled to before upload. Anthropic's own
|
|
474
|
+
* recommendation, and the same number the iOS client uses — a phone photo is
|
|
475
|
+
* several times this in each direction and costs tokens for nothing. */
|
|
476
|
+
const MAX_IMAGE_EDGE = 1568;
|
|
477
|
+
/**
|
|
478
|
+
* Stage, upload and track files for the next message of a session.
|
|
479
|
+
*
|
|
480
|
+
* Refusals happen as early as they can be known: a kind the capability record
|
|
481
|
+
* forswears never reaches the network (the gateway would 415 it), and everything
|
|
482
|
+
* else is the gateway's call — its vocabulary is authoritative, so an unknown
|
|
483
|
+
* media type is uploaded rather than guessed at.
|
|
484
|
+
*/
|
|
485
|
+
function useAttachments(client, sessionId, { capabilities, engine }) {
|
|
486
|
+
const [items, setItems] = useState([]);
|
|
487
|
+
const [error, setError] = useState();
|
|
488
|
+
const counter = useRef(0);
|
|
489
|
+
/** The originals, kept so a failed upload can be retried without re-picking. */
|
|
490
|
+
const fileByKey = useRef(/* @__PURE__ */ new Map());
|
|
491
|
+
/** Mirrors the live preview URLs so unmount can revoke them all — an unmount
|
|
492
|
+
* with blobs outstanding is a leak the GC does not clean up. */
|
|
493
|
+
const previewUrls = useRef([]);
|
|
494
|
+
previewUrls.current = items.flatMap((item) => item.previewUrl ? [item.previewUrl] : []);
|
|
495
|
+
const accepts = capabilities.attachments;
|
|
496
|
+
useEffect(() => () => {
|
|
497
|
+
for (const url of previewUrls.current) URL.revokeObjectURL(url);
|
|
498
|
+
}, []);
|
|
499
|
+
const patch = useCallback((key, next) => {
|
|
500
|
+
setItems((current) => current.map((item) => item.key === key ? {
|
|
501
|
+
...item,
|
|
502
|
+
...next
|
|
503
|
+
} : item));
|
|
504
|
+
}, []);
|
|
505
|
+
const upload = useCallback((key, file) => {
|
|
506
|
+
if (!sessionId) return;
|
|
507
|
+
patch(key, {
|
|
508
|
+
status: "uploading",
|
|
509
|
+
error: void 0
|
|
510
|
+
});
|
|
511
|
+
(async () => {
|
|
512
|
+
try {
|
|
513
|
+
const data = await prepare(file);
|
|
514
|
+
const uploaded = await client.uploadAttachment(sessionId, {
|
|
515
|
+
name: file.name,
|
|
516
|
+
mediaType: data.mediaType,
|
|
517
|
+
data: data.body
|
|
518
|
+
});
|
|
519
|
+
patch(key, {
|
|
520
|
+
status: "ready",
|
|
521
|
+
id: uploaded.id,
|
|
522
|
+
bytes: uploaded.bytes ?? file.size
|
|
523
|
+
});
|
|
524
|
+
} catch (e) {
|
|
525
|
+
patch(key, {
|
|
526
|
+
status: "failed",
|
|
527
|
+
error: e instanceof Error ? e.message : "Upload failed"
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
})();
|
|
531
|
+
}, [
|
|
532
|
+
client,
|
|
533
|
+
patch,
|
|
534
|
+
sessionId
|
|
535
|
+
]);
|
|
536
|
+
const add = useCallback((files) => {
|
|
537
|
+
const staged = [];
|
|
538
|
+
const pending = [];
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
const mediaType = file.type || "application/octet-stream";
|
|
541
|
+
const kind = attachmentKind(mediaType);
|
|
542
|
+
if (kind && !accepts.includes(kind)) {
|
|
543
|
+
setError(`The ${engine ?? "claude"} engine does not take ${kind} attachments.`);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const key = `att-${++counter.current}`;
|
|
547
|
+
staged.push({
|
|
548
|
+
key,
|
|
549
|
+
name: file.name,
|
|
550
|
+
mediaType,
|
|
551
|
+
bytes: file.size,
|
|
552
|
+
previewUrl: kind === "image" ? URL.createObjectURL(file) : void 0,
|
|
553
|
+
status: "uploading"
|
|
554
|
+
});
|
|
555
|
+
pending.push({
|
|
556
|
+
key,
|
|
557
|
+
file
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (staged.length === 0) return;
|
|
561
|
+
setItems((current) => [...current, ...staged]);
|
|
562
|
+
fileByKey.current = new Map([...fileByKey.current, ...pending.map(({ key, file }) => [key, file])]);
|
|
563
|
+
for (const { key, file } of pending) upload(key, file);
|
|
564
|
+
}, [
|
|
565
|
+
accepts,
|
|
566
|
+
engine,
|
|
567
|
+
upload
|
|
568
|
+
]);
|
|
569
|
+
const forget = useCallback((keys) => {
|
|
570
|
+
setItems((current) => {
|
|
571
|
+
for (const item of current) if (keys.includes(item.key) && item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
|
572
|
+
return current.filter((item) => !keys.includes(item.key));
|
|
573
|
+
});
|
|
574
|
+
for (const key of keys) fileByKey.current.delete(key);
|
|
575
|
+
}, []);
|
|
576
|
+
const remove = useCallback((key) => forget([key]), [forget]);
|
|
577
|
+
const clear = useCallback(() => {
|
|
578
|
+
setItems((current) => {
|
|
579
|
+
for (const item of current) if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
|
580
|
+
return [];
|
|
581
|
+
});
|
|
582
|
+
fileByKey.current.clear();
|
|
583
|
+
}, []);
|
|
584
|
+
const retry = useCallback((key) => {
|
|
585
|
+
const file = fileByKey.current.get(key);
|
|
586
|
+
if (file) upload(key, file);
|
|
587
|
+
}, [upload]);
|
|
588
|
+
return useMemo(() => ({
|
|
589
|
+
items,
|
|
590
|
+
readyIds: items.flatMap((item) => item.id ? [item.id] : []),
|
|
591
|
+
uploading: items.some((item) => item.status === "uploading"),
|
|
592
|
+
hasFailure: items.some((item) => item.status === "failed"),
|
|
593
|
+
accept: acceptAttribute(accepts),
|
|
594
|
+
disabled: accepts.length === 0 || !sessionId,
|
|
595
|
+
add,
|
|
596
|
+
retry,
|
|
597
|
+
remove,
|
|
598
|
+
clear,
|
|
599
|
+
error,
|
|
600
|
+
dismissError: () => setError(void 0)
|
|
601
|
+
}), [
|
|
602
|
+
items,
|
|
603
|
+
accepts,
|
|
604
|
+
sessionId,
|
|
605
|
+
add,
|
|
606
|
+
retry,
|
|
607
|
+
remove,
|
|
608
|
+
clear,
|
|
609
|
+
error
|
|
610
|
+
]);
|
|
611
|
+
}
|
|
612
|
+
/** What a file input should offer. The full set keeps the open door (anything —
|
|
613
|
+
* the gateway refuses the rest with a clear message); a narrower record narrows
|
|
614
|
+
* the browsing too, so most refusals never happen. */
|
|
615
|
+
function acceptAttribute(kinds) {
|
|
616
|
+
if (kinds.length === 0) return "";
|
|
617
|
+
const parts = [];
|
|
618
|
+
if (kinds.includes("image")) parts.push("image/*");
|
|
619
|
+
if (kinds.includes("pdf")) parts.push("application/pdf");
|
|
620
|
+
if (kinds.includes("text")) parts.push("text/*", ".md", ".json", ".yaml", ".yml", ".toml");
|
|
621
|
+
return kinds.length === 3 ? "" : parts.join(",");
|
|
622
|
+
}
|
|
623
|
+
const imaging = globalThis;
|
|
624
|
+
/**
|
|
625
|
+
* The bytes to upload, and the type they are.
|
|
626
|
+
*
|
|
627
|
+
* Oversized images are redrawn to {@link MAX_IMAGE_EDGE} first: a modern phone
|
|
628
|
+
* photo is 4000px on its long edge, which costs tokens for detail no model
|
|
629
|
+
* reads, and often exceeds the gateway's per-file cap outright. Everything else
|
|
630
|
+
* — and anything the browser can't decode — is uploaded as-is, so a failure here
|
|
631
|
+
* is never worse than not trying.
|
|
632
|
+
*/
|
|
633
|
+
async function prepare(file) {
|
|
634
|
+
const mediaType = file.type || "application/octet-stream";
|
|
635
|
+
const { createImageBitmap, document } = imaging;
|
|
636
|
+
if (!createImageBitmap || !document || !mediaType.startsWith("image/")) return {
|
|
637
|
+
body: file,
|
|
638
|
+
mediaType
|
|
639
|
+
};
|
|
640
|
+
if (mediaType === "image/gif") return {
|
|
641
|
+
body: file,
|
|
642
|
+
mediaType
|
|
643
|
+
};
|
|
644
|
+
try {
|
|
645
|
+
const bitmap = await createImageBitmap(file);
|
|
646
|
+
const longest = Math.max(bitmap.width, bitmap.height);
|
|
647
|
+
if (longest <= MAX_IMAGE_EDGE) {
|
|
648
|
+
bitmap.close();
|
|
649
|
+
return {
|
|
650
|
+
body: file,
|
|
651
|
+
mediaType
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const scale = MAX_IMAGE_EDGE / longest;
|
|
655
|
+
const canvas = document.createElement("canvas");
|
|
656
|
+
canvas.width = Math.round(bitmap.width * scale);
|
|
657
|
+
canvas.height = Math.round(bitmap.height * scale);
|
|
658
|
+
const context = canvas.getContext("2d");
|
|
659
|
+
if (!context) {
|
|
660
|
+
bitmap.close();
|
|
661
|
+
return {
|
|
662
|
+
body: file,
|
|
663
|
+
mediaType
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
667
|
+
bitmap.close();
|
|
668
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", .85));
|
|
669
|
+
return blob ? {
|
|
670
|
+
body: blob,
|
|
671
|
+
mediaType: "image/jpeg"
|
|
672
|
+
} : {
|
|
673
|
+
body: file,
|
|
674
|
+
mediaType
|
|
675
|
+
};
|
|
676
|
+
} catch {
|
|
677
|
+
return {
|
|
678
|
+
body: file,
|
|
679
|
+
mediaType
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/prompt-tokens.ts
|
|
685
|
+
/** Characters a command name may contain after the slash. Deliberately excludes
|
|
686
|
+
* `/`, so an absolute path pasted into a message (`/Users/me/…`) is not mistaken
|
|
687
|
+
* for a command; `:` is in because namespaced skills (`dev:wrapup`) are spelled
|
|
688
|
+
* that way. */
|
|
689
|
+
const COMMAND_BODY = /^[A-Za-z0-9\-_.:]+$/;
|
|
690
|
+
/** Trailing punctuation that belongs to the sentence, not the token — so
|
|
691
|
+
* "see @README.md." styles the path and leaves the period alone. */
|
|
692
|
+
const SENTENCE_TAIL = new Set([
|
|
693
|
+
".",
|
|
694
|
+
",",
|
|
695
|
+
";",
|
|
696
|
+
":",
|
|
697
|
+
"!",
|
|
698
|
+
"?",
|
|
699
|
+
")",
|
|
700
|
+
"]",
|
|
701
|
+
"}",
|
|
702
|
+
"\"",
|
|
703
|
+
"'"
|
|
704
|
+
]);
|
|
705
|
+
/**
|
|
706
|
+
* Every token in a sent message.
|
|
707
|
+
*
|
|
708
|
+
* Stricter than what a composer completes: a bare `@` is a token being typed, but
|
|
709
|
+
* in a sent message it is just an at sign.
|
|
710
|
+
*/
|
|
711
|
+
function scanPromptTokens(text) {
|
|
712
|
+
const tokens = [];
|
|
713
|
+
const words = /\S+/g;
|
|
714
|
+
let match;
|
|
715
|
+
while ((match = words.exec(text)) !== null) {
|
|
716
|
+
const word = match[0];
|
|
717
|
+
const kind = word[0] === "@" ? "file" : word[0] === "/" ? "command" : void 0;
|
|
718
|
+
if (!kind) continue;
|
|
719
|
+
let end = match.index + word.length;
|
|
720
|
+
while (end > match.index && SENTENCE_TAIL.has(text[end - 1])) end--;
|
|
721
|
+
const body = text.slice(match.index + 1, end);
|
|
722
|
+
if (!body) continue;
|
|
723
|
+
if (kind === "command" && !COMMAND_BODY.test(body)) continue;
|
|
724
|
+
tokens.push({
|
|
725
|
+
kind,
|
|
726
|
+
start: match.index,
|
|
727
|
+
end,
|
|
728
|
+
text: text.slice(match.index, end)
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
return tokens;
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region src/host-tree.ts
|
|
735
|
+
/**
|
|
736
|
+
* Flatten the loaded directories into the rows the tree shows.
|
|
737
|
+
*
|
|
738
|
+
* Pure, so the interesting part of a file tree — which nodes are visible at what
|
|
739
|
+
* depth once a few directories are expanded and one of them is still loading —
|
|
740
|
+
* is testable without a DOM or a gateway.
|
|
741
|
+
*
|
|
742
|
+
* Only *expanded* directories contribute children, and only if their listing has
|
|
743
|
+
* arrived. An expanded-but-unlisted directory yields its own row with
|
|
744
|
+
* `loading: true` and no children: expansion is a request the user already made,
|
|
745
|
+
* so the row must say the answer is coming rather than look like an empty folder.
|
|
746
|
+
*/
|
|
747
|
+
function flattenHostTree(root, dirs, expanded) {
|
|
748
|
+
const rows = [];
|
|
749
|
+
const walk = (dir, depth) => {
|
|
750
|
+
const state = dirs.get(dir);
|
|
751
|
+
if (!state) return;
|
|
752
|
+
for (const entry of state.entries) {
|
|
753
|
+
if (entry.type !== "dir") {
|
|
754
|
+
rows.push({
|
|
755
|
+
entry,
|
|
756
|
+
depth
|
|
757
|
+
});
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const isExpanded = expanded.has(entry.path);
|
|
761
|
+
const childState = dirs.get(entry.path);
|
|
762
|
+
rows.push({
|
|
763
|
+
entry,
|
|
764
|
+
depth,
|
|
765
|
+
expanded: isExpanded,
|
|
766
|
+
loading: isExpanded && !childState,
|
|
767
|
+
truncated: isExpanded ? childState?.truncated : void 0
|
|
768
|
+
});
|
|
769
|
+
if (isExpanded && childState) walk(entry.path, depth + 1);
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
walk(root, 0);
|
|
773
|
+
return rows;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Every ancestor of `path` below `root`, outermost first — the directories that
|
|
777
|
+
* must be expanded for `path` to be on screen.
|
|
778
|
+
*
|
|
779
|
+
* Returns `[]` when `path` is not under `root` rather than guessing: revealing a
|
|
780
|
+
* file the tree cannot contain is a no-op, not an error worth raising, and the
|
|
781
|
+
* caller has no better answer either.
|
|
782
|
+
*
|
|
783
|
+
* The prefix test is on a **path boundary** (`root` + `/`), so `/src/app` is not
|
|
784
|
+
* treated as living under `/src/a`.
|
|
785
|
+
*/
|
|
786
|
+
function ancestorsWithin(root, path) {
|
|
787
|
+
const base = root.endsWith("/") ? root.slice(0, -1) : root;
|
|
788
|
+
if (path === base || !path.startsWith(`${base}/`)) return [];
|
|
789
|
+
const rest = path.slice(base.length + 1).split("/");
|
|
790
|
+
const out = [];
|
|
791
|
+
let current = base;
|
|
792
|
+
for (const segment of rest.slice(0, -1)) {
|
|
793
|
+
current = `${current}/${segment}`;
|
|
794
|
+
out.push(current);
|
|
795
|
+
}
|
|
796
|
+
return out;
|
|
797
|
+
}
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/use-host-files.ts
|
|
800
|
+
/**
|
|
801
|
+
* Fuzzy file search rooted at a session's working directory — what an `@file`
|
|
802
|
+
* picker needs.
|
|
803
|
+
*
|
|
804
|
+
* Deliberately session-scoped: the server's `hostFiles.roots` are the security
|
|
805
|
+
* boundary, but what someone wants while talking to an agent is *this* project's
|
|
806
|
+
* tree, so this never offers the roots list.
|
|
807
|
+
*
|
|
808
|
+
* A gateway that answers 404 once has answered for the session: host files are
|
|
809
|
+
* either configured or they aren't, and the answer will not change while the cwd
|
|
810
|
+
* holds. Asking again on every character would be a request per keystroke for a
|
|
811
|
+
* feature that does not exist here.
|
|
812
|
+
*/
|
|
813
|
+
function useHostFileSearch(client, cwd) {
|
|
814
|
+
const [unsupported, setUnsupported] = useState(false);
|
|
815
|
+
const lastCwd = useRef(cwd);
|
|
816
|
+
useEffect(() => {
|
|
817
|
+
if (lastCwd.current !== cwd) {
|
|
818
|
+
lastCwd.current = cwd;
|
|
819
|
+
setUnsupported(false);
|
|
820
|
+
}
|
|
821
|
+
}, [cwd]);
|
|
822
|
+
const search = useCallback(async (query, options) => {
|
|
823
|
+
if (!cwd || unsupported) return [];
|
|
824
|
+
try {
|
|
825
|
+
const response = await client.findHostFiles(cwd, query, options?.limit ?? 8);
|
|
826
|
+
return options?.signal?.aborted ? [] : response.matches;
|
|
827
|
+
} catch (e) {
|
|
828
|
+
if (e instanceof WorkerDeckError && e.status === 404) setUnsupported(true);
|
|
829
|
+
return [];
|
|
830
|
+
}
|
|
831
|
+
}, [
|
|
832
|
+
client,
|
|
833
|
+
cwd,
|
|
834
|
+
unsupported
|
|
835
|
+
]);
|
|
836
|
+
return {
|
|
837
|
+
available: !!cwd && !unsupported,
|
|
838
|
+
search
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Whether host files are served here, and whether they may be written.
|
|
843
|
+
*
|
|
844
|
+
* One request per client, cached for the life of the hook: the roots and the
|
|
845
|
+
* write flag are gateway configuration, not session state, and they do not
|
|
846
|
+
* change while the tab is open.
|
|
847
|
+
*/
|
|
848
|
+
function useHostFileRoots(client) {
|
|
849
|
+
const [result, setResult] = useState({
|
|
850
|
+
available: false,
|
|
851
|
+
canWrite: false
|
|
852
|
+
});
|
|
853
|
+
useEffect(() => {
|
|
854
|
+
let cancelled = false;
|
|
855
|
+
client.listHostRoots().then((response) => {
|
|
856
|
+
if (!cancelled) setResult({
|
|
857
|
+
available: true,
|
|
858
|
+
canWrite: response.canWrite
|
|
859
|
+
});
|
|
860
|
+
}).catch(() => {
|
|
861
|
+
if (!cancelled) setResult({
|
|
862
|
+
available: false,
|
|
863
|
+
canWrite: false
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
return () => {
|
|
867
|
+
cancelled = true;
|
|
868
|
+
};
|
|
869
|
+
}, [client]);
|
|
870
|
+
return result;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* An expandable file tree rooted at a session's working directory.
|
|
874
|
+
*
|
|
875
|
+
* Rooted at the cwd rather than at `/fs/roots` for the same reason
|
|
876
|
+
* {@link useHostFileSearch} is: the roots are the *security* boundary the server
|
|
877
|
+
* enforces on every request, but what someone wants while watching an agent work
|
|
878
|
+
* is this project's tree. The roots may well be broader; showing them would
|
|
879
|
+
* offer navigation to directories the session has nothing to do with.
|
|
880
|
+
*
|
|
881
|
+
* Listings are cached per directory and kept across a collapse, so reopening a
|
|
882
|
+
* folder is instant and does not re-ask. That staleness is deliberate and
|
|
883
|
+
* bounded: `refresh` exists, and knowing when to call it is the *next* problem
|
|
884
|
+
* (the agent is editing this same tree), not something a tree can guess.
|
|
885
|
+
*
|
|
886
|
+
* Like the search hook, a 404 is answered once for the session: host files are
|
|
887
|
+
* either configured here or they are not.
|
|
888
|
+
*/
|
|
889
|
+
function useHostFileTree(client, cwd) {
|
|
890
|
+
const [dirs, setDirs] = useState(() => /* @__PURE__ */ new Map());
|
|
891
|
+
const [expanded, setExpanded] = useState(() => /* @__PURE__ */ new Set());
|
|
892
|
+
const [unsupported, setUnsupported] = useState(false);
|
|
893
|
+
const [error, setError] = useState();
|
|
894
|
+
const lastCwd = useRef(cwd);
|
|
895
|
+
useEffect(() => {
|
|
896
|
+
if (lastCwd.current === cwd) return;
|
|
897
|
+
lastCwd.current = cwd;
|
|
898
|
+
setDirs(/* @__PURE__ */ new Map());
|
|
899
|
+
setExpanded(/* @__PURE__ */ new Set());
|
|
900
|
+
setUnsupported(false);
|
|
901
|
+
setError(void 0);
|
|
902
|
+
}, [cwd]);
|
|
903
|
+
const alive = useRef(true);
|
|
904
|
+
useEffect(() => {
|
|
905
|
+
alive.current = true;
|
|
906
|
+
return () => {
|
|
907
|
+
alive.current = false;
|
|
908
|
+
};
|
|
909
|
+
}, []);
|
|
910
|
+
const requested = useRef(/* @__PURE__ */ new Set());
|
|
911
|
+
const list = useCallback((target, { force = false } = {}) => {
|
|
912
|
+
if (unsupported) return;
|
|
913
|
+
if (!force && requested.current.has(target)) return;
|
|
914
|
+
requested.current.add(target);
|
|
915
|
+
client.listHostDir(target).then((response) => {
|
|
916
|
+
if (!alive.current) return;
|
|
917
|
+
setDirs((previous) => {
|
|
918
|
+
const next = new Map(previous);
|
|
919
|
+
next.set(target, {
|
|
920
|
+
entries: response.entries,
|
|
921
|
+
truncated: response.truncated
|
|
922
|
+
});
|
|
923
|
+
return next;
|
|
924
|
+
});
|
|
925
|
+
}).catch((e) => {
|
|
926
|
+
if (!alive.current) return;
|
|
927
|
+
requested.current.delete(target);
|
|
928
|
+
if (e instanceof WorkerDeckError && e.status === 404) {
|
|
929
|
+
setUnsupported(true);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
setError(e instanceof Error ? e.message : "Could not read that directory");
|
|
933
|
+
});
|
|
934
|
+
}, [client, unsupported]);
|
|
935
|
+
useEffect(() => {
|
|
936
|
+
if (cwd) list(cwd);
|
|
937
|
+
}, [cwd, list]);
|
|
938
|
+
const toggle = useCallback((path) => {
|
|
939
|
+
setExpanded((previous) => {
|
|
940
|
+
const next = new Set(previous);
|
|
941
|
+
if (next.has(path)) next.delete(path);
|
|
942
|
+
else next.add(path);
|
|
943
|
+
return next;
|
|
944
|
+
});
|
|
945
|
+
list(path);
|
|
946
|
+
}, [list]);
|
|
947
|
+
const reveal = useCallback((path) => {
|
|
948
|
+
if (!cwd) return;
|
|
949
|
+
const ancestors = ancestorsWithin(cwd, path);
|
|
950
|
+
if (ancestors.length === 0) return;
|
|
951
|
+
for (const dir of ancestors) list(dir);
|
|
952
|
+
setExpanded((previous) => {
|
|
953
|
+
const next = new Set(previous);
|
|
954
|
+
for (const dir of ancestors) next.add(dir);
|
|
955
|
+
return next;
|
|
956
|
+
});
|
|
957
|
+
}, [cwd, list]);
|
|
958
|
+
const refresh = useCallback((path) => {
|
|
959
|
+
const target = path ?? cwd;
|
|
960
|
+
if (!target) return;
|
|
961
|
+
setError(void 0);
|
|
962
|
+
list(target, { force: true });
|
|
963
|
+
}, [cwd, list]);
|
|
964
|
+
const rows = useMemo(() => cwd ? flattenHostTree(cwd, dirs, expanded) : [], [
|
|
965
|
+
cwd,
|
|
966
|
+
dirs,
|
|
967
|
+
expanded
|
|
345
968
|
]);
|
|
969
|
+
return {
|
|
970
|
+
available: !!cwd && !unsupported,
|
|
971
|
+
root: cwd,
|
|
972
|
+
rows,
|
|
973
|
+
loading: !!cwd && !unsupported && !dirs.has(cwd) && !error,
|
|
974
|
+
error,
|
|
975
|
+
toggle,
|
|
976
|
+
reveal,
|
|
977
|
+
refresh
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
//#endregion
|
|
981
|
+
//#region src/use-session-info.ts
|
|
982
|
+
/**
|
|
983
|
+
* The registry's record of one session, over REST.
|
|
984
|
+
*
|
|
985
|
+
* Separate from {@link useClaudeSession} on purpose: that hook attaches a
|
|
986
|
+
* WebSocket and streams a transcript, which is far more than a caller needs to
|
|
987
|
+
* know a session's `cwd` or title — and a second attach would be a second
|
|
988
|
+
* client on the bridge, which is the one thing the bridge's "asks the first
|
|
989
|
+
* attached client" rule cannot tolerate.
|
|
990
|
+
*
|
|
991
|
+
* Fetched once per session id. The record is registry state, not a live feed;
|
|
992
|
+
* anything that changes during a run arrives on the session's event stream.
|
|
993
|
+
*/
|
|
994
|
+
function useSessionInfo(client, sessionId) {
|
|
995
|
+
const [info, setInfo] = useState();
|
|
996
|
+
const [loading, setLoading] = useState(!!sessionId);
|
|
997
|
+
const [error, setError] = useState();
|
|
998
|
+
useEffect(() => {
|
|
999
|
+
if (!sessionId) {
|
|
1000
|
+
setInfo(void 0);
|
|
1001
|
+
setLoading(false);
|
|
1002
|
+
setError(void 0);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
let cancelled = false;
|
|
1006
|
+
setLoading(true);
|
|
1007
|
+
setError(void 0);
|
|
1008
|
+
setInfo(void 0);
|
|
1009
|
+
client.getSession(sessionId).then((next) => {
|
|
1010
|
+
if (cancelled) return;
|
|
1011
|
+
setInfo(next);
|
|
1012
|
+
setLoading(false);
|
|
1013
|
+
}).catch((e) => {
|
|
1014
|
+
if (cancelled) return;
|
|
1015
|
+
setError(e instanceof Error ? e.message : "Session not found");
|
|
1016
|
+
setLoading(false);
|
|
1017
|
+
});
|
|
1018
|
+
return () => {
|
|
1019
|
+
cancelled = true;
|
|
1020
|
+
};
|
|
1021
|
+
}, [client, sessionId]);
|
|
1022
|
+
return {
|
|
1023
|
+
info,
|
|
1024
|
+
loading,
|
|
1025
|
+
error
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
//#endregion
|
|
1029
|
+
//#region src/open-files.ts
|
|
1030
|
+
/** Whether a tab has edits that are not on disk. Derived, so typing something
|
|
1031
|
+
* and undoing it back leaves the tab clean — which is what an editor should do
|
|
1032
|
+
* and what a boolean flag set on first keystroke would get wrong. */
|
|
1033
|
+
function isDirty(file) {
|
|
1034
|
+
return file.draft !== void 0 && file.draft !== file.content;
|
|
1035
|
+
}
|
|
1036
|
+
/** What a tab would write: its edits if it has any, else what it read. */
|
|
1037
|
+
function currentText(file) {
|
|
1038
|
+
return file.draft ?? file.content ?? "";
|
|
1039
|
+
}
|
|
1040
|
+
const initialOpenFilesState = { files: [] };
|
|
1041
|
+
/**
|
|
1042
|
+
* The tab strip and the editor's whole behaviour, as a pure function.
|
|
1043
|
+
*
|
|
1044
|
+
* The rules worth stating, because they are the ones a naive implementation
|
|
1045
|
+
* gets wrong:
|
|
1046
|
+
*
|
|
1047
|
+
* - **Opening an open path never re-reads it.** It focuses the tab. Re-reading
|
|
1048
|
+
* would silently discard that tab's unsaved edits on a double click.
|
|
1049
|
+
* - **Closing the focused tab focuses its right-hand neighbour**, falling back
|
|
1050
|
+
* to the left when it was last. Focusing "the first tab" instead is what makes
|
|
1051
|
+
* closing several tabs in a row jump the user around.
|
|
1052
|
+
* - **A successful save is applied against the text that was sent**, not against
|
|
1053
|
+
* the tab's current text. Typing during a save is normal; treating the write's
|
|
1054
|
+
* completion as "the tab is now clean" would silently drop those keystrokes.
|
|
1055
|
+
* - **Nothing here discards edits implicitly.** `revert` and `loaded` are the
|
|
1056
|
+
* only two things that clear a draft, and both are the direct result of
|
|
1057
|
+
* someone asking for it. The conditional write exists so a browser edit cannot
|
|
1058
|
+
* clobber the agent mid-run; this holds the same line in the other direction.
|
|
1059
|
+
*
|
|
1060
|
+
* Late results are addressed by path and dropped if that tab is gone, so a slow
|
|
1061
|
+
* read of a closed file cannot resurrect it.
|
|
1062
|
+
*/
|
|
1063
|
+
function openFilesReducer(state, action) {
|
|
1064
|
+
switch (action.type) {
|
|
1065
|
+
case "open": {
|
|
1066
|
+
if (state.files.some((f) => f.path === action.path)) return state.activePath === action.path ? state : {
|
|
1067
|
+
...state,
|
|
1068
|
+
activePath: action.path
|
|
1069
|
+
};
|
|
1070
|
+
const file = {
|
|
1071
|
+
path: action.path,
|
|
1072
|
+
name: baseName(action.path),
|
|
1073
|
+
status: "loading"
|
|
1074
|
+
};
|
|
1075
|
+
return {
|
|
1076
|
+
files: [...state.files, file],
|
|
1077
|
+
activePath: action.path
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
case "close": {
|
|
1081
|
+
const index = state.files.findIndex((f) => f.path === action.path);
|
|
1082
|
+
if (index === -1) return state;
|
|
1083
|
+
const files = state.files.filter((f) => f.path !== action.path);
|
|
1084
|
+
if (state.activePath !== action.path) return {
|
|
1085
|
+
...state,
|
|
1086
|
+
files
|
|
1087
|
+
};
|
|
1088
|
+
return {
|
|
1089
|
+
files,
|
|
1090
|
+
activePath: (files[index] ?? files[index - 1])?.path
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
case "closeAll": return initialOpenFilesState;
|
|
1094
|
+
case "activate":
|
|
1095
|
+
if (!state.files.some((f) => f.path === action.path)) return state;
|
|
1096
|
+
return state.activePath === action.path ? state : {
|
|
1097
|
+
...state,
|
|
1098
|
+
activePath: action.path
|
|
1099
|
+
};
|
|
1100
|
+
case "loaded": return patch(state, action.path, () => ({
|
|
1101
|
+
path: action.path,
|
|
1102
|
+
name: baseName(action.path),
|
|
1103
|
+
status: action.encoding === "utf8" ? "ready" : "binary",
|
|
1104
|
+
content: action.encoding === "utf8" ? action.content : void 0,
|
|
1105
|
+
bytes: action.bytes,
|
|
1106
|
+
hash: action.hash,
|
|
1107
|
+
modifiedAt: action.modifiedAt
|
|
1108
|
+
}));
|
|
1109
|
+
case "failed": return patch(state, action.path, (file) => ({
|
|
1110
|
+
...file,
|
|
1111
|
+
status: "error",
|
|
1112
|
+
error: action.error
|
|
1113
|
+
}));
|
|
1114
|
+
case "edit": return patch(state, action.path, (file) => file.status === "ready" ? {
|
|
1115
|
+
...file,
|
|
1116
|
+
draft: action.content
|
|
1117
|
+
} : file);
|
|
1118
|
+
case "revert": return patch(state, action.path, (file) => ({
|
|
1119
|
+
...file,
|
|
1120
|
+
draft: void 0,
|
|
1121
|
+
saveError: void 0,
|
|
1122
|
+
conflict: false
|
|
1123
|
+
}));
|
|
1124
|
+
case "saveStart": return patch(state, action.path, (file) => ({
|
|
1125
|
+
...file,
|
|
1126
|
+
saving: true,
|
|
1127
|
+
saveError: void 0,
|
|
1128
|
+
conflict: false
|
|
1129
|
+
}));
|
|
1130
|
+
case "saved": return patch(state, action.path, (file) => ({
|
|
1131
|
+
...file,
|
|
1132
|
+
saving: false,
|
|
1133
|
+
saveError: void 0,
|
|
1134
|
+
conflict: false,
|
|
1135
|
+
content: action.content,
|
|
1136
|
+
bytes: action.bytes,
|
|
1137
|
+
hash: action.hash,
|
|
1138
|
+
modifiedAt: action.modifiedAt,
|
|
1139
|
+
draft: file.draft === action.content ? void 0 : file.draft
|
|
1140
|
+
}));
|
|
1141
|
+
case "saveFailed": return patch(state, action.path, (file) => ({
|
|
1142
|
+
...file,
|
|
1143
|
+
saving: false,
|
|
1144
|
+
saveError: action.error,
|
|
1145
|
+
conflict: action.conflict ?? false
|
|
1146
|
+
}));
|
|
1147
|
+
case "dismissConflict": return patch(state, action.path, (file) => ({
|
|
1148
|
+
...file,
|
|
1149
|
+
conflict: false,
|
|
1150
|
+
saveError: void 0
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
/** Replace one file in place, preserving tab order; a no-op if it was closed
|
|
1155
|
+
* while the request was in flight. */
|
|
1156
|
+
function patch(state, path, next) {
|
|
1157
|
+
const index = state.files.findIndex((f) => f.path === path);
|
|
1158
|
+
if (index === -1) return state;
|
|
1159
|
+
const current = state.files[index];
|
|
1160
|
+
const updated = next(current);
|
|
1161
|
+
if (updated === current) return state;
|
|
1162
|
+
const files = state.files.slice();
|
|
1163
|
+
files[index] = updated;
|
|
1164
|
+
return {
|
|
1165
|
+
...state,
|
|
1166
|
+
files
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
/** Last path segment. Trailing slashes are not expected here — these are file
|
|
1170
|
+
* paths from `/fs/list` and `/fs/find` — but a bare `/` should still show as
|
|
1171
|
+
* something rather than as an empty tab. */
|
|
1172
|
+
function baseName(path) {
|
|
1173
|
+
const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
|
|
1174
|
+
return trimmed.slice(trimmed.lastIndexOf("/") + 1) || trimmed || path;
|
|
1175
|
+
}
|
|
1176
|
+
//#endregion
|
|
1177
|
+
//#region src/use-open-files.ts
|
|
1178
|
+
/**
|
|
1179
|
+
* The open-file tabs of a workspace: which files are open, which one is focused,
|
|
1180
|
+
* the bytes behind each, and the edits on top of them.
|
|
1181
|
+
*
|
|
1182
|
+
* Reads are fired from an effect keyed on "which tabs are still loading" rather
|
|
1183
|
+
* than from `open` itself, so the reducer stays pure and a tab that was opened,
|
|
1184
|
+
* closed and reopened does not carry a stale in-flight request with it.
|
|
1185
|
+
*
|
|
1186
|
+
* Deliberately **not** given the session's cwd: a tab is an absolute host path,
|
|
1187
|
+
* and where it came from — the tree, a search hit, a path in the transcript — is
|
|
1188
|
+
* the caller's business. Containment is the server's job on every `/fs/read` and
|
|
1189
|
+
* `/fs/write`, not something re-derived here from a directory this hook would
|
|
1190
|
+
* have to trust.
|
|
1191
|
+
*/
|
|
1192
|
+
function useOpenFiles(client) {
|
|
1193
|
+
const [state, dispatch] = useReducer(openFilesReducer, initialOpenFilesState);
|
|
1194
|
+
const requested = useRef(/* @__PURE__ */ new Set());
|
|
1195
|
+
const alive = useRef(true);
|
|
1196
|
+
useEffect(() => {
|
|
1197
|
+
alive.current = true;
|
|
1198
|
+
return () => {
|
|
1199
|
+
alive.current = false;
|
|
1200
|
+
};
|
|
1201
|
+
}, []);
|
|
1202
|
+
const latest = useRef(state);
|
|
1203
|
+
useEffect(() => {
|
|
1204
|
+
latest.current = state;
|
|
1205
|
+
}, [state]);
|
|
1206
|
+
const pending = state.files.filter((f) => f.status === "loading").map((f) => f.path).join("\n");
|
|
1207
|
+
const read = useCallback((path) => client.readHostFile(path).then((response) => {
|
|
1208
|
+
if (!alive.current) return void 0;
|
|
1209
|
+
dispatch({
|
|
1210
|
+
type: "loaded",
|
|
1211
|
+
path,
|
|
1212
|
+
content: response.content,
|
|
1213
|
+
encoding: response.encoding,
|
|
1214
|
+
bytes: response.bytes,
|
|
1215
|
+
hash: response.hash,
|
|
1216
|
+
modifiedAt: response.modifiedAt
|
|
1217
|
+
});
|
|
1218
|
+
return response;
|
|
1219
|
+
}), [client]);
|
|
1220
|
+
useEffect(() => {
|
|
1221
|
+
for (const path of pending ? pending.split("\n") : []) {
|
|
1222
|
+
if (requested.current.has(path)) continue;
|
|
1223
|
+
requested.current.add(path);
|
|
1224
|
+
read(path).catch((e) => {
|
|
1225
|
+
if (!alive.current) return;
|
|
1226
|
+
dispatch({
|
|
1227
|
+
type: "failed",
|
|
1228
|
+
path,
|
|
1229
|
+
error: e instanceof Error ? e.message : "Could not read that file"
|
|
1230
|
+
});
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}, [read, pending]);
|
|
1234
|
+
const open = useCallback((path) => dispatch({
|
|
1235
|
+
type: "open",
|
|
1236
|
+
path
|
|
1237
|
+
}), []);
|
|
1238
|
+
const close = useCallback((path) => {
|
|
1239
|
+
requested.current.delete(path);
|
|
1240
|
+
dispatch({
|
|
1241
|
+
type: "close",
|
|
1242
|
+
path
|
|
1243
|
+
});
|
|
1244
|
+
}, []);
|
|
1245
|
+
const closeAll = useCallback(() => {
|
|
1246
|
+
requested.current.clear();
|
|
1247
|
+
dispatch({ type: "closeAll" });
|
|
1248
|
+
}, []);
|
|
1249
|
+
const activate = useCallback((path) => dispatch({
|
|
1250
|
+
type: "activate",
|
|
1251
|
+
path
|
|
1252
|
+
}), []);
|
|
1253
|
+
const edit = useCallback((path, content) => dispatch({
|
|
1254
|
+
type: "edit",
|
|
1255
|
+
path,
|
|
1256
|
+
content
|
|
1257
|
+
}), []);
|
|
1258
|
+
const revert = useCallback((path) => dispatch({
|
|
1259
|
+
type: "revert",
|
|
1260
|
+
path
|
|
1261
|
+
}), []);
|
|
1262
|
+
const dismissConflict = useCallback((path) => dispatch({
|
|
1263
|
+
type: "dismissConflict",
|
|
1264
|
+
path
|
|
1265
|
+
}), []);
|
|
1266
|
+
const reload = useCallback((path) => {
|
|
1267
|
+
requested.current.add(path);
|
|
1268
|
+
read(path).catch((e) => {
|
|
1269
|
+
if (!alive.current) return;
|
|
1270
|
+
dispatch({
|
|
1271
|
+
type: "failed",
|
|
1272
|
+
path,
|
|
1273
|
+
error: e instanceof Error ? e.message : "Could not re-read that file"
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
}, [read]);
|
|
1277
|
+
/** One conditional write. Shared by `save` and `overwrite`, which differ only
|
|
1278
|
+
* in where the hash came from. */
|
|
1279
|
+
const write = useCallback(async (path, text, expectedHash) => {
|
|
1280
|
+
try {
|
|
1281
|
+
const response = await client.writeHostFile({
|
|
1282
|
+
path,
|
|
1283
|
+
content: text,
|
|
1284
|
+
expectedHash
|
|
1285
|
+
});
|
|
1286
|
+
if (!alive.current) return;
|
|
1287
|
+
dispatch({
|
|
1288
|
+
type: "saved",
|
|
1289
|
+
path,
|
|
1290
|
+
content: text,
|
|
1291
|
+
bytes: response.bytes,
|
|
1292
|
+
hash: response.hash,
|
|
1293
|
+
modifiedAt: response.modifiedAt
|
|
1294
|
+
});
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
if (!alive.current) return;
|
|
1297
|
+
const conflict = e instanceof WorkerDeckError && e.status === 409;
|
|
1298
|
+
dispatch({
|
|
1299
|
+
type: "saveFailed",
|
|
1300
|
+
path,
|
|
1301
|
+
conflict,
|
|
1302
|
+
error: conflict ? "This file changed on disk since you opened it." : e instanceof Error ? e.message : "Could not save that file"
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
}, [client]);
|
|
1306
|
+
const save = useCallback(async (path) => {
|
|
1307
|
+
const file = latest.current.files.find((f) => f.path === path);
|
|
1308
|
+
if (!file || file.saving || !isDirty(file)) return;
|
|
1309
|
+
dispatch({
|
|
1310
|
+
type: "saveStart",
|
|
1311
|
+
path
|
|
1312
|
+
});
|
|
1313
|
+
await write(path, currentText(file), file.hash);
|
|
1314
|
+
}, [write]);
|
|
1315
|
+
const overwrite = useCallback(async (path) => {
|
|
1316
|
+
const file = latest.current.files.find((f) => f.path === path);
|
|
1317
|
+
if (!file || file.saving) return;
|
|
1318
|
+
const mine = currentText(file);
|
|
1319
|
+
dispatch({
|
|
1320
|
+
type: "saveStart",
|
|
1321
|
+
path
|
|
1322
|
+
});
|
|
1323
|
+
try {
|
|
1324
|
+
const fresh = await client.readHostFile(path);
|
|
1325
|
+
if (!alive.current) return;
|
|
1326
|
+
await write(path, mine, fresh.hash);
|
|
1327
|
+
} catch (e) {
|
|
1328
|
+
if (!alive.current) return;
|
|
1329
|
+
dispatch({
|
|
1330
|
+
type: "saveFailed",
|
|
1331
|
+
path,
|
|
1332
|
+
error: e instanceof Error ? e.message : "Could not save that file"
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}, [client, write]);
|
|
1336
|
+
const active = useMemo(() => state.files.find((f) => f.path === state.activePath), [state.files, state.activePath]);
|
|
1337
|
+
const hasUnsaved = useMemo(() => state.files.some(isDirty), [state.files]);
|
|
1338
|
+
return {
|
|
1339
|
+
...state,
|
|
1340
|
+
active,
|
|
1341
|
+
hasUnsaved,
|
|
1342
|
+
open,
|
|
1343
|
+
close,
|
|
1344
|
+
closeAll,
|
|
1345
|
+
activate,
|
|
1346
|
+
edit,
|
|
1347
|
+
save,
|
|
1348
|
+
revert,
|
|
1349
|
+
reload,
|
|
1350
|
+
overwrite,
|
|
1351
|
+
dismissConflict
|
|
1352
|
+
};
|
|
346
1353
|
}
|
|
347
1354
|
//#endregion
|
|
348
1355
|
//#region src/tool-host.ts
|
|
@@ -515,6 +1522,85 @@ function useToolCallHost(handle, options = {}) {
|
|
|
515
1522
|
return { executions };
|
|
516
1523
|
}
|
|
517
1524
|
//#endregion
|
|
518
|
-
|
|
1525
|
+
//#region src/recap.ts
|
|
1526
|
+
/**
|
|
1527
|
+
* Summarize the items from `fromIndex` onward — the boundary being the number
|
|
1528
|
+
* of items that existed when the session was last looked at.
|
|
1529
|
+
*
|
|
1530
|
+
* An out-of-range boundary is clamped rather than rejected: a transcript can
|
|
1531
|
+
* *shrink* (a `/clear`, a fresh attach after a compaction), and the honest
|
|
1532
|
+
* reading of "you last saw 40 items, there are now 12" is "everything here is
|
|
1533
|
+
* new", not a negative count.
|
|
1534
|
+
*/
|
|
1535
|
+
function summarizeSince(state, fromIndex) {
|
|
1536
|
+
const start = Math.max(0, Math.min(fromIndex, state.items.length));
|
|
1537
|
+
const fresh = state.items.slice(start);
|
|
1538
|
+
const toolCounts = /* @__PURE__ */ new Map();
|
|
1539
|
+
let turns = 0;
|
|
1540
|
+
let replies = 0;
|
|
1541
|
+
let tools = 0;
|
|
1542
|
+
let files = 0;
|
|
1543
|
+
let errors = 0;
|
|
1544
|
+
for (const item of fresh) switch (item.kind) {
|
|
1545
|
+
case "turn_result":
|
|
1546
|
+
turns += 1;
|
|
1547
|
+
if (item.isError) errors += 1;
|
|
1548
|
+
break;
|
|
1549
|
+
case "assistant_text":
|
|
1550
|
+
replies += 1;
|
|
1551
|
+
break;
|
|
1552
|
+
case "tool_call":
|
|
1553
|
+
tools += 1;
|
|
1554
|
+
toolCounts.set(item.name, (toolCounts.get(item.name) ?? 0) + 1);
|
|
1555
|
+
if (item.status === "failed" || item.result?.isError) errors += 1;
|
|
1556
|
+
break;
|
|
1557
|
+
case "file_delivered":
|
|
1558
|
+
files += 1;
|
|
1559
|
+
break;
|
|
1560
|
+
case "notice":
|
|
1561
|
+
if (item.level === "error") errors += 1;
|
|
1562
|
+
break;
|
|
1563
|
+
default: break;
|
|
1564
|
+
}
|
|
1565
|
+
const toolNames = [...toolCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name]) => name);
|
|
1566
|
+
const pending = state.pendingApprovals?.length ?? 0;
|
|
1567
|
+
return {
|
|
1568
|
+
turns,
|
|
1569
|
+
replies,
|
|
1570
|
+
tools,
|
|
1571
|
+
toolNames,
|
|
1572
|
+
files,
|
|
1573
|
+
errors,
|
|
1574
|
+
pending,
|
|
1575
|
+
any: turns + replies + tools + files + errors + pending > 0
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The recap as one line of text, in the order a person reads it: what got done,
|
|
1580
|
+
* what it used, what went wrong, what is waiting.
|
|
1581
|
+
*
|
|
1582
|
+
* Returns `undefined` when there is nothing to say, so a caller can render the
|
|
1583
|
+
* row or not on the value alone.
|
|
1584
|
+
*/
|
|
1585
|
+
function recapLine(summary) {
|
|
1586
|
+
if (!summary.any) return void 0;
|
|
1587
|
+
const parts = [];
|
|
1588
|
+
if (summary.turns > 0) parts.push(plural(summary.turns, "turn"));
|
|
1589
|
+
else if (summary.replies > 0) parts.push(plural(summary.replies, "reply", "replies"));
|
|
1590
|
+
if (summary.tools > 0) {
|
|
1591
|
+
const named = summary.toolNames.slice(0, 3).join(", ");
|
|
1592
|
+
const rest = summary.toolNames.length - 3;
|
|
1593
|
+
parts.push(`${plural(summary.tools, "tool call")}${named ? ` (${named}${rest > 0 ? `, +${rest}` : ""})` : ""}`);
|
|
1594
|
+
}
|
|
1595
|
+
if (summary.files > 0) parts.push(plural(summary.files, "file"));
|
|
1596
|
+
if (summary.errors > 0) parts.push(plural(summary.errors, "error"));
|
|
1597
|
+
if (summary.pending > 0) parts.push(`${plural(summary.pending, "approval")} waiting`);
|
|
1598
|
+
return parts.join(" · ");
|
|
1599
|
+
}
|
|
1600
|
+
function plural(count, one, many = `${one}s`) {
|
|
1601
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
1602
|
+
}
|
|
1603
|
+
//#endregion
|
|
1604
|
+
export { ancestorsWithin, applyEvent, attachmentKind, createToolCallHost, currentText, flattenHostTree, initialOpenFilesState, initialTranscriptState, isDirty, openFilesReducer, rateLimitWindows, recapLine, scanPromptTokens, seedFromSessionInfo, summarizeSince, useAttachments, useClaudeSession, useHostFileRoots, useHostFileSearch, useHostFileTree, useOpenFiles, useSessionInfo, useToolCallHost };
|
|
519
1605
|
|
|
520
1606
|
//# sourceMappingURL=index.mjs.map
|