akanjs 3.0.0-beta.11 → 3.0.0-beta.13

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 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
- push: (href: string, routeOptions?: RouteOptions) => void;
18
- replace: (href: string, routeOptions?: RouteOptions) => void;
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
- const { path } = getPathInfo(pathname, this.#lang, this.#prefix);
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.11",
3
+ "version": "3.0.0-beta.13",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -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
- void globalThis.__AKAN_RSC_NAVIGATE__?.(window.location.href, { replace: true, scrollToTop: false });
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;
@@ -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 long after the last call the pointer stays before fadingone batch's gap, not one call's. */
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
- ScreenFlash.whenStill(target, () => {
32
- const { top, left, width, height } = target.getBoundingClientRect();
33
- const travel = AgentCursor.#moveTo(left + width / 2, top + height / 2);
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
- if (travel) setTimeout(() => AgentCursor.#tap(), travel);
36
- else AgentCursor.#tap();
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, `navigate` included: the router is not an element, and the
26
- * top-of-page bar tried for it read as chrome the page had grown rather than as the agent doing something.
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 handler `agentSessionOf` hands the session. `true` is every effect; an object turns one of them off. */
42
- static sink(option: boolean | AgentVisualOption = true): ((event: ToolActivity) => void) | undefined {
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 (event) => AgentVisual.on(event, settings);
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 || event.phase !== "start") return;
51
- for (const target of AgentVisual.#targetsOf(event)) AgentVisual.#act(target, cursor);
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 row's verb is registered once per row and every registration is interchangeable, so several matches means
69
- * the agent acted on a row nothing on screen identifies. Ringing a guessed one is worse than ringing none.
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
- return targets.length === 1 ? targets : [];
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: boolean) {
139
+ static #act(target: HTMLElement, { reveal, cursor }: Required<AgentVisualOption>): Promise<void> {
76
140
  AgentVisual.#count();
77
- if (AgentVisual.#revealed <= AgentVisual.revealCap && !ScreenFlash.inView(target)) ScreenFlash.reveal(target);
78
- if (cursor) AgentCursor.press(target);
79
- ScreenFlash.ring(target, { className: ScreenFlash.actingClass, ms: ScreenFlash.actingMs });
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 batch is counted by the gap between calls rather than by a turn boundary, which no host reports here: a
92
- * model's calls arrive back to back through one serialized queue, so a quiet second is the end of the batch.
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
- static reveal(target: HTMLElement) {
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.#visible(el)) ??
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.#visible);
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.#visible);
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.#visible(heading) ? ScreenReader.anchorOf(heading) : "";
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.#visible(el) ? el.getAttribute(attr) : null;
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.#visible) ?? null;
124
+ return [...scope.querySelectorAll<HTMLElement>(selector)].find(ScreenTarget.visible) ?? null;
125
125
  }
126
126
 
127
- static #visible(el: HTMLElement) {
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
  },
@@ -8,8 +8,9 @@ export interface DeepLinkOptions extends RouteOptions {
8
8
  resetStack?: boolean;
9
9
  }
10
10
  export interface RouterInstance {
11
- push: (href: string, routeOptions?: RouteOptions) => void;
12
- replace: (href: string, routeOptions?: RouteOptions) => void;
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;
@@ -10,6 +10,8 @@ export type RscClientFetchResponseResult = {
10
10
  type: "patch";
11
11
  response: Response;
12
12
  patch: AkanRscPatchMetadata;
13
+ } | {
14
+ type: "not-found";
13
15
  } | {
14
16
  type: "redirected";
15
17
  status?: number;
@@ -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 long after the last call the pointer stays before fadingone batch's gap, not one call's. */
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
- /** Travels to the control once it has stopped moving, then presses it. */
23
- static press(target: HTMLElement): void;
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, `navigate` included: the router is not an element, and the
21
- * top-of-page bar tried for it read as chrome the page had grown rather than as the agent doing something.
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
- /** The handler `agentSessionOf` hands the session. `true` is every effect; an object turns one of them off. */
34
- static sink(option?: boolean | AgentVisualOption): ((event: ToolActivity) => void) | undefined;
35
- static on(event: ToolActivity, { reveal, cursor }?: AgentVisualOption): void;
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
- static reveal(target: HTMLElement): void;
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
  }
@@ -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. On by default — the chat panel is closed as often as it is open, and
45
- * a change nothing attributes is one the user watches happen for no reason they can see. `false` draws nothing,
46
- * and an object turns one effect off (`visual={{ cursor: false }}`).
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. */
@@ -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. On by default — the chat panel is closed as often as it is open, and
33
- * a change nothing attributes is one the user watches happen for no reason they can see. `false` draws nothing,
34
- * and an object turns one effect off (`visual={{ cursor: false }}`).
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
  /**
@@ -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. On by default — the chat panel is closed as often as it is open, and
86
- * a change nothing attributes is one the user watches happen for no reason they can see. `false` draws nothing,
87
- * and an object turns one effect off (`visual={{ cursor: false }}`).
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. On by default — the chat panel is closed as often as it is open, and
48
- * a change nothing attributes is one the user watches happen for no reason they can see. `false` draws nothing,
49
- * and an object turns one effect off (`visual={{ cursor: false }}`).
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 onActivity = AgentVisual.sink(visual);
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
- ...(onActivity ? { onActivity } : {}),
63
+ ...(drawing ?? {}),
64
64
  });
65
65
  };
@@ -346,7 +346,14 @@ export const ClientSsrBridge = ({ lang, prefix = "", initialPageState }: ClientS
346
346
  fallback();
347
347
  return;
348
348
  }
349
- void navigation.catch((error) => {
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
@@ -59,7 +59,7 @@ export const Menu = ({
59
59
  switchTab(menu);
60
60
  if (scrollToTop) window.scrollTo({ top: 0, behavior: "smooth" });
61
61
  }}
62
- {...agentAttrs(switchTab)}
62
+ {...agentAttrs(switchTab, menu)}
63
63
  role="tab"
64
64
  type="button"
65
65
  >
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
- ): { "data-akan-action"?: string; "data-akan-state"?: string } | Record<string, never> => {
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 { "data-akan-action": tag.action, ...(tag.state ? { "data-akan-state": tag.state } : {}) };
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, () =>