akanjs 3.0.0-alpha.97 → 3.0.0-alpha.99

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/base/baseEnv.ts CHANGED
@@ -164,9 +164,10 @@ export const getEnv = (): ClientEnv => {
164
164
  ? (window.location.host.split(":")[0] ?? "unknown")
165
165
  : "localhost");
166
166
 
167
+ const selfServerPort = serverHost === "localhost" ? process.env.PORT : undefined;
167
168
  const serverPort =
168
169
  side === "server"
169
- ? parseInt(process.env.AKAN_PUBLIC_SERVER_PORT ?? "8282")
170
+ ? parseInt(process.env.AKAN_PUBLIC_SERVER_PORT ?? selfServerPort ?? "8282")
170
171
  : parseInt(window.location.port || (window.location.protocol === "https:" ? "443" : "80"));
171
172
 
172
173
  const serverHttpProtocol: "http:" | "https:" =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.97",
3
+ "version": "3.0.0-alpha.99",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -312,6 +312,7 @@ export class AkanApp {
312
312
  const role = this.#getRole(0);
313
313
 
314
314
  Object.assign(process.env, {
315
+ PORT: String(this.#port),
315
316
  NODE_ENV: AkanApp.#defaultChildNodeEnv(),
316
317
  AKAN_REPLICA: this.#replica.value,
317
318
  AKAN_REPLICA_IDX: "0",
@@ -324,7 +325,7 @@ export class AkanApp {
324
325
  });
325
326
 
326
327
  resetEnvCache();
327
- this.logger.info(`Starting ${role} replica in this process (solo); set AKAN_SOLO=false for the gateway`);
328
+ this.logger.debug(`Starting ${role} replica in this process (solo); set AKAN_SOLO=false for the gateway`);
328
329
  const mod = (await import(this.#serverPath)) as { server?: SoloServer; app?: SoloServer };
329
330
  const server = mod.server ?? mod.app;
330
331
  if (!server?.start) throw new Error("server.ts must export server or app with start()");
@@ -401,6 +402,8 @@ export class AkanApp {
401
402
  cwd: process.cwd(),
402
403
  env: {
403
404
  ...process.env,
405
+
406
+ PORT: String(this.#port),
404
407
  NODE_ENV: AkanApp.#defaultChildNodeEnv(),
405
408
  AKAN_REPLICA: this.#replica.value,
406
409
  AKAN_REPLICA_IDX: String(idx),
@@ -329,7 +329,7 @@ export class AkanServer {
329
329
  };
330
330
  if (!requestedWeb.ssr) {
331
331
  this.web = requestedWeb;
332
- this.logger.info("web off: serving api only (AKAN_SSR=false, or a build with `web: false`)");
332
+ this.logger.debug("web off: serving api only (AKAN_SSR=false, or a build with `web: false`)");
333
333
  return noWeb();
334
334
  }
335
335
  const { WebRouter } = await import("./webRouter");
@@ -263,7 +263,7 @@ export class DiLifecycle {
263
263
  for (const dependency of dependencies) if (candidates.has(dependency)) pending.push(dependency);
264
264
  }
265
265
  const mounted = [...selected].sort((a, b) => a.localeCompare(b)).join(", ");
266
- this.logger.info(`Mounting ${selected.size} of ${candidates.size} module(s): ${mounted}`);
266
+ this.logger.debug(`Mounting ${selected.size} of ${candidates.size} module(s): ${mounted}`);
267
267
  return selected;
268
268
  }
269
269
 
@@ -662,7 +662,7 @@ export class DiLifecycle {
662
662
  },
663
663
  })),
664
664
  );
665
- if (liveKeys.length) this.logger.info(`Live sync: ${liveKeys.length} live slice(s) — ${liveKeys.join(", ")}`);
665
+ if (liveKeys.length) this.logger.verbose(`Live sync: ${liveKeys.length} live slice(s) — ${liveKeys.join(", ")}`);
666
666
  return { routes, wsRoutes, routeOptions };
667
667
  }
668
668
 
@@ -205,7 +205,7 @@ export class CascadeRunner {
205
205
 
206
206
  if (!this.#anyEdges.length) return;
207
207
  const wildcards = this.#anyEdges.map((edge) => `${edge.refName}.${edge.key}`).join(", ");
208
- this.#logger.info(
208
+ this.#logger.verbose(
209
209
  `${this.#anyEdges.length} wildcard removeWith edge(s) (${wildcards}): every removal probes them, ` +
210
210
  `and no cascade removes in one query`,
211
211
  );
package/store/action.ts CHANGED
@@ -584,6 +584,12 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
584
584
 
585
585
  const current = (this.get() as { [key: string]: any })[names.modelDraft] as DraftState | null;
586
586
  if (current?.key !== draft.key) return;
587
+
588
+ const openedForm = (this.get() as { [key: string]: any })[names.modelForm] as object;
589
+ if (DraftStore.contentHash(record.form) === DraftStore.contentHash(DraftStore.encodeForm(refName, openedForm))) {
590
+ await DraftStore.remove(draft.key);
591
+ return;
592
+ }
587
593
  let form: object;
588
594
  try {
589
595
  form = DraftStore.decodeForm(refName, record.form);
@@ -1222,6 +1228,13 @@ export const makeActions = (refName: string, slice: { [key: string]: SerializedS
1222
1228
  * asking for what comes after them cannot drift from what is displayed. It also leaves `pageOf<Model>` at
1223
1229
  * 1, which is what keeps live placement — refused anywhere but the first page — working past the first
1224
1230
  * "more".
1231
+ *
1232
+ * It reads `<model>ListLoading` and never sets it: an append leaves the rows on screen, so raising it
1233
+ * would put the whole-list spinner over one and would make `applyLive<Model>` drop every live event until
1234
+ * the batch lands. So concurrent calls are NOT rejected — two of them fetch the same offset, since the
1235
+ * list they measure has not grown yet. That is a wasted round trip and never a wrong list: the ticket
1236
+ * makes only the newest response apply, and both asked for the same rows. A caller that minds the wasted
1237
+ * request holds its own in-flight flag, the way `InfiniteScroll` does.
1225
1238
  */
1226
1239
  [namesOfSlice.loadMoreOfModel]: async function (this: SetGet, options?: FetchPolicy) {
1227
1240
  const currentState = this.get() as { [key: string]: any };
@@ -17,6 +17,9 @@ const MAX_DRAFTS_PER_IDENTITY = 30;
17
17
  */
18
18
  const VOLATILE_CLAIMS = ["iat", "exp", "nbf", "jti"] as const;
19
19
 
20
+ /** The fields a form carries from its row rather than from what the user typed. */
21
+ const RECORD_STAMPS = new Set(["createdAt", "updatedAt", "removedAt"]);
22
+
20
23
  export interface DraftRecord {
21
24
  v: number;
22
25
  /** ISO. Drives both the "n minutes ago" label and the TTL/LRU sweep. */
@@ -143,6 +146,17 @@ export class DraftStore {
143
146
  return DraftStore.#hash8(DraftStore.#stableStringify(DraftStore.encodeForm(refName, form)));
144
147
  }
145
148
 
149
+ /**
150
+ * What two encoded forms holding the same values hash to, whatever record stamps they carry.
151
+ *
152
+ * The stamps come from the row rather than from the user, so a save the form made itself moves `updatedAt` and
153
+ * would otherwise make a draft look different from the record that already holds it.
154
+ */
155
+ static contentHash(encoded: Record<string, unknown>): string {
156
+ const content = Object.fromEntries(Object.entries(encoded).filter(([key]) => !RECORD_STAMPS.has(key)));
157
+ return DraftStore.#hash8(DraftStore.#stableStringify(content));
158
+ }
159
+
146
160
  static async read(key: string): Promise<DraftRecord | null> {
147
161
  const storage = DraftStore.#storage();
148
162
  if (!storage) return null;
@@ -36,6 +36,13 @@ export declare class DraftStore {
36
36
  static decodeForm(refName: string, plain: Record<string, unknown>): object;
37
37
  /** What the dirty check compares. Cheap enough to take once per debounce window, not once per keystroke. */
38
38
  static formHash(refName: string, form: object): string;
39
+ /**
40
+ * What two encoded forms holding the same values hash to, whatever record stamps they carry.
41
+ *
42
+ * The stamps come from the row rather than from the user, so a save the form made itself moves `updatedAt` and
43
+ * would otherwise make a draft look different from the record that already holds it.
44
+ */
45
+ static contentHash(encoded: Record<string, unknown>): string;
39
46
  static read(key: string): Promise<DraftRecord | null>;
40
47
  static write(key: string, record: DraftRecord): Promise<void>;
41
48
  static remove(key: string): Promise<void>;
@@ -2,6 +2,11 @@ export interface InfiniteScrollProps {
2
2
  hasMore: boolean;
3
3
  onLoadMore: () => Promise<void>;
4
4
  children: React.ReactNode;
5
+ /**
6
+ * Load earlier rows above the ones in hand, preserving the reading position across the prepend. Assumes
7
+ * normal column flow. It does not scroll anywhere at mount, so a list meant to open at its newest row scrolls
8
+ * itself — and until it does, the sentinel is on screen and loads one window unasked.
9
+ */
5
10
  reverse?: boolean;
6
11
  }
7
12
  export declare const InfiniteScroll: ({ hasMore, onLoadMore, children, reverse }: InfiniteScrollProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,14 +1,24 @@
1
1
  import type { SliceMeta } from "akanjs/fetch";
2
+ export interface DraftBarViewProps {
3
+ className?: string;
4
+ /** The model the recovered form belongs to. */
5
+ refName: string;
6
+ /**
7
+ * `conflict` is a decision the user has to settle — the record moved since the draft was taken, so the form
8
+ * shows the server's value and the bar offers the older one. `applied` is a notice: the draft is what is on
9
+ * screen, and the bar is the way back.
10
+ */
11
+ state: "conflict" | "applied";
12
+ /** When the draft was taken. */
13
+ savedAt: Date;
14
+ /** Puts the offered draft into the form. Passed in the `conflict` state only. */
15
+ onRestore?: () => void;
16
+ /** Drops the draft and keeps the form as it was opened. */
17
+ onDiscard: () => void;
18
+ }
2
19
  interface DraftBarProps {
3
20
  className?: string;
4
21
  slice: SliceMeta;
5
22
  }
6
- /**
7
- * What the user is told about a recovered form.
8
- *
9
- * Two states, and the difference is whether the draft is already in the form. A pending one is a conflict the
10
- * user has to settle — the record moved since the draft was taken, so the form shows the server's value and this
11
- * offers the older one. An applied one is just a notice: the draft is what is on screen, and this is the way back.
12
- */
13
23
  export default function DraftBar({ className, slice }: DraftBarProps): import("react/jsx-runtime").JSX.Element | null;
14
24
  export {};
@@ -1,5 +1,6 @@
1
1
  import type { SliceMeta } from "akanjs/fetch";
2
2
  import type { ReactNode } from "react";
3
+ import type { DraftProp } from "./draftScope.d.ts";
3
4
  interface EditProps {
4
5
  type?: "icon" | "button";
5
6
  className?: string;
@@ -11,6 +12,8 @@ interface EditProps {
11
12
  renderTitle?: ((model: {
12
13
  id: string;
13
14
  }) => string | ReactNode) | string;
15
+ /** Draft recovery for the form this opens. `false` turns it off; a string names the scope explicitly. */
16
+ draft?: DraftProp;
14
17
  }
15
- export default function Edit({ className, wrapperClassName, type, children, slice, modelId, modal, renderTitle, }: EditProps): import("react/jsx-runtime").JSX.Element;
18
+ export default function Edit({ className, wrapperClassName, type, children, slice, modelId, modal, renderTitle, draft, }: EditProps): import("react/jsx-runtime").JSX.Element;
16
19
  export {};
@@ -9,6 +9,8 @@ interface EditModelProps<Full> {
9
9
  slice: SliceMeta;
10
10
  /** Additional classes for the wrapper. */
11
11
  className?: string;
12
+ /** Additional classes for the recovered-form banner this shell draws above the form. */
13
+ draftBarClassName?: string;
12
14
  /** Re-check submit eligibility when form state changes. */
13
15
  checkSubmit?: boolean;
14
16
  /** Client edit promise or partial form seed. */
@@ -54,5 +56,5 @@ interface EditModalProps<Full extends {
54
56
  }
55
57
  export default function EditModal<Full extends {
56
58
  id: string;
57
- }>({ type, slice, id, className, disabled, checkSubmit, modalClassName, edit, modal, renderTitle, children, submitText, submitClassName, submitOption, renderSubmit, loadingWrapper, draft, onSubmit, onCancel, }: EditModalProps<Full>): import("react/jsx-runtime").JSX.Element | undefined;
59
+ }>({ type, slice, id, className, draftBarClassName, disabled, checkSubmit, modalClassName, edit, modal, renderTitle, children, submitText, submitClassName, submitOption, renderSubmit, loadingWrapper, draft, onSubmit, onCancel, }: EditModalProps<Full>): import("react/jsx-runtime").JSX.Element | undefined;
58
60
  export {};
@@ -22,6 +22,7 @@ import type { SkeletonProps } from "../Loading/Skeleton.d.ts";
22
22
  import type { SpinProps } from "../Loading/Spin.d.ts";
23
23
  import type { MenuProps } from "../Menu.d.ts";
24
24
  import type { ModalProps } from "../Modal.d.ts";
25
+ import type { DraftBarViewProps } from "../Model/DraftBar.d.ts";
25
26
  import type { PaginationProps } from "../Pagination.d.ts";
26
27
  import type { PopconfirmProps } from "../Popconfirm.d.ts";
27
28
  import type { ItemProps as RadioItemProps, RadioProps } from "../Radio.d.ts";
@@ -54,6 +55,7 @@ export interface AkanUiOverrides {
54
55
  Menu: ComponentType<MenuProps>;
55
56
  Tooltip: ComponentType<TooltipProps>;
56
57
  Unauthorized: ComponentType<UnauthorizedProps>;
58
+ DraftBar: ComponentType<DraftBarViewProps>;
57
59
  AgentChat: ComponentType<AgentChatProps>;
58
60
  AgentLauncher: ComponentType<AgentLauncherProps>;
59
61
  AgentBubble: ComponentType<AgentBubbleProps>;
@@ -6,19 +6,70 @@ export interface InfiniteScrollProps {
6
6
  hasMore: boolean;
7
7
  onLoadMore: () => Promise<void>;
8
8
  children: React.ReactNode;
9
+ /**
10
+ * Load earlier rows above the ones in hand, preserving the reading position across the prepend. Assumes
11
+ * normal column flow. It does not scroll anywhere at mount, so a list meant to open at its newest row scrolls
12
+ * itself — and until it does, the sentinel is on screen and loads one window unasked.
13
+ */
9
14
  reverse?: boolean;
10
15
  }
11
16
 
17
+ let warnedColumnReverse = false;
18
+
19
+ /**
20
+ * The sentinel is positioned by DOM order alone — first child to load earlier, last child to load more — so a
21
+ * `column-reverse` parent paints it at the opposite end from the rows it controls, and `scrollTop: 0` is then
22
+ * that same end, so it also fires at mount. `flex-col-reverse` is the usual no-JS way to pin a chat to the
23
+ * bottom, so a caller reaching for `reverse` may well already have it; the result reads as a control placed
24
+ * wrongly rather than as an error, which is why it is worth saying out loud once.
25
+ */
26
+ const warnColumnReverse = (sentinel: Element | null) => {
27
+ if (warnedColumnReverse || process.env.AKAN_PUBLIC_ENV !== "local") return;
28
+ const parent = sentinel?.parentElement;
29
+ if (!parent || getComputedStyle(parent).flexDirection !== "column-reverse") return;
30
+ warnedColumnReverse = true;
31
+ console.warn(
32
+ "<InfiniteScroll> sits in a `flex-col-reverse` parent, which paints its load sentinel at the end opposite the rows it loads, and fires it at mount. Drop `flex-col-reverse` and let `reverse` hold the reading position instead.",
33
+ );
34
+ };
35
+
36
+ const scrollableOverflows = new Set(["auto", "scroll", "overlay"]);
37
+
38
+ /**
39
+ * The element that actually scrolls the sentinel — the document only once no ancestor has taken the job.
40
+ *
41
+ * A chat timeline or a log tail scrolls inside its own `overflow-y-auto` box, and there the document does not
42
+ * move at all, so anchoring `document.scrollingElement` restores a position nothing changed. Resolved per load
43
+ * rather than once, because the box that scrolls is a layout outcome and a caller cannot be asked to name it.
44
+ */
45
+ const scrollerOf = (sentinel: Element | null) => {
46
+ if (typeof document === "undefined") return null;
47
+ let el = sentinel?.parentElement ?? null;
48
+ while (el && el !== document.body && el !== document.documentElement) {
49
+
50
+ if (el.scrollHeight > el.clientHeight && scrollableOverflows.has(getComputedStyle(el).overflowY)) return el;
51
+ el = el.parentElement;
52
+ }
53
+ return document.scrollingElement;
54
+ };
55
+
12
56
  export const InfiniteScroll = ({ hasMore, onLoadMore, children, reverse }: InfiniteScrollProps) => {
13
57
  const [isFetching, setIsFetching] = useState(false);
14
58
  const isFetchingRef = useRef(false);
15
59
  const target = useRef<HTMLDivElement>(null);
16
60
 
17
61
  useEffect(() => {
18
- const observer = new IntersectionObserver((entries) => {
19
- const [entry] = entries;
20
- if (entry.isIntersecting) void fetchMoreItems();
21
- });
62
+
63
+ warnColumnReverse(target.current);
64
+ const scroller = scrollerOf(target.current);
65
+ const root = scroller && scroller !== document.scrollingElement ? scroller : null;
66
+ const observer = new IntersectionObserver(
67
+ (entries) => {
68
+ const [entry] = entries;
69
+ if (entry.isIntersecting) void fetchMoreItems();
70
+ },
71
+ { root },
72
+ );
22
73
  if (target.current) observer.observe(target.current);
23
74
  return () => {
24
75
  observer.disconnect();
@@ -28,7 +79,7 @@ export const InfiniteScroll = ({ hasMore, onLoadMore, children, reverse }: Infin
28
79
  const fetchMoreItems = async () => {
29
80
  if (isFetchingRef.current) return;
30
81
 
31
- const scroller = reverse ? document.scrollingElement : null;
82
+ const scroller = reverse ? scrollerOf(target.current) : null;
32
83
  const prevScrollHeight = scroller?.scrollHeight ?? 0;
33
84
  const prevScrollTop = scroller?.scrollTop ?? 0;
34
85
 
@@ -9,21 +9,75 @@ import { AiOutlineDelete, AiOutlineHistory, AiOutlineRollback } from "react-icon
9
9
  import { agentAttrs } from "../agentAttrs";
10
10
  import { Button } from "../Button";
11
11
  import { RecentTime } from "../RecentTime";
12
+ import { createOverridable } from "../UiOverride";
12
13
 
13
- interface DraftBarProps {
14
+ export interface DraftBarViewProps {
14
15
  className?: string;
15
- slice: SliceMeta;
16
+ /** The model the recovered form belongs to. */
17
+ refName: string;
18
+ /**
19
+ * `conflict` is a decision the user has to settle — the record moved since the draft was taken, so the form
20
+ * shows the server's value and the bar offers the older one. `applied` is a notice: the draft is what is on
21
+ * screen, and the bar is the way back.
22
+ */
23
+ state: "conflict" | "applied";
24
+ /** When the draft was taken. */
25
+ savedAt: Date;
26
+ /** Puts the offered draft into the form. Passed in the `conflict` state only. */
27
+ onRestore?: () => void;
28
+ /** Drops the draft and keeps the form as it was opened. */
29
+ onDiscard: () => void;
16
30
  }
17
31
 
32
+ const DefaultDraftBar = ({ className, state, savedAt, onRestore, onDiscard }: DraftBarViewProps) => {
33
+ const { l } = usePage();
34
+ if (state === "conflict")
35
+ return (
36
+ <div
37
+ className={cn(
38
+ "mb-4 flex flex-wrap items-center gap-2 rounded-box border border-warning/40 bg-warning/10 p-3",
39
+ className,
40
+ )}
41
+ >
42
+ <AiOutlineHistory className="text-warning" />
43
+ <span className="flex-1 text-foreground/80 text-sm">
44
+ {l("base.draftConflict")} <RecentTime date={savedAt} />
45
+ </span>
46
+ <Button {...agentAttrs(onRestore)} size="sm" onClick={() => onRestore?.()}>
47
+ <AiOutlineRollback /> {l("base.draftRestore")}
48
+ </Button>
49
+ <Button {...agentAttrs(onDiscard)} size="sm" variant="ghost" onClick={() => onDiscard()}>
50
+ <AiOutlineDelete /> {l("base.draftDiscard")}
51
+ </Button>
52
+ </div>
53
+ );
54
+ return (
55
+ <div className={cn("mb-4 flex flex-wrap items-center gap-2 text-foreground/60 text-xs", className)}>
56
+ <AiOutlineHistory />
57
+ <span className="flex-1">
58
+ {l("base.draftApplied")} <RecentTime date={savedAt} />
59
+ </span>
60
+ <Button {...agentAttrs(onDiscard)} size="xs" variant="ghost" onClick={() => onDiscard()}>
61
+ {l("base.draftStartOver")}
62
+ </Button>
63
+ </div>
64
+ );
65
+ };
66
+
18
67
  /**
19
- * What the user is told about a recovered form.
68
+ * The banner itself, route-overridable through `page/**\/_overrides.tsx` (slot `DraftBar`).
20
69
  *
21
- * Two states, and the difference is whether the draft is already in the form. A pending one is a conflict the
22
- * user has to settle — the record moved since the draft was taken, so the form shows the server's value and this
23
- * offers the older one. An applied one is just a notice: the draft is what is on screen, and this is the way back.
70
+ * The shell below keeps the draft state and publishes the two agent tools, so a replacement re-skins the notice
71
+ * without reaching into the store under string keys or re-declaring what an agent may pull.
24
72
  */
73
+ const DraftBarView = createOverridable("DraftBar", DefaultDraftBar);
74
+
75
+ interface DraftBarProps {
76
+ className?: string;
77
+ slice: SliceMeta;
78
+ }
79
+
25
80
  export default function DraftBar({ className, slice }: DraftBarProps) {
26
- const { l } = usePage();
27
81
  const { refName } = slice;
28
82
  const [modelName, ModelName] = useMemo(() => [lowerlize(refName), capitalize(refName)], []);
29
83
  const names = useMemo(
@@ -50,35 +104,24 @@ export default function DraftBar({ className, slice }: DraftBarProps) {
50
104
 
51
105
  if (draft?.pending)
52
106
  return (
53
- <div
54
- className={cn(
55
- "mb-4 flex flex-wrap items-center gap-2 rounded-box border border-warning/40 bg-warning/10 p-3",
56
- className,
57
- )}
58
- >
59
- <AiOutlineHistory className="text-warning" />
60
- <span className="flex-1 text-foreground/80 text-sm">
61
- {l("base.draftConflict")} <RecentTime date={draft.pending.savedAt} />
62
- </span>
63
- <Button {...agentAttrs(restoreDraft)} size="sm" onClick={() => restoreDraft()}>
64
- <AiOutlineRollback /> {l("base.draftRestore")}
65
- </Button>
66
- <Button {...agentAttrs(discardDraft)} size="sm" variant="ghost" onClick={() => discardDraft()}>
67
- <AiOutlineDelete /> {l("base.draftDiscard")}
68
- </Button>
69
- </div>
107
+ <DraftBarView
108
+ className={className}
109
+ refName={refName}
110
+ state="conflict"
111
+ savedAt={draft.pending.savedAt}
112
+ onRestore={restoreDraft}
113
+ onDiscard={discardDraft}
114
+ />
70
115
  );
71
116
  if (draft?.appliedAt)
72
117
  return (
73
- <div className={cn("mb-4 flex flex-wrap items-center gap-2 text-foreground/60 text-xs", className)}>
74
- <AiOutlineHistory />
75
- <span className="flex-1">
76
- {l("base.draftApplied")} <RecentTime date={draft.appliedAt} />
77
- </span>
78
- <Button {...agentAttrs(discardDraft)} size="xs" variant="ghost" onClick={() => discardDraft()}>
79
- {l("base.draftStartOver")}
80
- </Button>
81
- </div>
118
+ <DraftBarView
119
+ className={className}
120
+ refName={refName}
121
+ state="applied"
122
+ savedAt={draft.appliedAt}
123
+ onDiscard={discardDraft}
124
+ />
82
125
  );
83
126
  return null;
84
127
  }
package/ui/Model/Edit.tsx CHANGED
@@ -3,6 +3,7 @@ import type { SliceMeta } from "akanjs/fetch";
3
3
  import type { ReactNode } from "react";
4
4
  import { AiOutlineEdit } from "react-icons/ai";
5
5
 
6
+ import type { DraftProp } from "./draftScope";
6
7
  import EditModal from "./EditModal";
7
8
  import EditWrapper from "./EditWrapper";
8
9
 
@@ -15,6 +16,8 @@ interface EditProps {
15
16
  modelId: string;
16
17
  modal?: string | null;
17
18
  renderTitle?: ((model: { id: string }) => string | ReactNode) | string;
19
+ /** Draft recovery for the form this opens. `false` turns it off; a string names the scope explicitly. */
20
+ draft?: DraftProp;
18
21
  }
19
22
 
20
23
  export default function Edit({
@@ -26,6 +29,7 @@ export default function Edit({
26
29
  modelId,
27
30
  modal,
28
31
  renderTitle,
32
+ draft,
29
33
  }: EditProps) {
30
34
  const { l } = usePage();
31
35
  return (
@@ -35,10 +39,11 @@ export default function Edit({
35
39
  slice={slice}
36
40
  modelId={modelId}
37
41
  modal={modal}
42
+ draft={draft}
38
43
  >
39
44
  <AiOutlineEdit /> {type === "button" ? l("base.edit") : null}
40
45
  </EditWrapper>
41
- <EditModal renderTitle={renderTitle} slice={slice} id={modelId}>
46
+ <EditModal renderTitle={renderTitle} slice={slice} id={modelId} draft={draft}>
42
47
  {children}
43
48
  </EditModal>
44
49
  </div>
@@ -27,6 +27,8 @@ interface EditModelProps<Full> {
27
27
  slice: SliceMeta;
28
28
  /** Additional classes for the wrapper. */
29
29
  className?: string;
30
+ /** Additional classes for the recovered-form banner this shell draws above the form. */
31
+ draftBarClassName?: string;
30
32
  /** Re-check submit eligibility when form state changes. */
31
33
  checkSubmit?: boolean;
32
34
  /** Client edit promise or partial form seed. */
@@ -52,6 +54,7 @@ const EditModel = <Full,>({
52
54
  type = "modal",
53
55
  slice,
54
56
  className,
57
+ draftBarClassName,
55
58
  checkSubmit = true,
56
59
  edit,
57
60
  modal,
@@ -107,7 +110,7 @@ const EditModel = <Full,>({
107
110
 
108
111
  return (
109
112
  <LoadingWrapper className={cn("w-full", className)}>
110
- <DraftBar slice={slice} />
113
+ <DraftBar className={draftBarClassName} slice={slice} />
111
114
  {children}
112
115
  </LoadingWrapper>
113
116
  );
@@ -142,6 +145,7 @@ export default function EditModal<Full extends { id: string }>({
142
145
  slice,
143
146
  id,
144
147
  className,
148
+ draftBarClassName,
145
149
  disabled,
146
150
  checkSubmit = true,
147
151
  modalClassName,
@@ -368,6 +372,7 @@ export default function EditModal<Full extends { id: string }>({
368
372
  type={type}
369
373
  slice={slice}
370
374
  className={className}
375
+ draftBarClassName={draftBarClassName}
371
376
  checkSubmit={checkSubmit}
372
377
  edit={edit}
373
378
  modal={modal}
@@ -385,6 +390,7 @@ export default function EditModal<Full extends { id: string }>({
385
390
  type={type}
386
391
  slice={slice}
387
392
  className={className}
393
+ draftBarClassName={draftBarClassName}
388
394
  checkSubmit={checkSubmit}
389
395
  edit={edit}
390
396
  modal={modal}
@@ -24,6 +24,7 @@ import type { SkeletonProps } from "../Loading/Skeleton";
24
24
  import type { SpinProps } from "../Loading/Spin";
25
25
  import type { MenuProps } from "../Menu";
26
26
  import type { ModalProps } from "../Modal";
27
+ import type { DraftBarViewProps } from "../Model/DraftBar";
27
28
  import type { PaginationProps } from "../Pagination";
28
29
  import type { PopconfirmProps } from "../Popconfirm";
29
30
  import type { ItemProps as RadioItemProps, RadioProps } from "../Radio";
@@ -58,6 +59,8 @@ export interface AkanUiOverrides {
58
59
  Menu: ComponentType<MenuProps>;
59
60
  Tooltip: ComponentType<TooltipProps>;
60
61
  Unauthorized: ComponentType<UnauthorizedProps>;
62
+
63
+ DraftBar: ComponentType<DraftBarViewProps>;
61
64
  AgentChat: ComponentType<AgentChatProps>;
62
65
 
63
66
  AgentLauncher: ComponentType<AgentLauncherProps>;