akanjs 3.0.0-beta.11 → 3.0.0-beta.12
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/client/router.ts +36 -11
- package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/rscClient.tsx +31 -1
- package/server/rscClientFetch.ts +6 -0
- package/store/agent/AgentCursor.ts +103 -9
- package/store/agent/AgentVisual.ts +124 -19
- package/store/agent/ScreenFlash.ts +22 -1
- package/store/agent/ScreenTarget.ts +8 -7
- package/store/agent/StoreSurfaceSource.ts +8 -0
- package/types/client/router.d.ts +18 -2
- package/types/server/rscClientFetch.d.ts +2 -0
- package/types/store/agent/AgentCursor.d.ts +26 -3
- package/types/store/agent/AgentVisual.d.ts +23 -5
- package/types/store/agent/ScreenFlash.d.ts +11 -1
- package/types/store/agent/ScreenTarget.d.ts +2 -0
- package/types/ui/Agent/Chat.d.ts +4 -3
- package/types/ui/Agent/Zone.d.ts +4 -3
- package/types/ui/agentAttrs.d.ts +6 -1
- package/types/vendor/use-agentic/AgentSession.d.ts +10 -1
- package/types/vendor/use-agentic/ToolRunner.d.ts +5 -1
- package/ui/Agent/Chat.tsx +4 -3
- package/ui/Agent/Zone.tsx +4 -3
- package/ui/Agent/agentSessionOf.ts +2 -2
- package/ui/System/Client.tsx +10 -3
- package/ui/Tab/Menu.tsx +1 -1
- package/ui/agentAttrs.ts +11 -2
- package/ui/styles.css +93 -0
- package/vendor/use-agentic/AgentSession.ts +12 -1
- package/vendor/use-agentic/ToolRunner.ts +6 -2
package/client/router.ts
CHANGED
|
@@ -14,14 +14,15 @@ export interface DeepLinkOptions extends RouteOptions {
|
|
|
14
14
|
const DEEP_LINK_STACK_STEP_DELAY = 450;
|
|
15
15
|
|
|
16
16
|
export interface RouterInstance {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/** May answer a promise that rejects when the route refused to move — see `Router.navigation`. */
|
|
18
|
+
push: (href: string, routeOptions?: RouteOptions) => void | Promise<void>;
|
|
19
|
+
replace: (href: string, routeOptions?: RouteOptions) => void | Promise<void>;
|
|
19
20
|
back: (routeOptions?: RouteOptions) => void;
|
|
20
21
|
refresh: () => void;
|
|
21
22
|
}
|
|
22
23
|
interface InternalRouterInstance {
|
|
23
|
-
push: (href: string, routeOptions?: RouteOptions) => void
|
|
24
|
-
replace: (href: string, routeOptions?: RouteOptions) => void
|
|
24
|
+
push: (href: string, routeOptions?: RouteOptions) => void | Promise<void>;
|
|
25
|
+
replace: (href: string, routeOptions?: RouteOptions) => void | Promise<void>;
|
|
25
26
|
back: (routeOptions?: RouteOptions) => void;
|
|
26
27
|
refresh: () => void;
|
|
27
28
|
}
|
|
@@ -163,6 +164,7 @@ class Router {
|
|
|
163
164
|
#lang = parseAkanI18nEnv().defaultLocale;
|
|
164
165
|
#routePaths = new Set<string>();
|
|
165
166
|
#indexPath = "/";
|
|
167
|
+
#navigation: Promise<void> = Promise.resolve();
|
|
166
168
|
#historyIdx = 0;
|
|
167
169
|
#instance: InternalRouterInstance = {
|
|
168
170
|
push: (href: string) => {
|
|
@@ -210,14 +212,14 @@ class Router {
|
|
|
210
212
|
const pathInfo = this.#getPathInfo(href);
|
|
211
213
|
const navigationPathInfo = this.#getNavigationPathInfo(href);
|
|
212
214
|
this.#postPathChange(pathInfo);
|
|
213
|
-
router.push(navigationPathInfo.href, routeOptions);
|
|
215
|
+
return router.push(navigationPathInfo.href, routeOptions);
|
|
214
216
|
},
|
|
215
217
|
replace: (href: string, routeOptions) => {
|
|
216
218
|
const router = options.router;
|
|
217
219
|
const pathInfo = this.#getPathInfo(href);
|
|
218
220
|
const navigationPathInfo = this.#getNavigationPathInfo(href);
|
|
219
221
|
this.#postPathChange(pathInfo);
|
|
220
|
-
router.replace(navigationPathInfo.href, routeOptions);
|
|
222
|
+
return router.replace(navigationPathInfo.href, routeOptions);
|
|
221
223
|
},
|
|
222
224
|
back: () => {
|
|
223
225
|
const router = options.router;
|
|
@@ -238,7 +240,7 @@ class Router {
|
|
|
238
240
|
push: (href: string, routeOptions) => {
|
|
239
241
|
const { path, pathname, hash, href: fullHref } = this.#getPathInfo(href);
|
|
240
242
|
this.#postPathChange({ path, pathname, hash });
|
|
241
|
-
options.router.push(this.#withCsrRuntimeSearchParams(fullHref), routeOptions);
|
|
243
|
+
return options.router.push(this.#withCsrRuntimeSearchParams(fullHref), routeOptions);
|
|
242
244
|
},
|
|
243
245
|
replace: (href: string, routeOptions) => {
|
|
244
246
|
const { path, pathname, hash, href: fullHref } = this.#getPathInfo(href);
|
|
@@ -367,18 +369,34 @@ class Router {
|
|
|
367
369
|
}
|
|
368
370
|
push(href: string, routeOptions?: RouteOptions) {
|
|
369
371
|
this.#checkInitialized();
|
|
370
|
-
this.#instance.push(href, routeOptions);
|
|
372
|
+
this.#track(this.#instance.push(href, routeOptions));
|
|
371
373
|
this.#rememberHistoryState("push");
|
|
372
374
|
this.#postDevSyncNavigation("push", href);
|
|
373
375
|
return undefined as never;
|
|
374
376
|
}
|
|
375
377
|
replace(href: string, routeOptions?: RouteOptions) {
|
|
376
378
|
this.#checkInitialized();
|
|
377
|
-
this.#instance.replace(href, routeOptions);
|
|
379
|
+
this.#track(this.#instance.replace(href, routeOptions));
|
|
378
380
|
this.#rememberHistoryState("replace");
|
|
379
381
|
this.#postDevSyncNavigation("replace", href);
|
|
380
382
|
return undefined as never;
|
|
381
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* The navigation `push` / `replace` last started, for a caller that has to know whether it landed. **Rejects
|
|
386
|
+
* when the route refused to move** — a target that resolves to nothing leaves the page where it was instead of
|
|
387
|
+
* replacing it, and `push` returns long before that is known, so this is the only place it can be reported.
|
|
388
|
+
*
|
|
389
|
+
* A method rather than a getter: `router` is a Proxy that binds functions to the real instance, and a getter
|
|
390
|
+
* reached through it would run with the Proxy as `this` and fail on the private field.
|
|
391
|
+
*/
|
|
392
|
+
navigation(): Promise<void> {
|
|
393
|
+
return this.#navigation;
|
|
394
|
+
}
|
|
395
|
+
#track(result: void | Promise<void>) {
|
|
396
|
+
this.#navigation = Promise.resolve(result);
|
|
397
|
+
|
|
398
|
+
void this.#navigation.catch(() => undefined);
|
|
399
|
+
}
|
|
382
400
|
canGoBack() {
|
|
383
401
|
if (getEnv().side === "server") return false;
|
|
384
402
|
return this.#historyIdx > 0;
|
|
@@ -444,10 +462,17 @@ class Router {
|
|
|
444
462
|
this.#instance.replace(`${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`);
|
|
445
463
|
return undefined as never;
|
|
446
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* A pathname with the locale and base-path segments taken off — the app-internal route it names, which is what
|
|
467
|
+
* a tool argument and a `<a href>` have in common. Unguarded, unlike `getPath`, whose default argument is the
|
|
468
|
+
* one thing in it that needs a browser.
|
|
469
|
+
*/
|
|
470
|
+
routeOf(pathname: string) {
|
|
471
|
+
return getPathInfo(pathname, this.#lang, this.#prefix).path;
|
|
472
|
+
}
|
|
447
473
|
getPath(pathname = window.location.pathname) {
|
|
448
474
|
if (getEnv().side === "server") throw new Error("getPath is only available in client side");
|
|
449
|
-
|
|
450
|
-
return path;
|
|
475
|
+
return this.routeOf(pathname);
|
|
451
476
|
}
|
|
452
477
|
getFullPath(withLang = true) {
|
|
453
478
|
if (getEnv().side === "server") throw new Error("getPath is only available in client side");
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/rscClient.tsx
CHANGED
|
@@ -82,6 +82,7 @@ type RscFetchResult =
|
|
|
82
82
|
outletKey: string;
|
|
83
83
|
headSnapshot: AkanHeadSnapshotV1;
|
|
84
84
|
}
|
|
85
|
+
| { type: "not-found" }
|
|
85
86
|
| { type: "redirected"; status?: number };
|
|
86
87
|
const MAX_RSC_CACHE_ENTRIES = 32;
|
|
87
88
|
let documentNavigationFallbackInFlight = false;
|
|
@@ -93,6 +94,22 @@ class RscRedirectNavigationStarted extends Error {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A navigation whose target resolves to nothing. Thrown instead of committing the payload, and deliberately not
|
|
99
|
+
* converted into a document navigation: the page the user is on is a working page, and trading it for a 404 — or,
|
|
100
|
+
* before this, for the empty tree `0:null` decodes to — throws away everything mounted on it, an in-page agent's
|
|
101
|
+
* session included. The route stays where it is and the caller is told.
|
|
102
|
+
*
|
|
103
|
+
* Recognised across bundles by `name` rather than `instanceof`: the RSC client is inlined into more than one
|
|
104
|
+
* browser bundle, so the class identity a given file holds is not always the one that threw.
|
|
105
|
+
*/
|
|
106
|
+
class RscRouteNotFound extends Error {
|
|
107
|
+
constructor(readonly href: string) {
|
|
108
|
+
super(`[rscClient] no route at ${href}`);
|
|
109
|
+
this.name = "RscRouteNotFound";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
96
113
|
function createInitialRscStream(): ReadableStream<Uint8Array> {
|
|
97
114
|
return new ReadableStream<Uint8Array>({
|
|
98
115
|
start(controller) {
|
|
@@ -229,6 +246,7 @@ async function fetchRsc(
|
|
|
229
246
|
shouldApplyNavigation,
|
|
230
247
|
});
|
|
231
248
|
if (responseResult.type === "redirected") return responseResult;
|
|
249
|
+
if (responseResult.type === "not-found") return responseResult;
|
|
232
250
|
if (responseResult.type === "patch") {
|
|
233
251
|
const patchResult = await validateRscPatchForGuardedCommit({
|
|
234
252
|
partialCommitEnabled: isAkanRscPartialCommitEnabled(),
|
|
@@ -364,6 +382,11 @@ function Root(): ReactNode {
|
|
|
364
382
|
shouldApplyNavigation: () => navId === navigationSeq,
|
|
365
383
|
});
|
|
366
384
|
if (next.type === "redirected") return;
|
|
385
|
+
|
|
386
|
+
if (next.type === "not-found") {
|
|
387
|
+
console.warn(`[rscClient] refresh target ${target} no longer resolves; keeping the current page`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
367
390
|
if (next.type === "patched") return;
|
|
368
391
|
observeRscNavigationNode({
|
|
369
392
|
cache: rscCache,
|
|
@@ -445,6 +468,7 @@ function Root(): ReactNode {
|
|
|
445
468
|
shouldApplyNavigation: () => navId === navigationSeq,
|
|
446
469
|
});
|
|
447
470
|
if (fetched.type === "redirected") return;
|
|
471
|
+
if (fetched.type === "not-found") throw new RscRouteNotFound(target);
|
|
448
472
|
if (fetched.type === "patched") {
|
|
449
473
|
if (navId !== navigationSeq) return;
|
|
450
474
|
if (
|
|
@@ -476,6 +500,7 @@ function Root(): ReactNode {
|
|
|
476
500
|
shouldApplyNavigation: () => navId === navigationSeq,
|
|
477
501
|
});
|
|
478
502
|
if (fallback.type === "redirected") return;
|
|
503
|
+
if (fallback.type === "not-found") throw new RscRouteNotFound(target);
|
|
479
504
|
if (fallback.type === "patched") throw new Error("[rscClient] full fallback unexpectedly returned a patch");
|
|
480
505
|
nextNode = fallback.node;
|
|
481
506
|
} else {
|
|
@@ -517,6 +542,8 @@ function Root(): ReactNode {
|
|
|
517
542
|
if (committed) rememberCommittedRouteState(nextNode);
|
|
518
543
|
} catch (error) {
|
|
519
544
|
if (error instanceof RscRedirectNavigationStarted) return;
|
|
545
|
+
|
|
546
|
+
if (error instanceof RscRouteNotFound) throw error;
|
|
520
547
|
if (navId === navigationSeq) hardNavigateAfterRscFailure(target, options.replace, error);
|
|
521
548
|
}
|
|
522
549
|
};
|
|
@@ -525,7 +552,10 @@ function Root(): ReactNode {
|
|
|
525
552
|
}
|
|
526
553
|
|
|
527
554
|
window.addEventListener("popstate", () => {
|
|
528
|
-
|
|
555
|
+
|
|
556
|
+
void globalThis
|
|
557
|
+
.__AKAN_RSC_NAVIGATE__?.(window.location.href, { replace: true, scrollToTop: false })
|
|
558
|
+
?.catch((error: unknown) => hardNavigateAfterRscFailure(window.location.href, true, error));
|
|
529
559
|
window.setTimeout(() => {
|
|
530
560
|
if (globalThis.__AKAN_DEV_SYNC_NAVIGATION_APPLYING__) return;
|
|
531
561
|
const href = globalThis.__AKAN_GET_SYNC_ROUTE_HREF__?.(window.location.href) ?? window.location.href;
|
package/server/rscClientFetch.ts
CHANGED
|
@@ -10,6 +10,7 @@ type RscNavigate = (href: string, options?: { replace?: boolean; scrollToTop?: b
|
|
|
10
10
|
export type RscClientFetchResponseResult =
|
|
11
11
|
| { type: "response"; response: Response }
|
|
12
12
|
| { type: "patch"; response: Response; patch: AkanRscPatchMetadata }
|
|
13
|
+
| { type: "not-found" }
|
|
13
14
|
| { type: "redirected"; status?: number };
|
|
14
15
|
|
|
15
16
|
export async function fetchRscNavigationResponse(
|
|
@@ -41,6 +42,11 @@ export async function fetchRscNavigationResponse(
|
|
|
41
42
|
if (shouldApplyNavigation()) await options.navigate?.(redirect, { replace: method !== "push", scrollToTop: true });
|
|
42
43
|
return { type: "redirected", status };
|
|
43
44
|
}
|
|
45
|
+
|
|
46
|
+
if (response.status === 404) {
|
|
47
|
+
await response.body?.cancel();
|
|
48
|
+
return { type: "not-found" };
|
|
49
|
+
}
|
|
44
50
|
if (response.headers.get("X-Akan-Rsc-Partial") === "patch") {
|
|
45
51
|
const patch = readAkanRscPatchMetadataResponseHeaders(response.headers);
|
|
46
52
|
if (options.sendRouterState === false) throw new Error("[rscClient] RSC full fallback returned a patch response");
|
|
@@ -18,26 +18,112 @@ export class AgentCursor {
|
|
|
18
18
|
static readonly travelMs = 320;
|
|
19
19
|
/** Under this far, the glide is skipped: a third of a second to cross fifty pixels reads as lag, not motion. */
|
|
20
20
|
static readonly travelFrom = 200;
|
|
21
|
-
/** How
|
|
21
|
+
/** How far off the control the pointer drifts before it starts waiting — just clear of its box. */
|
|
22
|
+
static readonly driftBy = 18;
|
|
23
|
+
/**
|
|
24
|
+
* How long after the last call the pointer stays before fading, for a host that reports no turn boundary. A
|
|
25
|
+
* host that does calls `hold` instead, because this is the wrong unit: between two calls of one turn the model
|
|
26
|
+
* is writing, which takes longer than this, and the pointer used to vanish and come back for every call.
|
|
27
|
+
*/
|
|
22
28
|
static readonly idleMs = 1400;
|
|
29
|
+
static readonly thinkingClass = "akan-agent-cursor-thinking";
|
|
30
|
+
static readonly scrollingClass = "akan-agent-cursor-scrolling";
|
|
23
31
|
|
|
24
32
|
static #el: HTMLElement | null = null;
|
|
25
33
|
static #at: { x: number; y: number } | null = null;
|
|
26
34
|
static #idle: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
static #held = false;
|
|
36
|
+
static #pressed: { top: number; left: number; right: number; bottom: number } | null = null;
|
|
27
37
|
|
|
28
|
-
/** Travels to the control once it has stopped moving, then presses it. */
|
|
29
|
-
static press(target: HTMLElement) {
|
|
30
|
-
if (typeof document === "undefined") return;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
/** Travels to the control once it has stopped moving, then presses it. Resolves when the press has landed. */
|
|
39
|
+
static press(target: HTMLElement): Promise<void> {
|
|
40
|
+
if (typeof document === "undefined") return Promise.resolve();
|
|
41
|
+
AgentCursor.#el?.classList.remove(AgentCursor.thinkingClass);
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
ScreenFlash.whenStill(target, () => {
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
if (!ScreenFlash.onTop(target)) {
|
|
46
|
+
AgentCursor.hide();
|
|
47
|
+
resolve();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const { top, left, width, height, right, bottom } = target.getBoundingClientRect();
|
|
51
|
+
AgentCursor.#pressed = { top, left, right, bottom };
|
|
52
|
+
const travel = AgentCursor.#moveTo(left + width / 2, top + height / 2);
|
|
53
|
+
|
|
54
|
+
if (travel)
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
AgentCursor.#tap();
|
|
57
|
+
resolve();
|
|
58
|
+
}, travel);
|
|
59
|
+
else {
|
|
60
|
+
AgentCursor.#tap();
|
|
61
|
+
resolve();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
37
64
|
});
|
|
38
65
|
}
|
|
39
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The page is being scrolled and the pointer is what is doing it. It stays put while the content moves, the way
|
|
69
|
+
* a person's does — what the chevron adds is who asked, since a pointer standing still over a page that slides
|
|
70
|
+
* under it otherwise reads as one that has come loose from the screen.
|
|
71
|
+
*/
|
|
72
|
+
static scroll(way: "up" | "down") {
|
|
73
|
+
const el = AgentCursor.#el;
|
|
74
|
+
if (!el?.isConnected) return;
|
|
75
|
+
el.classList.remove(AgentCursor.thinkingClass);
|
|
76
|
+
el.classList.add(AgentCursor.scrollingClass);
|
|
77
|
+
el.classList.toggle(`${AgentCursor.scrollingClass}-up`, way === "up");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Keeps the pointer up for as long as the turn runs, instead of for `idleMs` after the last call. */
|
|
81
|
+
static hold() {
|
|
82
|
+
AgentCursor.#held = true;
|
|
83
|
+
if (AgentCursor.#idle) clearTimeout(AgentCursor.#idle);
|
|
84
|
+
AgentCursor.#idle = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static release() {
|
|
88
|
+
AgentCursor.#held = false;
|
|
89
|
+
AgentCursor.hide();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Waiting rather than acting: the pointer drifts clear of what it just pressed and the arrow gives way to a
|
|
94
|
+
* spinner. Does nothing before the first press — an agent that drives no control has no place on the screen to
|
|
95
|
+
* be, and a pointer parked in a corner for a turn that only answered a question would be saying something untrue.
|
|
96
|
+
*/
|
|
97
|
+
static think() {
|
|
98
|
+
const el = AgentCursor.#el;
|
|
99
|
+
if (!el?.isConnected) return;
|
|
100
|
+
el.classList.remove(`${AgentCursor.className}-tap`);
|
|
101
|
+
AgentCursor.#driftOff();
|
|
102
|
+
el.classList.add(AgentCursor.thinkingClass);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Off the control before the waiting starts. A person clicks and then takes the hand away to think; a spinner
|
|
107
|
+
* left on the button it just pressed reads as one stuck to it, and it covers the change it caused.
|
|
108
|
+
*/
|
|
109
|
+
static #driftOff() {
|
|
110
|
+
const el = AgentCursor.#el;
|
|
111
|
+
const box = AgentCursor.#pressed;
|
|
112
|
+
AgentCursor.#pressed = null;
|
|
113
|
+
if (!el || !box) return;
|
|
114
|
+
const gap = AgentCursor.driftBy;
|
|
115
|
+
|
|
116
|
+
const x = box.right + gap <= window.innerWidth - gap ? box.right + gap : box.left - gap;
|
|
117
|
+
const y = box.bottom + gap <= window.innerHeight - gap ? box.bottom + gap : box.top - gap;
|
|
118
|
+
const at = { x: Math.max(gap, Math.round(x)), y: Math.max(gap, Math.round(y)) };
|
|
119
|
+
el.style.transform = `translate3d(${at.x}px, ${at.y}px, 0)`;
|
|
120
|
+
AgentCursor.#at = at;
|
|
121
|
+
}
|
|
122
|
+
|
|
40
123
|
static hide() {
|
|
124
|
+
AgentCursor.#pressed = null;
|
|
125
|
+
AgentCursor.#stopScrolling();
|
|
126
|
+
AgentCursor.#el?.classList.remove(AgentCursor.thinkingClass);
|
|
41
127
|
AgentCursor.#el?.classList.remove(`${AgentCursor.className}-shown`);
|
|
42
128
|
AgentCursor.#at = null;
|
|
43
129
|
const el = AgentCursor.#el;
|
|
@@ -48,8 +134,14 @@ export class AgentCursor {
|
|
|
48
134
|
}
|
|
49
135
|
|
|
50
136
|
/** How long the move will take, so the caller knows when the pointer has arrived. */
|
|
137
|
+
static #stopScrolling() {
|
|
138
|
+
AgentCursor.#el?.classList.remove(AgentCursor.scrollingClass, `${AgentCursor.scrollingClass}-up`);
|
|
139
|
+
}
|
|
140
|
+
|
|
51
141
|
static #moveTo(x: number, y: number): number {
|
|
52
142
|
const el = AgentCursor.#mounted();
|
|
143
|
+
AgentCursor.#stopScrolling();
|
|
144
|
+
el.classList.remove(AgentCursor.thinkingClass);
|
|
53
145
|
const at = AgentCursor.#at;
|
|
54
146
|
|
|
55
147
|
const travel = at && Math.hypot(x - at.x, y - at.y) >= AgentCursor.travelFrom ? AgentCursor.travelMs : 0;
|
|
@@ -76,6 +168,8 @@ export class AgentCursor {
|
|
|
76
168
|
|
|
77
169
|
static #keep() {
|
|
78
170
|
if (AgentCursor.#idle) clearTimeout(AgentCursor.#idle);
|
|
171
|
+
AgentCursor.#idle = null;
|
|
172
|
+
if (AgentCursor.#held) return;
|
|
79
173
|
AgentCursor.#idle = setTimeout(() => {
|
|
80
174
|
AgentCursor.#idle = null;
|
|
81
175
|
AgentCursor.hide();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { router } from "akanjs/client";
|
|
1
2
|
import type { ToolActivity } from "../../vendor/use-agentic";
|
|
2
3
|
import { AgentCursor } from "./AgentCursor";
|
|
3
4
|
import { ScreenFlash } from "./ScreenFlash";
|
|
@@ -22,8 +23,13 @@ export interface AgentVisualOption {
|
|
|
22
23
|
* Nothing here is ever waited on. A call starts the moment the event is handed over; the ring catching up a frame
|
|
23
24
|
* later costs the turn nothing, and an animation that held a call would make the agent slower for a decoration.
|
|
24
25
|
*
|
|
25
|
-
* A call that lands on no control draws nothing
|
|
26
|
-
* top-of-page bar tried for it read as chrome the page had grown rather than as the agent doing
|
|
26
|
+
* A call that lands on no control draws nothing. That still covers most of `navigate` — the router is not an
|
|
27
|
+
* element, and the top-of-page bar tried for it read as chrome the page had grown rather than as the agent doing
|
|
28
|
+
* something — but a destination the screen already offers as a link is an element, and that one is pressed.
|
|
29
|
+
*
|
|
30
|
+
* The pointer's unit is the **turn**, not the call. A model's calls arrive with its own writing between them, so
|
|
31
|
+
* a pointer that lived for a call's length spent every turn disappearing and coming back; between calls it waits
|
|
32
|
+
* where it last acted, as a spinner, and goes when the turn does.
|
|
27
33
|
*/
|
|
28
34
|
export class AgentVisual {
|
|
29
35
|
/**
|
|
@@ -34,21 +40,65 @@ export class AgentVisual {
|
|
|
34
40
|
static readonly revealCap = 4;
|
|
35
41
|
/** How many of a form patch's fields are drawn. A patch of twenty is a form being filled, not twenty events. */
|
|
36
42
|
static readonly fieldCap = 5;
|
|
43
|
+
/**
|
|
44
|
+
* The longest a call is held for the drawing that has to precede it. Every millisecond here is one the agent
|
|
45
|
+
* spends on a decoration, so the press is given time to land and the call goes either way.
|
|
46
|
+
*/
|
|
47
|
+
static readonly leadMs = 600;
|
|
37
48
|
|
|
38
49
|
static #revealed = 0;
|
|
39
50
|
static #idle: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
+
static #turning = false;
|
|
52
|
+
static #acting = 0;
|
|
40
53
|
|
|
41
|
-
/** The
|
|
42
|
-
static sink(option: boolean | AgentVisualOption = true)
|
|
54
|
+
/** The handlers `agentSessionOf` hands the session. `true` is every effect; an object turns one of them off. */
|
|
55
|
+
static sink(option: boolean | AgentVisualOption = true) {
|
|
43
56
|
if (option === false) return undefined;
|
|
44
57
|
const settings = option === true ? {} : option;
|
|
45
|
-
return
|
|
58
|
+
return {
|
|
59
|
+
onActivity: (event: ToolActivity) => AgentVisual.on(event, settings),
|
|
60
|
+
onTurn: (running: boolean) => AgentVisual.turn(running, settings),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The boundary the pointer lives between, and the one the scroll budget is spent in. Raising a pointer here
|
|
66
|
+
* would be a pointer with nowhere to point yet, so the turn's start only resets what the last turn used.
|
|
67
|
+
*/
|
|
68
|
+
static turn(running: boolean, { cursor = true }: AgentVisualOption = {}) {
|
|
69
|
+
if (typeof document === "undefined") return;
|
|
70
|
+
AgentVisual.#turning = running;
|
|
71
|
+
AgentVisual.#revealed = 0;
|
|
72
|
+
if (AgentVisual.#idle) clearTimeout(AgentVisual.#idle);
|
|
73
|
+
AgentVisual.#idle = null;
|
|
74
|
+
if (!running) AgentVisual.#acting = 0;
|
|
75
|
+
if (!cursor) return;
|
|
76
|
+
if (running) AgentCursor.hold();
|
|
77
|
+
else AgentCursor.release();
|
|
46
78
|
}
|
|
47
79
|
|
|
48
|
-
static on(event: ToolActivity, { reveal = true, cursor = true }: AgentVisualOption = {}) {
|
|
80
|
+
static on(event: ToolActivity, { reveal = true, cursor = true }: AgentVisualOption = {}): void | Promise<void> {
|
|
49
81
|
if (typeof document === "undefined" || document.hidden) return;
|
|
50
|
-
if (!reveal
|
|
51
|
-
|
|
82
|
+
if (!reveal && !cursor) return;
|
|
83
|
+
if (event.phase === "end") {
|
|
84
|
+
AgentVisual.#acting = Math.max(0, AgentVisual.#acting - 1);
|
|
85
|
+
if (cursor && AgentVisual.#turning && !AgentVisual.#acting) AgentCursor.think();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
AgentVisual.#acting += 1;
|
|
89
|
+
const pressed = AgentVisual.#targetsOf(event).map((target) => AgentVisual.#act(target, { reveal, cursor }));
|
|
90
|
+
|
|
91
|
+
if (!cursor || !pressed.length || !AgentVisual.#navigating(event.name)) return;
|
|
92
|
+
return AgentVisual.#capped(Promise.all(pressed));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolves when the drawing has landed or when its budget runs out, whichever comes first. */
|
|
96
|
+
static #capped(drawing: Promise<unknown>): Promise<void> {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
const done = () => resolve();
|
|
99
|
+
setTimeout(done, AgentVisual.leadMs);
|
|
100
|
+
void drawing.then(done, done);
|
|
101
|
+
});
|
|
52
102
|
}
|
|
53
103
|
|
|
54
104
|
/**
|
|
@@ -57,26 +107,80 @@ export class AgentVisual {
|
|
|
57
107
|
* by any one of them and ringing the form as a whole would say nothing about what changed.
|
|
58
108
|
*/
|
|
59
109
|
static #targetsOf(event: ToolActivity): HTMLElement[] {
|
|
110
|
+
if (AgentVisual.#navigating(event.name)) return AgentVisual.#linkTo(event.args.path);
|
|
60
111
|
const form = AgentVisual.#formOf(event.name);
|
|
61
|
-
if (!form) return AgentVisual.#only(ScreenTarget.controls(event.name));
|
|
112
|
+
if (!form) return AgentVisual.#only(ScreenTarget.controls(event.name), event.args);
|
|
62
113
|
return Object.keys(event.args)
|
|
63
114
|
.slice(0, AgentVisual.fieldCap)
|
|
64
115
|
.flatMap((key) => AgentVisual.#only(ScreenTarget.controls(`${form}.${key}`)));
|
|
65
116
|
}
|
|
66
117
|
|
|
67
118
|
/**
|
|
68
|
-
* One
|
|
69
|
-
*
|
|
119
|
+
* One verb is registered once per row and every registration carries the same name, so several matches means the
|
|
120
|
+
* agent acted somewhere the name alone cannot place. The call's own arguments break the tie when a control says
|
|
121
|
+
* which one it is (`data-akan-key`, from `agentAttrs(handler, key)`) — that is how a tab's menus are told apart.
|
|
122
|
+
* Short of exactly one answer nothing is drawn: ringing a guessed control is worse than ringing none.
|
|
70
123
|
*/
|
|
71
|
-
static #only(targets: HTMLElement[]): HTMLElement[] {
|
|
72
|
-
|
|
124
|
+
static #only(targets: HTMLElement[], args?: Record<string, unknown>): HTMLElement[] {
|
|
125
|
+
if (targets.length === 1) return targets;
|
|
126
|
+
if (!targets.length || !args) return [];
|
|
127
|
+
const named = new Set(
|
|
128
|
+
Object.values(args)
|
|
129
|
+
.filter((value): value is string | number => typeof value === "string" || typeof value === "number")
|
|
130
|
+
.map(String),
|
|
131
|
+
);
|
|
132
|
+
const keyed = targets.filter((target) => {
|
|
133
|
+
const key = target.getAttribute("data-akan-key");
|
|
134
|
+
return !!key && named.has(key);
|
|
135
|
+
});
|
|
136
|
+
return keyed.length === 1 ? keyed : [];
|
|
73
137
|
}
|
|
74
138
|
|
|
75
|
-
static #act(target: HTMLElement, cursor:
|
|
139
|
+
static #act(target: HTMLElement, { reveal, cursor }: Required<AgentVisualOption>): Promise<void> {
|
|
76
140
|
AgentVisual.#count();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
141
|
+
const scrolled =
|
|
142
|
+
reveal && AgentVisual.#revealed <= AgentVisual.revealCap && !ScreenFlash.inView(target)
|
|
143
|
+
? ScreenFlash.reveal(target)
|
|
144
|
+
: null;
|
|
145
|
+
if (cursor && scrolled) AgentCursor.scroll(scrolled);
|
|
146
|
+
const pressed = cursor ? AgentCursor.press(target) : Promise.resolve();
|
|
147
|
+
if (reveal) ScreenFlash.ring(target, { className: ScreenFlash.actingClass, ms: ScreenFlash.actingMs });
|
|
148
|
+
return pressed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The link on screen that goes where a `navigate` call is going, when exactly one does and the user can already
|
|
153
|
+
* see it. Off-screen links are left alone deliberately: scrolling to a link and then leaving the page it is on
|
|
154
|
+
* is two motions for one act, and the destination is where the attention belongs by then.
|
|
155
|
+
*
|
|
156
|
+
* Addresses are compared through `router.routeOf`, which strips the locale and base-path segments an `<a>`
|
|
157
|
+
* carries and a tool argument does not — `/en/docs/intro` and `/docs/intro` are the same route.
|
|
158
|
+
*/
|
|
159
|
+
static #linkTo(path: unknown): HTMLElement[] {
|
|
160
|
+
const wanted = AgentVisual.#routeOf(typeof path === "string" ? path : "");
|
|
161
|
+
if (!wanted || !document.body) return [];
|
|
162
|
+
const links = [...document.body.querySelectorAll<HTMLAnchorElement>("a[href]")].filter(
|
|
163
|
+
(link) =>
|
|
164
|
+
AgentVisual.#routeOf(link.getAttribute("href") ?? "") === wanted &&
|
|
165
|
+
ScreenTarget.visible(link) &&
|
|
166
|
+
ScreenFlash.inView(link),
|
|
167
|
+
);
|
|
168
|
+
return AgentVisual.#only(links);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
static #routeOf(href: string) {
|
|
172
|
+
if (!href || href.startsWith("#")) return "";
|
|
173
|
+
try {
|
|
174
|
+
const url = new URL(href, window.location.href);
|
|
175
|
+
if (url.origin !== window.location.origin) return "";
|
|
176
|
+
return router.routeOf(url.pathname);
|
|
177
|
+
} catch {
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
static #navigating(name: string) {
|
|
183
|
+
return name.slice(name.lastIndexOf(".") + 1) === "navigate";
|
|
80
184
|
}
|
|
81
185
|
|
|
82
186
|
/** `fillTaskForm` → `taskForm`, which is what the fields of that form carry in `data-akan-state`. */
|
|
@@ -88,8 +192,9 @@ export class AgentVisual {
|
|
|
88
192
|
}
|
|
89
193
|
|
|
90
194
|
/**
|
|
91
|
-
* The
|
|
92
|
-
* model's calls arrive back to back through one serialized queue, so a
|
|
195
|
+
* The turn is the batch, and `turn` resets this. The quiet-second timer stays as the fallback for a host that
|
|
196
|
+
* reports no turn at all — a model's calls arrive back to back through one serialized queue, so a second of
|
|
197
|
+
* quiet is the end of a batch even when nothing said so.
|
|
93
198
|
*/
|
|
94
199
|
static #count() {
|
|
95
200
|
AgentVisual.#revealed += 1;
|
|
@@ -24,8 +24,13 @@ export class ScreenFlash {
|
|
|
24
24
|
ScreenFlash.ring(target);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** Answers which way the view travels, for whoever has to say that the scroll is the agent's doing. */
|
|
28
|
+
static reveal(target: HTMLElement): "up" | "down" {
|
|
29
|
+
const { top, height } = target.getBoundingClientRect();
|
|
30
|
+
const viewHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
31
|
+
const way = top + height / 2 < viewHeight / 2 ? "up" : "down";
|
|
28
32
|
target.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
33
|
+
return way;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -40,6 +45,22 @@ export class ScreenFlash {
|
|
|
40
45
|
return top >= margin && left >= 0 && bottom <= viewHeight - margin && right <= viewWidth;
|
|
41
46
|
}
|
|
42
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Whether the element is the one actually painted at its own centre, rather than merely present in the layout.
|
|
50
|
+
* `checkVisibility` answers about the element alone, so a control under a modal's backdrop, inside a drawer that
|
|
51
|
+
* has slid off, or faded to nothing all pass it while being invisible — and a pointer sent to one of those lands
|
|
52
|
+
* on a blank patch of overlay, which reads as the effect being broken rather than as the agent acting.
|
|
53
|
+
*
|
|
54
|
+
* An ancestor counts as a hit: a `<label>` wrapping its input is what the point belongs to, not an occlusion.
|
|
55
|
+
*/
|
|
56
|
+
static onTop(target: HTMLElement) {
|
|
57
|
+
if (!ScreenFlash.inView(target)) return false;
|
|
58
|
+
if (typeof document.elementFromPoint !== "function") return true;
|
|
59
|
+
const { top, left, width, height } = target.getBoundingClientRect();
|
|
60
|
+
const hit = document.elementFromPoint(left + width / 2, top + height / 2);
|
|
61
|
+
return !!hit && (hit === target || target.contains(hit) || hit.contains(target));
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/**
|
|
44
65
|
* The ring goes on once the scroll lands, not when it starts: a smooth scroll across a long page takes most of a
|
|
45
66
|
* second, and a flash begun at the top is already fading by the time the user's eye arrives. Removed on a timer
|
|
@@ -33,7 +33,7 @@ export class ScreenTarget {
|
|
|
33
33
|
|
|
34
34
|
return (
|
|
35
35
|
ScreenTarget.#first(scope, selector) ??
|
|
36
|
-
[...scope.querySelectorAll<HTMLElement>("[id]")].find((el) => el.id === name && ScreenTarget
|
|
36
|
+
[...scope.querySelectorAll<HTMLElement>("[id]")].find((el) => el.id === name && ScreenTarget.visible(el)) ??
|
|
37
37
|
null
|
|
38
38
|
);
|
|
39
39
|
}
|
|
@@ -51,7 +51,7 @@ export class ScreenTarget {
|
|
|
51
51
|
if (!scope || !name) return [];
|
|
52
52
|
const escaped = CSS.escape(name);
|
|
53
53
|
const selector = controlAttrs.map((attr) => `[${attr}="${escaped}"]`).join(", ");
|
|
54
|
-
return [...scope.querySelectorAll<HTMLElement>(selector)].filter(ScreenTarget
|
|
54
|
+
return [...scope.querySelectorAll<HTMLElement>(selector)].filter(ScreenTarget.visible);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Matched on letters and digits only, so a slug written for a heading still finds it. */
|
|
@@ -59,7 +59,7 @@ export class ScreenTarget {
|
|
|
59
59
|
const scope = root ?? ScreenTarget.#body();
|
|
60
60
|
const wanted = ScreenTarget.#slug(text);
|
|
61
61
|
if (!scope || !wanted) return null;
|
|
62
|
-
const headings = [...scope.querySelectorAll<HTMLElement>(headingSelector)].filter(ScreenTarget
|
|
62
|
+
const headings = [...scope.querySelectorAll<HTMLElement>(headingSelector)].filter(ScreenTarget.visible);
|
|
63
63
|
return (
|
|
64
64
|
headings.find((heading) => ScreenTarget.#slug(heading.textContent ?? "") === wanted) ??
|
|
65
65
|
headings.find((heading) => ScreenTarget.#slug(heading.textContent ?? "").includes(wanted)) ??
|
|
@@ -88,7 +88,7 @@ export class ScreenTarget {
|
|
|
88
88
|
if (!scope) return [];
|
|
89
89
|
const names = new Set<string>();
|
|
90
90
|
for (const heading of scope.querySelectorAll<HTMLElement>(headingSelector)) {
|
|
91
|
-
const anchor = ScreenTarget
|
|
91
|
+
const anchor = ScreenTarget.visible(heading) ? ScreenReader.anchorOf(heading) : "";
|
|
92
92
|
if (anchor) names.add(anchor);
|
|
93
93
|
}
|
|
94
94
|
return [...names];
|
|
@@ -109,7 +109,7 @@ export class ScreenTarget {
|
|
|
109
109
|
const own = scope.getAttribute(attr);
|
|
110
110
|
if (own) names.add(own);
|
|
111
111
|
for (const el of scope.querySelectorAll<HTMLElement>(`[${attr}]`)) {
|
|
112
|
-
const value = ScreenTarget
|
|
112
|
+
const value = ScreenTarget.visible(el) ? el.getAttribute(attr) : null;
|
|
113
113
|
if (value) names.add(value);
|
|
114
114
|
}
|
|
115
115
|
}
|
|
@@ -121,10 +121,11 @@ export class ScreenTarget {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
static #first(scope: HTMLElement, selector: string) {
|
|
124
|
-
return [...scope.querySelectorAll<HTMLElement>(selector)].find(ScreenTarget
|
|
124
|
+
return [...scope.querySelectorAll<HTMLElement>(selector)].find(ScreenTarget.visible) ?? null;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/** The one rule for whether an element is something the user is actually looking at. */
|
|
128
|
+
static visible(el: HTMLElement) {
|
|
128
129
|
if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") return false;
|
|
129
130
|
if (el.closest("[data-agent-ui]")) return false;
|
|
130
131
|
return typeof el.checkVisibility === "function" ? el.checkVisibility() : true;
|
|
@@ -70,6 +70,14 @@ export class StoreSurfaceSource implements SurfaceSource {
|
|
|
70
70
|
const path = String(args.path);
|
|
71
71
|
router.push(path);
|
|
72
72
|
|
|
73
|
+
try {
|
|
74
|
+
await router.navigation();
|
|
75
|
+
} catch {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`There is no route at ${path}, so the page did not move. Call readScreen — it prints each link on this screen with its own path — and navigate to one of those.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
await ScreenSettle.wait({ appearMs: 800, timeoutMs: 5000 });
|
|
74
82
|
return `Now on ${path}. Call readScreen to see it; this screen's own tools and state are listed from the next turn.`;
|
|
75
83
|
},
|
package/types/client/router.d.ts
CHANGED
|
@@ -8,8 +8,9 @@ export interface DeepLinkOptions extends RouteOptions {
|
|
|
8
8
|
resetStack?: boolean;
|
|
9
9
|
}
|
|
10
10
|
export interface RouterInstance {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
/** May answer a promise that rejects when the route refused to move — see `Router.navigation`. */
|
|
12
|
+
push: (href: string, routeOptions?: RouteOptions) => void | Promise<void>;
|
|
13
|
+
replace: (href: string, routeOptions?: RouteOptions) => void | Promise<void>;
|
|
13
14
|
back: (routeOptions?: RouteOptions) => void;
|
|
14
15
|
refresh: () => void;
|
|
15
16
|
}
|
|
@@ -70,6 +71,15 @@ declare class Router {
|
|
|
70
71
|
enterDeepLink(href: string, options?: DeepLinkOptions): boolean;
|
|
71
72
|
push(href: string, routeOptions?: RouteOptions): never;
|
|
72
73
|
replace(href: string, routeOptions?: RouteOptions): never;
|
|
74
|
+
/**
|
|
75
|
+
* The navigation `push` / `replace` last started, for a caller that has to know whether it landed. **Rejects
|
|
76
|
+
* when the route refused to move** — a target that resolves to nothing leaves the page where it was instead of
|
|
77
|
+
* replacing it, and `push` returns long before that is known, so this is the only place it can be reported.
|
|
78
|
+
*
|
|
79
|
+
* A method rather than a getter: `router` is a Proxy that binds functions to the real instance, and a getter
|
|
80
|
+
* reached through it would run with the Proxy as `this` and fail on the private field.
|
|
81
|
+
*/
|
|
82
|
+
navigation(): Promise<void>;
|
|
73
83
|
canGoBack(): boolean;
|
|
74
84
|
backOrFallback(fallbackHref?: string, routeOptions?: RouteOptions): never;
|
|
75
85
|
back(routeOptions?: RouteOptions): never;
|
|
@@ -77,6 +87,12 @@ declare class Router {
|
|
|
77
87
|
redirect(href: string, options?: RedirectOptions): never;
|
|
78
88
|
notFound(): never;
|
|
79
89
|
setLang(lang: string): never;
|
|
90
|
+
/**
|
|
91
|
+
* A pathname with the locale and base-path segments taken off — the app-internal route it names, which is what
|
|
92
|
+
* a tool argument and a `<a href>` have in common. Unguarded, unlike `getPath`, whose default argument is the
|
|
93
|
+
* one thing in it that needs a browser.
|
|
94
|
+
*/
|
|
95
|
+
routeOf(pathname: string): string;
|
|
80
96
|
getPath(pathname?: string): string;
|
|
81
97
|
getFullPath(withLang?: boolean): string;
|
|
82
98
|
getPrefix(): string;
|
|
@@ -17,9 +17,32 @@ export declare class AgentCursor {
|
|
|
17
17
|
static readonly travelMs = 320;
|
|
18
18
|
/** Under this far, the glide is skipped: a third of a second to cross fifty pixels reads as lag, not motion. */
|
|
19
19
|
static readonly travelFrom = 200;
|
|
20
|
-
/** How
|
|
20
|
+
/** How far off the control the pointer drifts before it starts waiting — just clear of its box. */
|
|
21
|
+
static readonly driftBy = 18;
|
|
22
|
+
/**
|
|
23
|
+
* How long after the last call the pointer stays before fading, for a host that reports no turn boundary. A
|
|
24
|
+
* host that does calls `hold` instead, because this is the wrong unit: between two calls of one turn the model
|
|
25
|
+
* is writing, which takes longer than this, and the pointer used to vanish and come back for every call.
|
|
26
|
+
*/
|
|
21
27
|
static readonly idleMs = 1400;
|
|
22
|
-
|
|
23
|
-
static
|
|
28
|
+
static readonly thinkingClass = "akan-agent-cursor-thinking";
|
|
29
|
+
static readonly scrollingClass = "akan-agent-cursor-scrolling";
|
|
30
|
+
/** Travels to the control once it has stopped moving, then presses it. Resolves when the press has landed. */
|
|
31
|
+
static press(target: HTMLElement): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* The page is being scrolled and the pointer is what is doing it. It stays put while the content moves, the way
|
|
34
|
+
* a person's does — what the chevron adds is who asked, since a pointer standing still over a page that slides
|
|
35
|
+
* under it otherwise reads as one that has come loose from the screen.
|
|
36
|
+
*/
|
|
37
|
+
static scroll(way: "up" | "down"): void;
|
|
38
|
+
/** Keeps the pointer up for as long as the turn runs, instead of for `idleMs` after the last call. */
|
|
39
|
+
static hold(): void;
|
|
40
|
+
static release(): void;
|
|
41
|
+
/**
|
|
42
|
+
* Waiting rather than acting: the pointer drifts clear of what it just pressed and the arrow gives way to a
|
|
43
|
+
* spinner. Does nothing before the first press — an agent that drives no control has no place on the screen to
|
|
44
|
+
* be, and a pointer parked in a corner for a turn that only answered a question would be saying something untrue.
|
|
45
|
+
*/
|
|
46
|
+
static think(): void;
|
|
24
47
|
static hide(): void;
|
|
25
48
|
}
|
|
@@ -17,8 +17,13 @@ export interface AgentVisualOption {
|
|
|
17
17
|
* Nothing here is ever waited on. A call starts the moment the event is handed over; the ring catching up a frame
|
|
18
18
|
* later costs the turn nothing, and an animation that held a call would make the agent slower for a decoration.
|
|
19
19
|
*
|
|
20
|
-
* A call that lands on no control draws nothing
|
|
21
|
-
* top-of-page bar tried for it read as chrome the page had grown rather than as the agent doing
|
|
20
|
+
* A call that lands on no control draws nothing. That still covers most of `navigate` — the router is not an
|
|
21
|
+
* element, and the top-of-page bar tried for it read as chrome the page had grown rather than as the agent doing
|
|
22
|
+
* something — but a destination the screen already offers as a link is an element, and that one is pressed.
|
|
23
|
+
*
|
|
24
|
+
* The pointer's unit is the **turn**, not the call. A model's calls arrive with its own writing between them, so
|
|
25
|
+
* a pointer that lived for a call's length spent every turn disappearing and coming back; between calls it waits
|
|
26
|
+
* where it last acted, as a spinner, and goes when the turn does.
|
|
22
27
|
*/
|
|
23
28
|
export declare class AgentVisual {
|
|
24
29
|
#private;
|
|
@@ -30,7 +35,20 @@ export declare class AgentVisual {
|
|
|
30
35
|
static readonly revealCap = 4;
|
|
31
36
|
/** How many of a form patch's fields are drawn. A patch of twenty is a form being filled, not twenty events. */
|
|
32
37
|
static readonly fieldCap = 5;
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The longest a call is held for the drawing that has to precede it. Every millisecond here is one the agent
|
|
40
|
+
* spends on a decoration, so the press is given time to land and the call goes either way.
|
|
41
|
+
*/
|
|
42
|
+
static readonly leadMs = 600;
|
|
43
|
+
/** The handlers `agentSessionOf` hands the session. `true` is every effect; an object turns one of them off. */
|
|
44
|
+
static sink(option?: boolean | AgentVisualOption): {
|
|
45
|
+
onActivity: (event: ToolActivity) => void | Promise<void>;
|
|
46
|
+
onTurn: (running: boolean) => void;
|
|
47
|
+
} | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* The boundary the pointer lives between, and the one the scroll budget is spent in. Raising a pointer here
|
|
50
|
+
* would be a pointer with nowhere to point yet, so the turn's start only resets what the last turn used.
|
|
51
|
+
*/
|
|
52
|
+
static turn(running: boolean, { cursor }?: AgentVisualOption): void;
|
|
53
|
+
static on(event: ToolActivity, { reveal, cursor }?: AgentVisualOption): void | Promise<void>;
|
|
36
54
|
}
|
|
@@ -19,12 +19,22 @@ export declare class ScreenFlash {
|
|
|
19
19
|
static readonly actingClass = "akan-agent-acting";
|
|
20
20
|
static readonly actingMs = 1000;
|
|
21
21
|
static show(target: HTMLElement): void;
|
|
22
|
-
|
|
22
|
+
/** Answers which way the view travels, for whoever has to say that the scroll is the agent's doing. */
|
|
23
|
+
static reveal(target: HTMLElement): "up" | "down";
|
|
23
24
|
/**
|
|
24
25
|
* Whether the element is far enough inside the viewport to be looked at, not merely intersecting it: a button
|
|
25
26
|
* whose bottom pixel is on screen is one the user cannot read, and a ring there points at nothing.
|
|
26
27
|
*/
|
|
27
28
|
static inView(target: HTMLElement, margin?: number): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Whether the element is the one actually painted at its own centre, rather than merely present in the layout.
|
|
31
|
+
* `checkVisibility` answers about the element alone, so a control under a modal's backdrop, inside a drawer that
|
|
32
|
+
* has slid off, or faded to nothing all pass it while being invisible — and a pointer sent to one of those lands
|
|
33
|
+
* on a blank patch of overlay, which reads as the effect being broken rather than as the agent acting.
|
|
34
|
+
*
|
|
35
|
+
* An ancestor counts as a hit: a `<label>` wrapping its input is what the point belongs to, not an occlusion.
|
|
36
|
+
*/
|
|
37
|
+
static onTop(target: HTMLElement): boolean;
|
|
28
38
|
/**
|
|
29
39
|
* The ring goes on once the scroll lands, not when it starts: a smooth scroll across a long page takes most of a
|
|
30
40
|
* second, and a flash begun at the top is already fading by the time the user's eye arrives. Removed on a timer
|
|
@@ -36,4 +36,6 @@ export declare class ScreenTarget {
|
|
|
36
36
|
static containerNames(root?: HTMLElement | null): string[];
|
|
37
37
|
static anchorNames(root?: HTMLElement | null): string[];
|
|
38
38
|
static targetNames(root?: HTMLElement | null): string[];
|
|
39
|
+
/** The one rule for whether an element is something the user is actually looking at. */
|
|
40
|
+
static visible(el: HTMLElement): boolean;
|
|
39
41
|
}
|
package/types/ui/Agent/Chat.d.ts
CHANGED
|
@@ -41,9 +41,10 @@ export interface ChatProps {
|
|
|
41
41
|
onOpenChange?: (open: boolean) => void;
|
|
42
42
|
/**
|
|
43
43
|
* What the page itself draws while this agent drives it: the control a call was published from is ringed where
|
|
44
|
-
* it stands, and a pointer presses it
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* it stands, and a pointer presses it, waits out the model's turn as a spinner where it landed, and goes when
|
|
45
|
+
* the turn ends. On by default — the chat panel is closed as often as it is open, and a change nothing
|
|
46
|
+
* attributes is one the user watches happen for no reason they can see. `false` draws nothing, and an object
|
|
47
|
+
* turns one effect off (`visual={{ cursor: false }}` keeps the ring, `{ reveal: false }` keeps the pointer).
|
|
47
48
|
*/
|
|
48
49
|
visual?: boolean | AgentVisualOption;
|
|
49
50
|
/** `false` draws no launcher, for an app that opens the panel from a control of its own. */
|
package/types/ui/Agent/Zone.d.ts
CHANGED
|
@@ -29,9 +29,10 @@ export interface ZoneProps {
|
|
|
29
29
|
onCompact?: AgentSessionOptions["onCompact"];
|
|
30
30
|
/**
|
|
31
31
|
* What the page itself draws while this agent drives it: the control a call was published from is ringed where
|
|
32
|
-
* it stands, and a pointer presses it
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* it stands, and a pointer presses it, waits out the model's turn as a spinner where it landed, and goes when
|
|
33
|
+
* the turn ends. On by default — the chat panel is closed as often as it is open, and a change nothing
|
|
34
|
+
* attributes is one the user watches happen for no reason they can see. `false` draws nothing, and an object
|
|
35
|
+
* turns one effect off (`visual={{ cursor: false }}` keeps the ring, `{ reveal: false }` keeps the pointer).
|
|
35
36
|
*/
|
|
36
37
|
visual?: boolean | AgentVisualOption;
|
|
37
38
|
/**
|
package/types/ui/agentAttrs.d.ts
CHANGED
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
*
|
|
8
8
|
* What it buys, beyond an in-page agent: an accessibility tree and E2E selectors that name the action rather than a
|
|
9
9
|
* class, and an external browser agent — one this framework has no bridge into — reading the same names.
|
|
10
|
+
*
|
|
11
|
+
* `key` is for a control that shares its handler with its siblings — a tab's menus, a list's rows — and names
|
|
12
|
+
* which one of them this is, in the same vocabulary the call's argument uses. Without it every namesake is
|
|
13
|
+
* interchangeable in the DOM, so the page can say *what* the agent did but never *where*.
|
|
10
14
|
*/
|
|
11
|
-
export declare const agentAttrs: (handler: unknown) => {
|
|
15
|
+
export declare const agentAttrs: (handler: unknown, key?: string | number) => {
|
|
12
16
|
"data-akan-action"?: string;
|
|
13
17
|
"data-akan-state"?: string;
|
|
18
|
+
"data-akan-key"?: string;
|
|
14
19
|
} | Record<string, never>;
|
|
@@ -81,7 +81,16 @@ export interface AgentSessionOptions {
|
|
|
81
81
|
* The session keeps none of it: nothing here is transcript, and holding it would mean a re-render per call for
|
|
82
82
|
* something no message renders.
|
|
83
83
|
*/
|
|
84
|
-
onActivity?: (event: ToolActivity) => void
|
|
84
|
+
onActivity?: (event: ToolActivity) => void | Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Called when a turn starts and when it settles — the boundary a host draws the agent's own presence in. The
|
|
87
|
+
* calls of one turn arrive with model turns between them, seconds long, so anything measured in the gap
|
|
88
|
+
* between *calls* keeps ending and restarting inside a turn that never stopped.
|
|
89
|
+
*
|
|
90
|
+
* Only the conversation loop reports here. `compact` runs under the same flag and drives nothing on screen, so
|
|
91
|
+
* a host drawing the agent at work would draw it for a summary nobody asked to watch.
|
|
92
|
+
*/
|
|
93
|
+
onTurn?: (running: boolean) => void;
|
|
85
94
|
}
|
|
86
95
|
/**
|
|
87
96
|
* The client-side conversation loop: send → model turn → tool calls → approval gate → execute → report diffs → next
|
|
@@ -36,8 +36,12 @@ export interface ToolRunnerHost {
|
|
|
36
36
|
* That a call is running, for a host drawing it on the page rather than in a transcript. Separate from
|
|
37
37
|
* `progress`, which only ever fires for a tool that chose to report: a call the user has to be told about is
|
|
38
38
|
* every call, and one that says nothing about itself is exactly the one whose effect arrives unexplained.
|
|
39
|
+
*
|
|
40
|
+
* **The `start` is awaited**, so a host may draw something that has to land before the call does — a pointer
|
|
41
|
+
* pressing the link a `navigate` is about to follow is drawing on an element the router would otherwise have
|
|
42
|
+
* replaced first. The host owns the deadline: a decoration that takes its time makes the agent take its time.
|
|
39
43
|
*/
|
|
40
|
-
activity?: (event: ToolActivity) => void
|
|
44
|
+
activity?: (event: ToolActivity) => void | Promise<void>;
|
|
41
45
|
/**
|
|
42
46
|
* Answers a name the surface does not carry — where a consumer puts a built-in of its own. Reached only after
|
|
43
47
|
* the surface came up empty, so a registered tool of the same name shadows it.
|
package/ui/Agent/Chat.tsx
CHANGED
|
@@ -82,9 +82,10 @@ export interface ChatProps {
|
|
|
82
82
|
onOpenChange?: (open: boolean) => void;
|
|
83
83
|
/**
|
|
84
84
|
* What the page itself draws while this agent drives it: the control a call was published from is ringed where
|
|
85
|
-
* it stands, and a pointer presses it
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* it stands, and a pointer presses it, waits out the model's turn as a spinner where it landed, and goes when
|
|
86
|
+
* the turn ends. On by default — the chat panel is closed as often as it is open, and a change nothing
|
|
87
|
+
* attributes is one the user watches happen for no reason they can see. `false` draws nothing, and an object
|
|
88
|
+
* turns one effect off (`visual={{ cursor: false }}` keeps the ring, `{ reveal: false }` keeps the pointer).
|
|
88
89
|
*/
|
|
89
90
|
visual?: boolean | AgentVisualOption;
|
|
90
91
|
/** `false` draws no launcher, for an app that opens the panel from a control of its own. */
|
package/ui/Agent/Zone.tsx
CHANGED
|
@@ -44,9 +44,10 @@ export interface ZoneProps {
|
|
|
44
44
|
onCompact?: AgentSessionOptions["onCompact"];
|
|
45
45
|
/**
|
|
46
46
|
* What the page itself draws while this agent drives it: the control a call was published from is ringed where
|
|
47
|
-
* it stands, and a pointer presses it
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* it stands, and a pointer presses it, waits out the model's turn as a spinner where it landed, and goes when
|
|
48
|
+
* the turn ends. On by default — the chat panel is closed as often as it is open, and a change nothing
|
|
49
|
+
* attributes is one the user watches happen for no reason they can see. `false` draws nothing, and an object
|
|
50
|
+
* turns one effect off (`visual={{ cursor: false }}` keeps the ring, `{ reveal: false }` keeps the pointer).
|
|
50
51
|
*/
|
|
51
52
|
visual?: boolean | AgentVisualOption;
|
|
52
53
|
/**
|
|
@@ -50,7 +50,7 @@ export const agentSessionOf = ({
|
|
|
50
50
|
}: AgentSessionSetup): AgentSession => {
|
|
51
51
|
const { surface } = ensureStoreSurface();
|
|
52
52
|
const history = sessionHistoryOf(persist, view.join("."));
|
|
53
|
-
const
|
|
53
|
+
const drawing = AgentVisual.sink(visual);
|
|
54
54
|
return new AgentSession(sessionView(surface, view, builtins), runner ?? fetchRunner(), {
|
|
55
55
|
buildContext: (scoped) => AgentContext.of().blocks(scoped, view),
|
|
56
56
|
settle: () => ScreenSettle.wait(),
|
|
@@ -60,6 +60,6 @@ export const agentSessionOf = ({
|
|
|
60
60
|
...(compact ? { compact } : {}),
|
|
61
61
|
...(history ? { history } : {}),
|
|
62
62
|
...(onCompact ? { onCompact } : {}),
|
|
63
|
-
...(
|
|
63
|
+
...(drawing ?? {}),
|
|
64
64
|
});
|
|
65
65
|
};
|
package/ui/System/Client.tsx
CHANGED
|
@@ -346,7 +346,14 @@ export const ClientSsrBridge = ({ lang, prefix = "", initialPageState }: ClientS
|
|
|
346
346
|
fallback();
|
|
347
347
|
return;
|
|
348
348
|
}
|
|
349
|
-
|
|
349
|
+
return navigation.catch((error: unknown) => {
|
|
350
|
+
|
|
351
|
+
if (error instanceof Error && error.name === "RscRouteNotFound") {
|
|
352
|
+
|
|
353
|
+
syncHref(window.location.href);
|
|
354
|
+
Logger.error(`No route at ${href}; the page was left where it was.`);
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
350
357
|
Logger.warn(`RSC navigation failed, falling back to document navigation: ${String(error)}`);
|
|
351
358
|
fallback();
|
|
352
359
|
});
|
|
@@ -370,11 +377,11 @@ export const ClientSsrBridge = ({ lang, prefix = "", initialPageState }: ClientS
|
|
|
370
377
|
router: {
|
|
371
378
|
push: (href, routeOptions) => {
|
|
372
379
|
syncHref(href);
|
|
373
|
-
navigateRscWithFallback(href, routeOptions, () => window.location.assign(href));
|
|
380
|
+
return navigateRscWithFallback(href, routeOptions, () => window.location.assign(href));
|
|
374
381
|
},
|
|
375
382
|
replace: (href, routeOptions) => {
|
|
376
383
|
syncHref(href);
|
|
377
|
-
navigateRscWithFallback(href, { ...routeOptions, replace: true }, () => window.location.replace(href));
|
|
384
|
+
return navigateRscWithFallback(href, { ...routeOptions, replace: true }, () => window.location.replace(href));
|
|
378
385
|
},
|
|
379
386
|
back: () => {
|
|
380
387
|
window.history.back();
|
package/ui/Tab/Menu.tsx
CHANGED
package/ui/agentAttrs.ts
CHANGED
|
@@ -9,11 +9,20 @@ import { actionTagOf } from "akanjs/store";
|
|
|
9
9
|
*
|
|
10
10
|
* What it buys, beyond an in-page agent: an accessibility tree and E2E selectors that name the action rather than a
|
|
11
11
|
* class, and an external browser agent — one this framework has no bridge into — reading the same names.
|
|
12
|
+
*
|
|
13
|
+
* `key` is for a control that shares its handler with its siblings — a tab's menus, a list's rows — and names
|
|
14
|
+
* which one of them this is, in the same vocabulary the call's argument uses. Without it every namesake is
|
|
15
|
+
* interchangeable in the DOM, so the page can say *what* the agent did but never *where*.
|
|
12
16
|
*/
|
|
13
17
|
export const agentAttrs = (
|
|
14
18
|
handler: unknown,
|
|
15
|
-
|
|
19
|
+
key?: string | number,
|
|
20
|
+
): { "data-akan-action"?: string; "data-akan-state"?: string; "data-akan-key"?: string } | Record<string, never> => {
|
|
16
21
|
const tag = actionTagOf(handler);
|
|
17
22
|
if (!tag) return {};
|
|
18
|
-
return {
|
|
23
|
+
return {
|
|
24
|
+
"data-akan-action": tag.action,
|
|
25
|
+
...(tag.state ? { "data-akan-state": tag.state } : {}),
|
|
26
|
+
...(key === undefined || key === "" ? {} : { "data-akan-key": String(key) }),
|
|
27
|
+
};
|
|
19
28
|
};
|
package/ui/styles.css
CHANGED
|
@@ -451,6 +451,89 @@ body.akan-mobile-document {
|
|
|
451
451
|
animation: akanAgentTap 450ms ease-out;
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
/*
|
|
455
|
+
* Between two calls of one turn the model is writing, which takes longer than a pointer sitting still reads as.
|
|
456
|
+
* The arrow gives way to a spinner in place, so the pause is the agent thinking rather than the pointer stuck.
|
|
457
|
+
*/
|
|
458
|
+
.akan-agent-cursor-thinking > i {
|
|
459
|
+
opacity: 0;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.akan-agent-cursor-thinking > b {
|
|
463
|
+
width: 1.125rem;
|
|
464
|
+
height: 1.125rem;
|
|
465
|
+
margin: -0.5625rem 0 0 -0.5625rem;
|
|
466
|
+
opacity: 1;
|
|
467
|
+
border-color: color-mix(in oklab, var(--color-primary) 25%, transparent);
|
|
468
|
+
border-top-color: var(--color-primary);
|
|
469
|
+
animation: akanAgentThinking 700ms linear infinite;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
@keyframes akanAgentThinking {
|
|
473
|
+
to {
|
|
474
|
+
transform: rotate(360deg);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/*
|
|
479
|
+
* The pointer holds still while a reveal scrolls the page under it, exactly as a person's does — so on its own the
|
|
480
|
+
* stillness reads as a pointer that came loose. The chevron is what says the scroll is the agent's, and it points
|
|
481
|
+
* the way the view is travelling.
|
|
482
|
+
*/
|
|
483
|
+
.akan-agent-cursor::after {
|
|
484
|
+
content: "";
|
|
485
|
+
position: absolute;
|
|
486
|
+
top: 22px;
|
|
487
|
+
left: 1px;
|
|
488
|
+
width: 9px;
|
|
489
|
+
height: 9px;
|
|
490
|
+
border-right: 2px solid var(--color-primary);
|
|
491
|
+
border-bottom: 2px solid var(--color-primary);
|
|
492
|
+
opacity: 0;
|
|
493
|
+
transform: rotate(45deg);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.akan-agent-cursor-scrolling::after {
|
|
497
|
+
animation: akanAgentScrollDown 800ms ease-in-out infinite;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.akan-agent-cursor-scrolling-up::after {
|
|
501
|
+
animation-name: akanAgentScrollUp;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.akan-agent-cursor-thinking::after {
|
|
505
|
+
animation: none;
|
|
506
|
+
opacity: 0;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
@keyframes akanAgentScrollDown {
|
|
510
|
+
0% {
|
|
511
|
+
transform: rotate(45deg) translate(-4px, -4px);
|
|
512
|
+
opacity: 0;
|
|
513
|
+
}
|
|
514
|
+
45% {
|
|
515
|
+
opacity: 1;
|
|
516
|
+
}
|
|
517
|
+
100% {
|
|
518
|
+
transform: rotate(45deg) translate(4px, 4px);
|
|
519
|
+
opacity: 0;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
@keyframes akanAgentScrollUp {
|
|
524
|
+
0% {
|
|
525
|
+
transform: rotate(225deg) translate(-4px, -4px);
|
|
526
|
+
opacity: 0;
|
|
527
|
+
}
|
|
528
|
+
45% {
|
|
529
|
+
opacity: 1;
|
|
530
|
+
}
|
|
531
|
+
100% {
|
|
532
|
+
transform: rotate(225deg) translate(4px, 4px);
|
|
533
|
+
opacity: 0;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
454
537
|
@keyframes akanAgentTap {
|
|
455
538
|
0% {
|
|
456
539
|
transform: scale(0.2);
|
|
@@ -472,6 +555,16 @@ body.akan-mobile-document {
|
|
|
472
555
|
.akan-agent-cursor {
|
|
473
556
|
transition: opacity 200ms ease-out;
|
|
474
557
|
}
|
|
558
|
+
|
|
559
|
+
/* The chevron holds its direction instead of sliding: the way the view is going is the part that carries. */
|
|
560
|
+
.akan-agent-cursor-scrolling::after {
|
|
561
|
+
animation: none;
|
|
562
|
+
opacity: 1;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.akan-agent-cursor-scrolling-up::after {
|
|
566
|
+
transform: rotate(225deg);
|
|
567
|
+
}
|
|
475
568
|
}
|
|
476
569
|
|
|
477
570
|
@keyframes spin {
|
|
@@ -98,7 +98,16 @@ export interface AgentSessionOptions {
|
|
|
98
98
|
* The session keeps none of it: nothing here is transcript, and holding it would mean a re-render per call for
|
|
99
99
|
* something no message renders.
|
|
100
100
|
*/
|
|
101
|
-
onActivity?: (event: ToolActivity) => void
|
|
101
|
+
onActivity?: (event: ToolActivity) => void | Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Called when a turn starts and when it settles — the boundary a host draws the agent's own presence in. The
|
|
104
|
+
* calls of one turn arrive with model turns between them, seconds long, so anything measured in the gap
|
|
105
|
+
* between *calls* keeps ending and restarting inside a turn that never stopped.
|
|
106
|
+
*
|
|
107
|
+
* Only the conversation loop reports here. `compact` runs under the same flag and drives nothing on screen, so
|
|
108
|
+
* a host drawing the agent at work would draw it for a summary nobody asked to watch.
|
|
109
|
+
*/
|
|
110
|
+
onTurn?: (running: boolean) => void;
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
/**
|
|
@@ -365,6 +374,7 @@ export class AgentSession {
|
|
|
365
374
|
async #turn(input: string | ChatMessage[]) {
|
|
366
375
|
const controller = new AbortController();
|
|
367
376
|
this.#controller = controller;
|
|
377
|
+
this.#options.onTurn?.(true);
|
|
368
378
|
if (typeof input === "string") this.#append({ role: "user", text: input });
|
|
369
379
|
else for (const message of input) this.#append(message);
|
|
370
380
|
const maxTurns = this.#options.maxTurns ?? 12;
|
|
@@ -420,6 +430,7 @@ export class AgentSession {
|
|
|
420
430
|
|
|
421
431
|
const draft = this.#messages[this.#messages.length - 1];
|
|
422
432
|
if (draft?.role === "assistant" && !Transcript.carries(draft)) this.#messages = this.#messages.slice(0, -1);
|
|
433
|
+
this.#options.onTurn?.(false);
|
|
423
434
|
this.#notify();
|
|
424
435
|
}
|
|
425
436
|
}
|
|
@@ -41,8 +41,12 @@ export interface ToolRunnerHost {
|
|
|
41
41
|
* That a call is running, for a host drawing it on the page rather than in a transcript. Separate from
|
|
42
42
|
* `progress`, which only ever fires for a tool that chose to report: a call the user has to be told about is
|
|
43
43
|
* every call, and one that says nothing about itself is exactly the one whose effect arrives unexplained.
|
|
44
|
+
*
|
|
45
|
+
* **The `start` is awaited**, so a host may draw something that has to land before the call does — a pointer
|
|
46
|
+
* pressing the link a `navigate` is about to follow is drawing on an element the router would otherwise have
|
|
47
|
+
* replaced first. The host owns the deadline: a decoration that takes its time makes the agent take its time.
|
|
44
48
|
*/
|
|
45
|
-
activity?: (event: ToolActivity) => void
|
|
49
|
+
activity?: (event: ToolActivity) => void | Promise<void>;
|
|
46
50
|
/**
|
|
47
51
|
* Answers a name the surface does not carry — where a consumer puts a built-in of its own. Reached only after
|
|
48
52
|
* the surface came up empty, so a registered tool of the same name shadows it.
|
|
@@ -109,7 +113,7 @@ export class ToolRunner {
|
|
|
109
113
|
const base = { id: call.id, name: call.name };
|
|
110
114
|
const before = this.#surface.snapshot();
|
|
111
115
|
|
|
112
|
-
this.#host.activity?.({ callId: call.id, name: call.name, args: call.args, phase: "start" });
|
|
116
|
+
await this.#host.activity?.({ callId: call.id, name: call.name, args: call.args, phase: "start" });
|
|
113
117
|
let failed: string | undefined;
|
|
114
118
|
try {
|
|
115
119
|
const result = await AgentAbort.run(signal, () =>
|