@cosmicdrift/kumiko-renderer 0.146.4 → 0.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/format-when.test.ts +12 -0
- package/src/app/kumiko-screen.tsx +27 -5
- package/src/components/render-edit.tsx +1 -2
- package/src/components/render-list.tsx +24 -2
- package/src/format-when.ts +6 -0
- package/src/hooks/__tests__/use-ai-text.test.tsx +176 -0
- package/src/hooks/use-ai-text.ts +171 -0
- package/src/hooks/use-mutation.ts +16 -6
- package/src/i18n-defaults.ts +32 -0
- package/src/i18n.tsx +2 -3
- package/src/index.ts +13 -0
- package/src/primitives.tsx +4 -0
- package/src/sort-by-accessor.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.147.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.147.0",
|
|
20
20
|
"react": "^19.2.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatWhen } from "../format-when";
|
|
3
|
+
|
|
4
|
+
describe("formatWhen", () => {
|
|
5
|
+
test("formats a parseable ISO timestamp", () => {
|
|
6
|
+
expect(formatWhen("2024-01-01T00:00:00.000Z")).not.toBe("2024-01-01T00:00:00.000Z");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("falls back to the raw value on an unparseable input", () => {
|
|
10
|
+
expect(formatWhen("garbage")).toBe("garbage");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -52,6 +52,10 @@ function evalRowExtractor(
|
|
|
52
52
|
return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
|
|
56
|
+
return action.kind === "writeHandler" || action.kind === undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
55
59
|
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
56
60
|
// routes it to the right renderer based on `screen.type`. Command
|
|
57
61
|
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
@@ -877,8 +881,8 @@ function EntityListBody({
|
|
|
877
881
|
};
|
|
878
882
|
}
|
|
879
883
|
if (dispatcher === undefined) return null;
|
|
880
|
-
if (action
|
|
881
|
-
const writeAction = action
|
|
884
|
+
if (!isWriteHandlerRowAction(action)) return null;
|
|
885
|
+
const writeAction = action;
|
|
882
886
|
const writeActionVisible = writeAction.visible;
|
|
883
887
|
return {
|
|
884
888
|
id: writeAction.id,
|
|
@@ -946,7 +950,16 @@ function EntityListBody({
|
|
|
946
950
|
}),
|
|
947
951
|
onTrigger: async () => {
|
|
948
952
|
const payload = action.payload ?? {};
|
|
949
|
-
await dispatcher.write(action.handler, payload);
|
|
953
|
+
const result = await dispatcher.write(action.handler, payload);
|
|
954
|
+
// Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
|
|
955
|
+
// 2026-06-07): ein verschlucktes Failure-Result sah wie
|
|
956
|
+
// "nichts passiert" aus.
|
|
957
|
+
if (!result.isSuccess) {
|
|
958
|
+
throw new WriteFailedError(
|
|
959
|
+
result.error,
|
|
960
|
+
dispatcherErrorText(result.error, effectiveTranslate),
|
|
961
|
+
);
|
|
962
|
+
}
|
|
950
963
|
},
|
|
951
964
|
};
|
|
952
965
|
})
|
|
@@ -1112,7 +1125,7 @@ function ProjectionListBody({
|
|
|
1112
1125
|
// Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
|
|
1113
1126
|
// Dialog kommentarlos).
|
|
1114
1127
|
if (dispatcher === undefined) continue;
|
|
1115
|
-
const writeAction = action
|
|
1128
|
+
const writeAction = action;
|
|
1116
1129
|
const writeVisible = writeAction.visible;
|
|
1117
1130
|
out.push({
|
|
1118
1131
|
id: writeAction.id,
|
|
@@ -1170,7 +1183,16 @@ function ProjectionListBody({
|
|
|
1170
1183
|
confirmLabel: effectiveTranslate(action.confirmLabel),
|
|
1171
1184
|
}),
|
|
1172
1185
|
onTrigger: async () => {
|
|
1173
|
-
await dispatcher.write(action.handler, action.payload ?? {});
|
|
1186
|
+
const result = await dispatcher.write(action.handler, action.payload ?? {});
|
|
1187
|
+
// Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
|
|
1188
|
+
// 2026-06-07): ein verschlucktes Failure-Result sah wie
|
|
1189
|
+
// "nichts passiert" aus.
|
|
1190
|
+
if (!result.isSuccess) {
|
|
1191
|
+
throw new WriteFailedError(
|
|
1192
|
+
result.error,
|
|
1193
|
+
dispatcherErrorText(result.error, effectiveTranslate),
|
|
1194
|
+
);
|
|
1195
|
+
}
|
|
1174
1196
|
},
|
|
1175
1197
|
});
|
|
1176
1198
|
}
|
|
@@ -247,8 +247,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
247
247
|
[screen, entity, snapshot.values, translate, featureName],
|
|
248
248
|
);
|
|
249
249
|
|
|
250
|
-
//
|
|
251
|
-
// section with no fields of its own too (it carries its own dirty/save).
|
|
250
|
+
// true for an extension section with no fields of its own too (it carries its own dirty/save).
|
|
252
251
|
const isFormEditable = hasEditableSection(vm.sections);
|
|
253
252
|
|
|
254
253
|
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
@@ -148,7 +148,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
148
148
|
// wären Column-Header raw i18n-Keys.
|
|
149
149
|
const t = useTranslation();
|
|
150
150
|
const translate: Translate = translateProp ?? t;
|
|
151
|
-
const { DataTable, Button, Dialog, Input, Text } = usePrimitives();
|
|
151
|
+
const { DataTable, Button, Dialog, Input, Text, Banner } = usePrimitives();
|
|
152
152
|
|
|
153
153
|
// Local Search-Buffer + Debounce. Externe Änderungen (Browser-Back,
|
|
154
154
|
// Cross-Component-Reset) spiegeln wir per Sync-Effect zurück; Tipps
|
|
@@ -280,7 +280,13 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
280
280
|
{hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
|
|
281
281
|
{hasToolbarActions &&
|
|
282
282
|
toolbarActions.map((a) => (
|
|
283
|
-
<ToolbarActionView
|
|
283
|
+
<ToolbarActionView
|
|
284
|
+
key={a.id}
|
|
285
|
+
action={a}
|
|
286
|
+
Button={Button}
|
|
287
|
+
Dialog={Dialog}
|
|
288
|
+
Banner={Banner}
|
|
289
|
+
/>
|
|
284
290
|
))}
|
|
285
291
|
{onCreate !== undefined && (
|
|
286
292
|
<Button variant="primary" onClick={onCreate} testId="render-list-create">
|
|
@@ -423,18 +429,29 @@ function ToolbarActionView({
|
|
|
423
429
|
action,
|
|
424
430
|
Button,
|
|
425
431
|
Dialog,
|
|
432
|
+
Banner,
|
|
426
433
|
}: {
|
|
427
434
|
readonly action: ToolbarActionButton;
|
|
428
435
|
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
429
436
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
437
|
+
readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
|
|
430
438
|
}): ReactNode {
|
|
431
439
|
const [busy, setBusy] = useState(false);
|
|
432
440
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
441
|
+
// Toolbar-Actions haben (anders als rowActions, die über die DataTable-
|
|
442
|
+
// Primitive laufen) keine Toast-Primitive zur Verfügung — ein inline
|
|
443
|
+
// Error-Banner ist der surfacing-Pfad hier. Gleicher Grund wie bei
|
|
444
|
+
// rowActions (Prod-Bug 2026-06-07): ein verschlucktes Failure-Result
|
|
445
|
+
// sieht für den User wie "nichts passiert" aus.
|
|
446
|
+
const [errorText, setErrorText] = useState<string | null>(null);
|
|
433
447
|
|
|
434
448
|
const trigger = async (): Promise<void> => {
|
|
435
449
|
setBusy(true);
|
|
450
|
+
setErrorText(null);
|
|
436
451
|
try {
|
|
437
452
|
await action.onTrigger();
|
|
453
|
+
} catch (e) {
|
|
454
|
+
setErrorText(e instanceof Error ? e.message : String(e));
|
|
438
455
|
} finally {
|
|
439
456
|
setBusy(false);
|
|
440
457
|
}
|
|
@@ -469,6 +486,11 @@ function ToolbarActionView({
|
|
|
469
486
|
onConfirm={trigger}
|
|
470
487
|
testId={`render-list-toolbar-action-${action.id}-dialog`}
|
|
471
488
|
/>
|
|
489
|
+
{errorText !== null && (
|
|
490
|
+
<Banner variant="error" testId={`render-list-toolbar-action-${action.id}-error`}>
|
|
491
|
+
{errorText}
|
|
492
|
+
</Banner>
|
|
493
|
+
)}
|
|
472
494
|
</>
|
|
473
495
|
);
|
|
474
496
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Shared timestamp formatter for operator screens (audit log, job runs) —
|
|
2
|
+
// falls back to the raw ISO string on an unparseable value instead of "Invalid Date".
|
|
3
|
+
export function formatWhen(value: string): string {
|
|
4
|
+
const d = new Date(value);
|
|
5
|
+
return Number.isNaN(d.getTime()) ? value : d.toLocaleString();
|
|
6
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
+
import { useAiTextAction, useCompletion } from "../use-ai-text";
|
|
7
|
+
|
|
8
|
+
function makeDispatcher(query: Dispatcher["query"]): Dispatcher {
|
|
9
|
+
return {
|
|
10
|
+
query,
|
|
11
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
12
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
13
|
+
statusStore: {
|
|
14
|
+
getState: () => "online",
|
|
15
|
+
subscribe: () => () => {},
|
|
16
|
+
} as unknown as Dispatcher["statusStore"],
|
|
17
|
+
pendingWrites: () => [],
|
|
18
|
+
pendingFiles: () => [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function wrapperFor(dispatcher: Dispatcher) {
|
|
23
|
+
return ({ children }: { readonly children: ReactNode }) => (
|
|
24
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("useAiTextAction", () => {
|
|
29
|
+
test("success → state='success', result carries the text", async () => {
|
|
30
|
+
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => ({
|
|
31
|
+
isSuccess: true,
|
|
32
|
+
data: {
|
|
33
|
+
type: "text",
|
|
34
|
+
text: `echo:${(payload as { text: string }).text}`,
|
|
35
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
36
|
+
},
|
|
37
|
+
})) as unknown as Dispatcher["query"]);
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
40
|
+
|
|
41
|
+
expect(result.current.state).toBe("idle");
|
|
42
|
+
await act(async () => {
|
|
43
|
+
await result.current.run({ mode: "correct", text: "hi" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.current.state).toBe("success");
|
|
47
|
+
expect(result.current.result).toEqual({
|
|
48
|
+
type: "text",
|
|
49
|
+
text: "echo:hi",
|
|
50
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
51
|
+
});
|
|
52
|
+
expect(result.current.error).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("cap_exceeded error → state='cap-exceeded'", async () => {
|
|
56
|
+
const dispatcher = makeDispatcher((async () => ({
|
|
57
|
+
isSuccess: false,
|
|
58
|
+
error: { code: "cap_exceeded", message: "capped", i18nKey: "errors.cap" },
|
|
59
|
+
})) as unknown as Dispatcher["query"]);
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
62
|
+
|
|
63
|
+
await act(async () => {
|
|
64
|
+
await result.current.run({ mode: "correct", text: "hi" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(result.current.state).toBe("cap-exceeded");
|
|
68
|
+
expect(result.current.error?.code).toBe("cap_exceeded");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("feature_disabled error → state='unavailable' (graceful degradation)", async () => {
|
|
72
|
+
const dispatcher = makeDispatcher((async () => ({
|
|
73
|
+
isSuccess: false,
|
|
74
|
+
error: { code: "feature_disabled", message: "off", i18nKey: "errors.disabled" },
|
|
75
|
+
})) as unknown as Dispatcher["query"]);
|
|
76
|
+
|
|
77
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await result.current.run({ mode: "complete", text: "hi" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(result.current.state).toBe("unavailable");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("reset clears state/result/error back to idle", async () => {
|
|
87
|
+
const dispatcher = makeDispatcher((async () => ({
|
|
88
|
+
isSuccess: false,
|
|
89
|
+
error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
|
|
90
|
+
})) as unknown as Dispatcher["query"]);
|
|
91
|
+
|
|
92
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
93
|
+
await act(async () => {
|
|
94
|
+
await result.current.run({ mode: "correct", text: "hi" });
|
|
95
|
+
});
|
|
96
|
+
expect(result.current.state).toBe("error");
|
|
97
|
+
|
|
98
|
+
act(() => result.current.reset());
|
|
99
|
+
expect(result.current.state).toBe("idle");
|
|
100
|
+
expect(result.current.error).toBeNull();
|
|
101
|
+
expect(result.current.result).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("useCompletion", () => {
|
|
106
|
+
test("debounces requestCompletion — only the last call within the window fires", async () => {
|
|
107
|
+
let calls = 0;
|
|
108
|
+
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
|
|
109
|
+
calls++;
|
|
110
|
+
return {
|
|
111
|
+
isSuccess: true,
|
|
112
|
+
data: {
|
|
113
|
+
type: "text",
|
|
114
|
+
text: `suggestion for "${(payload as { text: string }).text}"`,
|
|
115
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}) as unknown as Dispatcher["query"]);
|
|
119
|
+
|
|
120
|
+
const { result } = renderHook(() => useCompletion(20), { wrapper: wrapperFor(dispatcher) });
|
|
121
|
+
|
|
122
|
+
act(() => {
|
|
123
|
+
result.current.requestCompletion("a");
|
|
124
|
+
result.current.requestCompletion("ab");
|
|
125
|
+
result.current.requestCompletion("abc");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await waitFor(() => expect(result.current.suggestion).not.toBeNull(), { timeout: 1000 });
|
|
129
|
+
|
|
130
|
+
expect(calls).toBe(1);
|
|
131
|
+
expect(result.current.suggestion).toBe('suggestion for "abc"');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("empty text resets immediately without a request", async () => {
|
|
135
|
+
let calls = 0;
|
|
136
|
+
const dispatcher = makeDispatcher((async () => {
|
|
137
|
+
calls++;
|
|
138
|
+
return {
|
|
139
|
+
isSuccess: true,
|
|
140
|
+
data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
141
|
+
};
|
|
142
|
+
}) as unknown as Dispatcher["query"]);
|
|
143
|
+
|
|
144
|
+
const { result } = renderHook(() => useCompletion(10), { wrapper: wrapperFor(dispatcher) });
|
|
145
|
+
|
|
146
|
+
act(() => {
|
|
147
|
+
result.current.requestCompletion("");
|
|
148
|
+
});
|
|
149
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
150
|
+
|
|
151
|
+
expect(calls).toBe(0);
|
|
152
|
+
expect(result.current.suggestion).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("clear cancels a pending debounce and resets the suggestion", async () => {
|
|
156
|
+
let calls = 0;
|
|
157
|
+
const dispatcher = makeDispatcher((async () => {
|
|
158
|
+
calls++;
|
|
159
|
+
return {
|
|
160
|
+
isSuccess: true,
|
|
161
|
+
data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
162
|
+
};
|
|
163
|
+
}) as unknown as Dispatcher["query"]);
|
|
164
|
+
|
|
165
|
+
const { result } = renderHook(() => useCompletion(30), { wrapper: wrapperFor(dispatcher) });
|
|
166
|
+
|
|
167
|
+
act(() => {
|
|
168
|
+
result.current.requestCompletion("hello");
|
|
169
|
+
});
|
|
170
|
+
act(() => result.current.clear());
|
|
171
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
172
|
+
|
|
173
|
+
expect(calls).toBe(0);
|
|
174
|
+
expect(result.current.suggestion).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useDispatcher } from "../context/dispatcher-context";
|
|
4
|
+
|
|
5
|
+
// AiTextField/AiTextArea's client-side surface. Deliberately duplicates the
|
|
6
|
+
// wire-contract shape from kumiko-enterprise's `ai-text` feature instead of
|
|
7
|
+
// importing it — this package is public NPM, ai-text is a private Enterprise
|
|
8
|
+
// package, and the widgets only ever talk HTTP (ai-text-primitive plan doc,
|
|
9
|
+
// Architecture Decision 1). Keep these in lockstep with
|
|
10
|
+
// kumiko-enterprise/packages/ai-text/src/{modes,feature}.ts by hand; there's
|
|
11
|
+
// no compile-time link between the two repos.
|
|
12
|
+
|
|
13
|
+
export const AI_TEXT_RUN_QN = "ai-text:query:run";
|
|
14
|
+
|
|
15
|
+
export type AiTextMode = "complete" | "correct" | "translate" | "rewrite";
|
|
16
|
+
export type AiTextRewriteStyle = "formal" | "casual" | "concise" | "expand";
|
|
17
|
+
|
|
18
|
+
export type AiTextRunPayload =
|
|
19
|
+
| { readonly mode: "complete"; readonly text: string }
|
|
20
|
+
| { readonly mode: "correct"; readonly text: string }
|
|
21
|
+
| { readonly mode: "translate"; readonly text: string; readonly targetLanguage: string }
|
|
22
|
+
| { readonly mode: "rewrite"; readonly text: string; readonly style?: AiTextRewriteStyle };
|
|
23
|
+
|
|
24
|
+
export type AiTextUsage = { readonly inputTokens: number; readonly outputTokens: number };
|
|
25
|
+
|
|
26
|
+
export type AiTextRunResult =
|
|
27
|
+
| { readonly type: "text"; readonly text: string; readonly usage: AiTextUsage }
|
|
28
|
+
| { readonly type: "error"; readonly reason: string; readonly usage: AiTextUsage };
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// useAiTextAction — one-shot request/response, any mode
|
|
32
|
+
// =============================================================================
|
|
33
|
+
//
|
|
34
|
+
// v1 has no streaming (ai-text-primitive plan doc, sequencing note —
|
|
35
|
+
// SSE-with-auth-reuse needs a framework-core `r.streamHandler` primitive,
|
|
36
|
+
// tracked separately). `complete` goes through this same request/response
|
|
37
|
+
// path as correct/translate/rewrite; `useCompletion` below adds debounce on
|
|
38
|
+
// top for the ghost-text use-case specifically.
|
|
39
|
+
|
|
40
|
+
export type AiTextActionState =
|
|
41
|
+
| "idle"
|
|
42
|
+
| "loading"
|
|
43
|
+
| "success"
|
|
44
|
+
| "error"
|
|
45
|
+
| "cap-exceeded"
|
|
46
|
+
| "unavailable";
|
|
47
|
+
|
|
48
|
+
export type UseAiTextActionResult = {
|
|
49
|
+
readonly run: (payload: AiTextRunPayload) => Promise<AiTextRunResult | null>;
|
|
50
|
+
readonly state: AiTextActionState;
|
|
51
|
+
readonly result: AiTextRunResult | null;
|
|
52
|
+
readonly error: DispatcherError | null;
|
|
53
|
+
readonly reset: () => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function stateForError(error: DispatcherError): AiTextActionState {
|
|
57
|
+
if (error.code === "cap_exceeded" || error.code === "rate_limited") return "cap-exceeded";
|
|
58
|
+
if (error.code === "feature_disabled") return "unavailable";
|
|
59
|
+
return "error";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useAiTextAction(): UseAiTextActionResult {
|
|
63
|
+
const dispatcher = useDispatcher();
|
|
64
|
+
const [state, setState] = useState<AiTextActionState>("idle");
|
|
65
|
+
const [result, setResult] = useState<AiTextRunResult | null>(null);
|
|
66
|
+
const [error, setError] = useState<DispatcherError | null>(null);
|
|
67
|
+
|
|
68
|
+
// Track the in-flight call so a newer `run()` cancels an older one, and
|
|
69
|
+
// unmount doesn't set state after the component is gone — same pattern
|
|
70
|
+
// as useQuery's activeCtrl.
|
|
71
|
+
const activeCtrl = useRef<AbortController | null>(null);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
return () => {
|
|
74
|
+
activeCtrl.current?.abort();
|
|
75
|
+
};
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const run = useCallback(
|
|
79
|
+
async (payload: AiTextRunPayload): Promise<AiTextRunResult | null> => {
|
|
80
|
+
activeCtrl.current?.abort();
|
|
81
|
+
const ctrl = new AbortController();
|
|
82
|
+
activeCtrl.current = ctrl;
|
|
83
|
+
|
|
84
|
+
setState("loading");
|
|
85
|
+
setError(null);
|
|
86
|
+
|
|
87
|
+
const res = await dispatcher.query<AiTextRunResult>(AI_TEXT_RUN_QN, payload, {
|
|
88
|
+
signal: ctrl.signal,
|
|
89
|
+
});
|
|
90
|
+
if (ctrl.signal.aborted) return null;
|
|
91
|
+
|
|
92
|
+
if (!res.isSuccess) {
|
|
93
|
+
if (res.error.code === "aborted") return null;
|
|
94
|
+
setError(res.error);
|
|
95
|
+
setState(stateForError(res.error));
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setResult(res.data);
|
|
100
|
+
setState("success");
|
|
101
|
+
return res.data;
|
|
102
|
+
},
|
|
103
|
+
[dispatcher],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const reset = useCallback(() => {
|
|
107
|
+
activeCtrl.current?.abort();
|
|
108
|
+
setState("idle");
|
|
109
|
+
setResult(null);
|
|
110
|
+
setError(null);
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
return { run, state, result, error, reset };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// useCompletion — debounced ghost-text for AiTextField/AiTextArea
|
|
118
|
+
// =============================================================================
|
|
119
|
+
//
|
|
120
|
+
// Debounce exists to keep the request-rate down against the monthly cap,
|
|
121
|
+
// not for UX polish — every keystroke would otherwise burn a request.
|
|
122
|
+
|
|
123
|
+
export type UseCompletionResult = {
|
|
124
|
+
readonly suggestion: string | null;
|
|
125
|
+
readonly state: AiTextActionState;
|
|
126
|
+
readonly error: DispatcherError | null;
|
|
127
|
+
/** Debounced — schedules a completion request `debounceMs` after the
|
|
128
|
+
* last call. Calling again before the timer fires replaces it. */
|
|
129
|
+
readonly requestCompletion: (text: string) => void;
|
|
130
|
+
/** Cancels any pending/in-flight request and clears the suggestion. */
|
|
131
|
+
readonly clear: () => void;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const DEFAULT_DEBOUNCE_MS = 500;
|
|
135
|
+
|
|
136
|
+
export function useCompletion(debounceMs: number = DEFAULT_DEBOUNCE_MS): UseCompletionResult {
|
|
137
|
+
const { run, state, result, error, reset } = useAiTextAction();
|
|
138
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
139
|
+
|
|
140
|
+
const clearTimer = useCallback(() => {
|
|
141
|
+
if (timerRef.current !== undefined) {
|
|
142
|
+
clearTimeout(timerRef.current);
|
|
143
|
+
timerRef.current = undefined;
|
|
144
|
+
}
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const requestCompletion = useCallback(
|
|
148
|
+
(text: string) => {
|
|
149
|
+
clearTimer();
|
|
150
|
+
if (text.length === 0) {
|
|
151
|
+
reset();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
timerRef.current = setTimeout(() => {
|
|
155
|
+
void run({ mode: "complete", text });
|
|
156
|
+
}, debounceMs);
|
|
157
|
+
},
|
|
158
|
+
[clearTimer, reset, run, debounceMs],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const clear = useCallback(() => {
|
|
162
|
+
clearTimer();
|
|
163
|
+
reset();
|
|
164
|
+
}, [clearTimer, reset]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => clearTimer, [clearTimer]);
|
|
167
|
+
|
|
168
|
+
const suggestion = result?.type === "text" ? result.text : null;
|
|
169
|
+
|
|
170
|
+
return { suggestion, state, error, requestCompletion, clear };
|
|
171
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DispatcherError, WriteResult } from "@cosmicdrift/kumiko-headless";
|
|
2
|
-
import { useCallback, useState } from "react";
|
|
2
|
+
import { useCallback, useRef, useState } from "react";
|
|
3
3
|
import { useDispatcher } from "../context/dispatcher-context";
|
|
4
4
|
|
|
5
5
|
// React wrapper around dispatcher.write — the write-side sibling of
|
|
@@ -24,24 +24,34 @@ export function useMutation<TData = unknown>(type: string): UseMutationResult<TD
|
|
|
24
24
|
const [pending, setPending] = useState(false);
|
|
25
25
|
const [error, setError] = useState<DispatcherError | null>(null);
|
|
26
26
|
const [data, setData] = useState<TData | null>(null);
|
|
27
|
+
// Sequence guard: `mutate` has no abort (unlike useQuery's GETs, a write
|
|
28
|
+
// already landed server-side and can't be cancelled). Two overlapping
|
|
29
|
+
// calls on one instance (e.g. two list-row actions) must not let the
|
|
30
|
+
// first-to-resolve clobber pending/error/data set by the second — only
|
|
31
|
+
// the most recently STARTED call's outcome may update shared state.
|
|
32
|
+
const sequence = useRef(0);
|
|
27
33
|
|
|
28
34
|
const mutate = useCallback(
|
|
29
35
|
async (payload: unknown): Promise<WriteResult<TData>> => {
|
|
36
|
+
const callSeq = ++sequence.current;
|
|
30
37
|
setPending(true);
|
|
31
38
|
setError(null);
|
|
32
39
|
const result = await dispatcher.write<TData>(type, payload);
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
if (callSeq === sequence.current) {
|
|
41
|
+
if (result.isSuccess) {
|
|
42
|
+
setData(result.data);
|
|
43
|
+
} else {
|
|
44
|
+
setError(result.error);
|
|
45
|
+
}
|
|
46
|
+
setPending(false);
|
|
37
47
|
}
|
|
38
|
-
setPending(false);
|
|
39
48
|
return result;
|
|
40
49
|
},
|
|
41
50
|
[dispatcher, type],
|
|
42
51
|
);
|
|
43
52
|
|
|
44
53
|
const reset = useCallback(() => {
|
|
54
|
+
sequence.current++; // invalidate any in-flight mutate's late update
|
|
45
55
|
setPending(false);
|
|
46
56
|
setError(null);
|
|
47
57
|
setData(null);
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -63,6 +63,20 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
63
63
|
"kumiko.dialog.cancel": "Abbrechen",
|
|
64
64
|
"kumiko.dialog.close": "Schließen",
|
|
65
65
|
|
|
66
|
+
// AiTextField/AiTextArea — Ghost-Text-Hint, Toolbar-Aria-Labels, Diff-Dialog.
|
|
67
|
+
"kumiko.aiText.acceptHint": "Tab = übernehmen, Esc = verwerfen",
|
|
68
|
+
"kumiko.aiText.correct": "Korrigieren",
|
|
69
|
+
"kumiko.aiText.translate": "Übersetzen",
|
|
70
|
+
"kumiko.aiText.rewrite": "Umschreiben",
|
|
71
|
+
"kumiko.aiText.diff.before": "Vorher",
|
|
72
|
+
"kumiko.aiText.diff.after": "Nachher",
|
|
73
|
+
"kumiko.aiText.diff.generating": "Wird generiert…",
|
|
74
|
+
"kumiko.aiText.style.formal": "Formell",
|
|
75
|
+
"kumiko.aiText.style.casual": "Locker",
|
|
76
|
+
"kumiko.aiText.style.concise": "Kompakt",
|
|
77
|
+
"kumiko.aiText.style.expand": "Ausführlicher",
|
|
78
|
+
"kumiko.aiText.capExceeded": "Monatliches AI-Limit erreicht.",
|
|
79
|
+
|
|
66
80
|
// Row-Actions — Fehler-Toast wenn ein Action-Write fehlschlägt.
|
|
67
81
|
"kumiko.rowAction.failed": "Aktion fehlgeschlagen",
|
|
68
82
|
|
|
@@ -128,7 +142,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
128
142
|
"errors.unconfigured": "Diese Funktion ist noch nicht konfiguriert.",
|
|
129
143
|
"errors.internal": "Etwas ist schiefgegangen. Bitte versuche es später erneut.",
|
|
130
144
|
"errors.rate_limited": "Zu viele Anfragen. Bitte versuche es in Kürze erneut.",
|
|
145
|
+
"errors.cap.exceeded":
|
|
146
|
+
"Limit erreicht. Bitte Tarif upgraden oder auf die nächste Periode warten.",
|
|
131
147
|
"errors.download.urlMissing": "Download nicht verfügbar — bitte versuche es erneut.",
|
|
148
|
+
"auth.errors.originNotAllowed": "Zugriff von dieser Herkunft ist nicht erlaubt.",
|
|
132
149
|
},
|
|
133
150
|
en: {
|
|
134
151
|
"kumiko.actions.save": "Save",
|
|
@@ -171,6 +188,19 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
171
188
|
"kumiko.dialog.cancel": "Cancel",
|
|
172
189
|
"kumiko.dialog.close": "Close",
|
|
173
190
|
|
|
191
|
+
"kumiko.aiText.acceptHint": "Tab = accept, Esc = discard",
|
|
192
|
+
"kumiko.aiText.correct": "Correct",
|
|
193
|
+
"kumiko.aiText.translate": "Translate",
|
|
194
|
+
"kumiko.aiText.rewrite": "Rewrite",
|
|
195
|
+
"kumiko.aiText.diff.before": "Before",
|
|
196
|
+
"kumiko.aiText.diff.after": "After",
|
|
197
|
+
"kumiko.aiText.diff.generating": "Generating…",
|
|
198
|
+
"kumiko.aiText.style.formal": "Formal",
|
|
199
|
+
"kumiko.aiText.style.casual": "Casual",
|
|
200
|
+
"kumiko.aiText.style.concise": "Concise",
|
|
201
|
+
"kumiko.aiText.style.expand": "Expand",
|
|
202
|
+
"kumiko.aiText.capExceeded": "Monthly AI limit reached.",
|
|
203
|
+
|
|
174
204
|
"kumiko.rowAction.failed": "Action failed",
|
|
175
205
|
|
|
176
206
|
"kumiko.config.source.user": "My value",
|
|
@@ -221,6 +251,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
221
251
|
"errors.unconfigured": "This feature isn't configured yet.",
|
|
222
252
|
"errors.internal": "Something went wrong. Please try again later.",
|
|
223
253
|
"errors.rate_limited": "Too many requests. Please try again shortly.",
|
|
254
|
+
"errors.cap.exceeded": "Limit reached. Upgrade your plan or wait for the next period.",
|
|
224
255
|
"errors.download.urlMissing": "Download unavailable — please try again.",
|
|
256
|
+
"auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
|
|
225
257
|
},
|
|
226
258
|
};
|
package/src/i18n.tsx
CHANGED
|
@@ -40,9 +40,8 @@ export function translationsByLocaleFromKeys(source: TranslationsByKey): Transla
|
|
|
40
40
|
const out: Record<string, Record<string, string>> = {};
|
|
41
41
|
for (const [key, byLocale] of Object.entries(source)) {
|
|
42
42
|
for (const [locale, value] of Object.entries(byLocale)) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
out[locale] = bucket;
|
|
43
|
+
out[locale] ??= {};
|
|
44
|
+
out[locale][key] = value;
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
return out;
|
package/src/index.ts
CHANGED
|
@@ -59,11 +59,23 @@ export {
|
|
|
59
59
|
useDispatcherStatus,
|
|
60
60
|
useOptionalDispatcher,
|
|
61
61
|
} from "./context/dispatcher-context";
|
|
62
|
+
export { formatWhen } from "./format-when";
|
|
62
63
|
export {
|
|
63
64
|
REFERENCE_COMBOBOX_LIMIT,
|
|
64
65
|
REFERENCE_LIST_LOOKUP_LIMIT,
|
|
65
66
|
REFERENCE_SEARCH_DEBOUNCE_MS,
|
|
66
67
|
} from "./hooks/reference-limits";
|
|
68
|
+
export type {
|
|
69
|
+
AiTextActionState,
|
|
70
|
+
AiTextMode,
|
|
71
|
+
AiTextRewriteStyle,
|
|
72
|
+
AiTextRunPayload,
|
|
73
|
+
AiTextRunResult,
|
|
74
|
+
AiTextUsage,
|
|
75
|
+
UseAiTextActionResult,
|
|
76
|
+
UseCompletionResult,
|
|
77
|
+
} from "./hooks/use-ai-text";
|
|
78
|
+
export { AI_TEXT_RUN_QN, useAiTextAction, useCompletion } from "./hooks/use-ai-text";
|
|
67
79
|
export type { UseDisclosureResult } from "./hooks/use-disclosure";
|
|
68
80
|
export { useDisclosure } from "./hooks/use-disclosure";
|
|
69
81
|
export type { UseFormOptions, UseFormResult } from "./hooks/use-form";
|
|
@@ -125,6 +137,7 @@ export type {
|
|
|
125
137
|
TextProps,
|
|
126
138
|
} from "./primitives";
|
|
127
139
|
export { PrimitivesProvider, usePrimitives } from "./primitives";
|
|
140
|
+
export { sortByAccessor } from "./sort-by-accessor";
|
|
128
141
|
export type { LiveEvent, LiveEventSubscriber, LiveEventsProviderProps } from "./sse/live-events";
|
|
129
142
|
export { LiveEventsProvider, useLiveEvents } from "./sse/live-events";
|
|
130
143
|
export type {
|
package/src/primitives.tsx
CHANGED
|
@@ -560,6 +560,10 @@ export type SectionProps = {
|
|
|
560
560
|
* übernehmen"). Web rendert standalone eine abgehobene Footer-Row
|
|
561
561
|
* (border-t), innerhalb eines Forms eine rechtsbündige Button-Reihe. */
|
|
562
562
|
readonly actions?: ReactNode;
|
|
563
|
+
/** "destructive" marks the Section as a warning/danger area (e.g. account
|
|
564
|
+
* deletion, restrict processing) — border color only, no content change.
|
|
565
|
+
* Default "default" (normal card border). */
|
|
566
|
+
readonly variant?: "default" | "destructive";
|
|
563
567
|
readonly testId?: string;
|
|
564
568
|
};
|
|
565
569
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { DataTableSort } from "./primitives";
|
|
2
|
+
|
|
3
|
+
/** Sorts `rows` by a `DataTableSort` against a field->accessor map. Unknown
|
|
4
|
+
* field or `sort === null` returns `rows` unchanged (no-op, not an error —
|
|
5
|
+
* callers pass whatever the DataTable reports). */
|
|
6
|
+
export function sortByAccessor<TRow>(
|
|
7
|
+
rows: readonly TRow[],
|
|
8
|
+
sort: DataTableSort | null,
|
|
9
|
+
accessors: Readonly<Record<string, (row: TRow) => string | number>>,
|
|
10
|
+
): readonly TRow[] {
|
|
11
|
+
if (sort === null) return rows;
|
|
12
|
+
const accessor = accessors[sort.field];
|
|
13
|
+
if (accessor === undefined) return rows;
|
|
14
|
+
const factor = sort.dir === "asc" ? 1 : -1;
|
|
15
|
+
return [...rows].sort((a, b) => {
|
|
16
|
+
const av = accessor(a);
|
|
17
|
+
const bv = accessor(b);
|
|
18
|
+
return av < bv ? -factor : av > bv ? factor : 0;
|
|
19
|
+
});
|
|
20
|
+
}
|