@cosmicdrift/kumiko-renderer 1.0.0 → 2.0.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.
Files changed (36) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/error-i18n-defaults.test.ts +13 -0
  3. package/src/__tests__/format-when.test.ts +12 -0
  4. package/src/__tests__/i18n.test.tsx +59 -0
  5. package/src/__tests__/qn.test.ts +44 -1
  6. package/src/__tests__/sort-by-accessor.test.ts +48 -0
  7. package/src/app/__tests__/config-edit-shim.test.ts +40 -0
  8. package/src/app/__tests__/screen-access-allows.test.ts +24 -0
  9. package/src/app/dashboard-body.tsx +32 -0
  10. package/src/app/extension-sections.tsx +11 -4
  11. package/src/app/kumiko-screen.tsx +408 -20
  12. package/src/app/projection-detail-shim.ts +72 -0
  13. package/src/app/projection-list-shim.ts +62 -0
  14. package/src/app/qn.ts +13 -0
  15. package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
  16. package/src/components/render-edit-logic.ts +8 -5
  17. package/src/components/render-edit.tsx +25 -2
  18. package/src/components/render-field.tsx +2 -1
  19. package/src/components/render-list.tsx +24 -2
  20. package/src/context/user-roles-context.tsx +27 -0
  21. package/src/format-when.ts +11 -0
  22. package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
  23. package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
  24. package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
  25. package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
  26. package/src/hooks/use-ai-text.ts +172 -0
  27. package/src/hooks/use-disclosure.ts +20 -0
  28. package/src/hooks/use-mutation.ts +61 -0
  29. package/src/hooks/use-query.ts +5 -2
  30. package/src/hooks/use-reference-lookup.ts +2 -1
  31. package/src/hooks/use-stream-handler.ts +134 -0
  32. package/src/i18n-defaults.ts +56 -0
  33. package/src/i18n.tsx +72 -31
  34. package/src/index.ts +31 -0
  35. package/src/primitives.tsx +70 -4
  36. package/src/sort-by-accessor.ts +20 -0
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatcher, DispatcherError } 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 { useStreamHandler } from "../use-stream-handler";
7
+
8
+ function makeDispatcher(streamImpl: unknown): Dispatcher {
9
+ return {
10
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
11
+ query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
12
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
13
+ stream: streamImpl as Dispatcher["stream"],
14
+ statusStore: {
15
+ getState: () => "online",
16
+ subscribe: () => () => {},
17
+ } as unknown as Dispatcher["statusStore"],
18
+ pendingWrites: () => [],
19
+ pendingFiles: () => [],
20
+ };
21
+ }
22
+
23
+ function wrapperFor(dispatcher: Dispatcher) {
24
+ return ({ children }: { readonly children: ReactNode }) => (
25
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
26
+ );
27
+ }
28
+
29
+ describe("useStreamHandler", () => {
30
+ test("start accumulates chunks then status=done", async () => {
31
+ const dispatcher = makeDispatcher(async function* () {
32
+ yield { i: 0 };
33
+ yield { i: 1 };
34
+ });
35
+
36
+ const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
37
+ wrapper: wrapperFor(dispatcher),
38
+ });
39
+
40
+ await act(async () => {
41
+ await result.current.start({ count: 2 });
42
+ });
43
+
44
+ await waitFor(() => expect(result.current.status).toBe("done"));
45
+ expect(result.current.chunks).toEqual([{ i: 0 }, { i: 1 }]);
46
+ expect(result.current.error).toBeNull();
47
+ });
48
+
49
+ test("stream error sets status=error and error envelope", async () => {
50
+ const err: DispatcherError = {
51
+ code: "access_denied",
52
+ httpStatus: 403,
53
+ i18nKey: "errors.access",
54
+ message: "denied",
55
+ };
56
+ const dispatcher = makeDispatcher(async function* () {
57
+ yield* []; // satisfy generator shape; error is the only outcome
58
+ throw err;
59
+ });
60
+
61
+ const { result } = renderHook(() => useStreamHandler("f:stream:x:tail"), {
62
+ wrapper: wrapperFor(dispatcher),
63
+ });
64
+
65
+ await act(async () => {
66
+ await result.current.start();
67
+ });
68
+
69
+ await waitFor(() => expect(result.current.status).toBe("error"));
70
+ expect(result.current.error?.code).toBe("access_denied");
71
+ });
72
+
73
+ test("abort during stream resets status to idle (no late done)", async () => {
74
+ let release!: () => void;
75
+ const gate = new Promise<void>((r) => {
76
+ release = r;
77
+ });
78
+ const dispatcher = makeDispatcher(async function* (
79
+ _t: string,
80
+ _p: unknown,
81
+ opts?: { signal?: AbortSignal },
82
+ ) {
83
+ yield { i: 0 };
84
+ await gate;
85
+ if (opts?.signal?.aborted) {
86
+ const e = { code: "aborted", httpStatus: 0, i18nKey: "x", message: "aborted" };
87
+ throw e;
88
+ }
89
+ yield { i: 1 };
90
+ });
91
+
92
+ const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
93
+ wrapper: wrapperFor(dispatcher),
94
+ });
95
+
96
+ let started!: Promise<void>;
97
+ act(() => {
98
+ started = result.current.start();
99
+ });
100
+ await waitFor(() => expect(result.current.chunks).toEqual([{ i: 0 }]));
101
+ act(() => result.current.abort());
102
+ release();
103
+ await started;
104
+ expect(result.current.chunks).toEqual([{ i: 0 }]);
105
+ expect(result.current.status).toBe("idle");
106
+ });
107
+ });
@@ -0,0 +1,172 @@
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
+ // skip: empty text, state already reset above
153
+ return;
154
+ }
155
+ timerRef.current = setTimeout(() => {
156
+ void run({ mode: "complete", text });
157
+ }, debounceMs);
158
+ },
159
+ [clearTimer, reset, run, debounceMs],
160
+ );
161
+
162
+ const clear = useCallback(() => {
163
+ clearTimer();
164
+ reset();
165
+ }, [clearTimer, reset]);
166
+
167
+ useEffect(() => clearTimer, [clearTimer]);
168
+
169
+ const suggestion = result?.type === "text" ? result.text : null;
170
+
171
+ return { suggestion, state, error, requestCompletion, clear };
172
+ }
@@ -0,0 +1,20 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ // Open/close state for dialogs, sheets and collapsibles — the standard
4
+ // replacement for the hand-rolled `useState(false)` + toggle-callback
5
+ // trio in app screens. All callbacks are referentially stable.
6
+
7
+ export type UseDisclosureResult = {
8
+ readonly open: boolean;
9
+ readonly onOpen: () => void;
10
+ readonly onClose: () => void;
11
+ readonly onToggle: () => void;
12
+ };
13
+
14
+ export function useDisclosure(initialOpen = false): UseDisclosureResult {
15
+ const [open, setOpen] = useState(initialOpen);
16
+ const onOpen = useCallback(() => setOpen(true), []);
17
+ const onClose = useCallback(() => setOpen(false), []);
18
+ const onToggle = useCallback(() => setOpen((prev) => !prev), []);
19
+ return { open, onOpen, onClose, onToggle };
20
+ }
@@ -0,0 +1,61 @@
1
+ import type { DispatcherError, WriteResult } from "@cosmicdrift/kumiko-headless";
2
+ import { useCallback, useRef, useState } from "react";
3
+ import { useDispatcher } from "../context/dispatcher-context";
4
+
5
+ // React wrapper around dispatcher.write — the write-side sibling of
6
+ // useQuery. One hook instance per handler-type; `mutate` carries the
7
+ // payload so a single instance serves list-row actions with varying
8
+ // payloads.
9
+ //
10
+ // `mutate` resolves with the raw WriteResult so callers can branch
11
+ // (navigate on success, keep the form open on failure) without waiting
12
+ // for a re-render of `error`/`data`.
13
+
14
+ export type UseMutationResult<TData> = {
15
+ readonly mutate: (payload: unknown) => Promise<WriteResult<TData>>;
16
+ readonly pending: boolean;
17
+ readonly error: DispatcherError | null;
18
+ readonly data: TData | null;
19
+ readonly reset: () => void;
20
+ };
21
+
22
+ export function useMutation<TData = unknown>(type: string): UseMutationResult<TData> {
23
+ const dispatcher = useDispatcher();
24
+ const [pending, setPending] = useState(false);
25
+ const [error, setError] = useState<DispatcherError | null>(null);
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);
33
+
34
+ const mutate = useCallback(
35
+ async (payload: unknown): Promise<WriteResult<TData>> => {
36
+ const callSeq = ++sequence.current;
37
+ setPending(true);
38
+ setError(null);
39
+ const result = await dispatcher.write<TData>(type, payload);
40
+ if (callSeq === sequence.current) {
41
+ if (result.isSuccess) {
42
+ setData(result.data);
43
+ } else {
44
+ setError(result.error);
45
+ }
46
+ setPending(false);
47
+ }
48
+ return result;
49
+ },
50
+ [dispatcher, type],
51
+ );
52
+
53
+ const reset = useCallback(() => {
54
+ sequence.current++; // invalidate any in-flight mutate's late update
55
+ setPending(false);
56
+ setError(null);
57
+ setData(null);
58
+ }, []);
59
+
60
+ return { mutate, pending, error, data, reset };
61
+ }
@@ -87,14 +87,14 @@ export function useQuery<TData = unknown>(
87
87
 
88
88
  setLoading(true);
89
89
  const result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
90
- // Don't update state if a newer fetch has already taken over.
90
+ // skip: a newer fetch already superseded this one, don't clobber its state
91
91
  if (ctrl.signal.aborted) return;
92
92
  if (result.isSuccess) {
93
93
  setData(result.data);
94
94
  setError(null);
95
95
  } else {
96
96
  // A cancelled request comes back with code "aborted" from the
97
- // dispatcher — skip the state update, another run replaces it.
97
+ // skip: aborted request, a newer run already replaces this result
98
98
  if (result.error.code === "aborted") return;
99
99
  setError(result.error);
100
100
  }
@@ -104,6 +104,7 @@ export function useQuery<TData = unknown>(
104
104
  useEffect(() => {
105
105
  if (!enabled) {
106
106
  setLoading(false);
107
+ // skip: query disabled, loading state already cleared above
107
108
  return;
108
109
  }
109
110
  void run();
@@ -117,8 +118,10 @@ export function useQuery<TData = unknown>(
117
118
  // Subscription-Lifecycle genau einmal durchwalzt.
118
119
  const subscribeLive = useLiveEvents();
119
120
  useEffect(() => {
121
+ // skip: live mode or query disabled, no SSE subscription needed
120
122
  if (!live || !enabled) return;
121
123
  const entity = entityFromQueryType(type);
124
+ // skip: query type has no mapped entity, nothing to subscribe to
122
125
  if (entity === undefined) return;
123
126
  return subscribeLive(entity, () => {
124
127
  void run();
@@ -18,6 +18,7 @@
18
18
  // Entity, Live-Updates kommen via SSE (use-query-live).
19
19
 
20
20
  import { useMemo } from "react";
21
+ import { toKebab } from "../app/qn";
21
22
  import { REFERENCE_LIST_LOOKUP_LIMIT } from "./reference-limits";
22
23
  import { useQuery } from "./use-query";
23
24
 
@@ -35,7 +36,7 @@ export function useReferenceLookup(
35
36
  refEntity: string,
36
37
  labelField: string,
37
38
  ): { readonly map: ReferenceLookupMap; readonly loading: boolean } {
38
- const queryQn = `${featureName}:query:${refEntity}:list`;
39
+ const queryQn = `${toKebab(featureName)}:query:${toKebab(refEntity)}:list`;
39
40
  const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
40
41
  limit: REFERENCE_LIST_LOOKUP_LIMIT,
41
42
  });
@@ -0,0 +1,134 @@
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
+ // React wrapper around dispatcher.stream (#1382). Accumulates yielded
6
+ // chunks into `chunks` (unlike useQuery({live:true}), which only
7
+ // invalidates and re-fetches). Each start() owns an AbortController;
8
+ // a newer start() / unmount aborts the previous run so late chunks
9
+ // cannot clobber fresher state.
10
+
11
+ export type StreamStatus = "idle" | "streaming" | "done" | "error";
12
+
13
+ export type UseStreamHandlerResult<TChunk> = {
14
+ readonly chunks: readonly TChunk[];
15
+ readonly status: StreamStatus;
16
+ readonly error: DispatcherError | null;
17
+ readonly start: (payload?: unknown) => Promise<void>;
18
+ readonly abort: () => void;
19
+ readonly reset: () => void;
20
+ };
21
+
22
+ export type UseStreamHandlerOptions = {
23
+ // When true, start() runs once on mount with the initial payload.
24
+ // Default false — streams are usually user-triggered (unlike queries).
25
+ readonly autoStart?: boolean;
26
+ };
27
+
28
+ export function useStreamHandler<TChunk = unknown>(
29
+ type: string,
30
+ payload: unknown = {},
31
+ options: UseStreamHandlerOptions = {},
32
+ ): UseStreamHandlerResult<TChunk> {
33
+ const dispatcher = useDispatcher();
34
+ const { autoStart = false } = options;
35
+
36
+ const [chunks, setChunks] = useState<readonly TChunk[]>([]);
37
+ const [status, setStatus] = useState<StreamStatus>("idle");
38
+ const [error, setError] = useState<DispatcherError | null>(null);
39
+
40
+ const activeCtrl = useRef<AbortController | null>(null);
41
+ const payloadRef = useRef(payload);
42
+ payloadRef.current = payload;
43
+
44
+ const abort = useCallback((): void => {
45
+ activeCtrl.current?.abort();
46
+ activeCtrl.current = null;
47
+ // User cancel — exit streaming so UI can re-enable the start button.
48
+ setStatus((s) => (s === "streaming" ? "idle" : s));
49
+ }, []);
50
+
51
+ const reset = useCallback((): void => {
52
+ abort();
53
+ setChunks([]);
54
+ setStatus("idle");
55
+ setError(null);
56
+ }, [abort]);
57
+
58
+ // Shared by every abort-detection branch below: only touch status if this
59
+ // run is still the active one (or activeCtrl was already cleared by abort()).
60
+ const settleAborted = useCallback((ctrl: AbortController): void => {
61
+ if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
62
+ }, []);
63
+
64
+ const start = useCallback(
65
+ async (overridePayload?: unknown): Promise<void> => {
66
+ activeCtrl.current?.abort();
67
+ const ctrl = new AbortController();
68
+ activeCtrl.current = ctrl;
69
+
70
+ setChunks([]);
71
+ setError(null);
72
+ setStatus("streaming");
73
+
74
+ const body = overridePayload !== undefined ? overridePayload : payloadRef.current;
75
+ try {
76
+ for await (const chunk of dispatcher.stream<TChunk>(type, body, { signal: ctrl.signal })) {
77
+ // skip: a newer start() already superseded this run
78
+ if (ctrl.signal.aborted) {
79
+ settleAborted(ctrl);
80
+ return;
81
+ }
82
+ setChunks((prev) => [...prev, chunk]);
83
+ }
84
+ // skip: aborted after last chunk, don't mark done
85
+ if (ctrl.signal.aborted) {
86
+ settleAborted(ctrl);
87
+ return;
88
+ }
89
+ setStatus("done");
90
+ } catch (e) {
91
+ // skip: abort is not an error toast
92
+ if (ctrl.signal.aborted) {
93
+ settleAborted(ctrl);
94
+ return;
95
+ }
96
+ const mapped = asDispatcherError(e);
97
+ // skip: dispatcher-mapped abort (fetch cancelled)
98
+ if (mapped.code === "aborted") {
99
+ settleAborted(ctrl);
100
+ return;
101
+ }
102
+ setError(mapped);
103
+ setStatus("error");
104
+ }
105
+ },
106
+ [dispatcher, type, settleAborted],
107
+ );
108
+
109
+ useEffect(() => {
110
+ // skip: autoStart off — streams are usually user-triggered
111
+ if (!autoStart) return;
112
+ void start();
113
+ }, [autoStart, start]);
114
+
115
+ useEffect(() => {
116
+ return () => {
117
+ activeCtrl.current?.abort();
118
+ };
119
+ }, []);
120
+
121
+ return { chunks, status, error, start, abort, reset };
122
+ }
123
+
124
+ function asDispatcherError(e: unknown): DispatcherError {
125
+ if (e && typeof e === "object" && "code" in e && "message" in e && "i18nKey" in e) {
126
+ return e as DispatcherError;
127
+ }
128
+ return {
129
+ code: "stream_error",
130
+ httpStatus: 0,
131
+ i18nKey: "errors.unknown",
132
+ message: e instanceof Error ? e.message : String(e),
133
+ };
134
+ }
@@ -20,6 +20,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
20
20
  "kumiko.actions.reload": "Neu laden",
21
21
  "kumiko.actions.create": "Neu",
22
22
  "kumiko.actions.edit": "Bearbeiten",
23
+ "kumiko.actions.copyLink": "Link kopieren",
24
+ "kumiko.actions.copyLinkCopied": "Kopiert!",
23
25
 
24
26
  // Version — Update-Awareness-Banner (UpdateChecker).
25
27
  "kumiko.version.update-available": "Eine neue Version ist verfügbar.",
@@ -43,17 +45,40 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
43
45
  "kumiko.combobox.search-placeholder": "Suchen…",
44
46
  "kumiko.combobox.empty": "Keine Treffer.",
45
47
  "kumiko.combobox.loading": "Lade…",
48
+
49
+ // Dashboard — Default-Label für den "(alle)"-Eintrag im Screen-Filter,
50
+ // wenn DashboardFilterDefinition.allLabel nicht gesetzt ist.
51
+ "kumiko.dashboard.filter.all": "Alle",
46
52
  "kumiko.combobox.placeholder": "—",
47
53
 
54
+ // Widgets — Query-States (QueryTable, LoadingState, ErrorState).
55
+ "kumiko.widget.loading": "Lade…",
56
+ "kumiko.widget.error.title": "Konnte nicht geladen werden.",
57
+
48
58
  // Nav — Sidebar Tree (Toggle-aria-Labels).
49
59
  "kumiko.nav.expand": "Aufklappen",
50
60
  "kumiko.nav.collapse": "Zuklappen",
61
+ "kumiko.nav.search": "Navigation durchsuchen…",
51
62
 
52
63
  // Dialog — Confirm-Buttons + Close-aria-Label.
53
64
  "kumiko.dialog.confirm": "Bestätigen",
54
65
  "kumiko.dialog.cancel": "Abbrechen",
55
66
  "kumiko.dialog.close": "Schließen",
56
67
 
68
+ // AiTextField/AiTextArea — Ghost-Text-Hint, Toolbar-Aria-Labels, Diff-Dialog.
69
+ "kumiko.aiText.acceptHint": "Tab = übernehmen, Esc = verwerfen",
70
+ "kumiko.aiText.correct": "Korrigieren",
71
+ "kumiko.aiText.translate": "Übersetzen",
72
+ "kumiko.aiText.rewrite": "Umschreiben",
73
+ "kumiko.aiText.diff.before": "Vorher",
74
+ "kumiko.aiText.diff.after": "Nachher",
75
+ "kumiko.aiText.diff.generating": "Wird generiert…",
76
+ "kumiko.aiText.style.formal": "Formell",
77
+ "kumiko.aiText.style.casual": "Locker",
78
+ "kumiko.aiText.style.concise": "Kompakt",
79
+ "kumiko.aiText.style.expand": "Ausführlicher",
80
+ "kumiko.aiText.capExceeded": "Monatliches AI-Limit erreicht.",
81
+
57
82
  // Row-Actions — Fehler-Toast wenn ein Action-Write fehlschlägt.
58
83
  "kumiko.rowAction.failed": "Aktion fehlgeschlagen",
59
84
 
@@ -119,7 +144,13 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
119
144
  "errors.unconfigured": "Diese Funktion ist noch nicht konfiguriert.",
120
145
  "errors.internal": "Etwas ist schiefgegangen. Bitte versuche es später erneut.",
121
146
  "errors.rate_limited": "Zu viele Anfragen. Bitte versuche es in Kürze erneut.",
147
+ "errors.cap.exceeded":
148
+ "Limit erreicht. Bitte Tarif upgraden oder auf die nächste Periode warten.",
122
149
  "errors.download.urlMissing": "Download nicht verfügbar — bitte versuche es erneut.",
150
+ "auth.errors.originNotAllowed": "Zugriff von dieser Herkunft ist nicht erlaubt.",
151
+ "dispatcher.errors.network":
152
+ "Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.",
153
+ "dispatcher.errors.aborted": "Anfrage abgebrochen.",
123
154
  },
124
155
  en: {
125
156
  "kumiko.actions.save": "Save",
@@ -129,6 +160,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
129
160
  "kumiko.actions.reload": "Reload",
130
161
  "kumiko.actions.create": "New",
131
162
  "kumiko.actions.edit": "Edit",
163
+ "kumiko.actions.copyLink": "Copy link",
164
+ "kumiko.actions.copyLinkCopied": "Copied!",
132
165
 
133
166
  "kumiko.version.update-available": "A new version is available.",
134
167
 
@@ -149,13 +182,32 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
149
182
  "kumiko.combobox.loading": "Loading…",
150
183
  "kumiko.combobox.placeholder": "—",
151
184
 
185
+ "kumiko.dashboard.filter.all": "All",
186
+
187
+ "kumiko.widget.loading": "Loading…",
188
+ "kumiko.widget.error.title": "Couldn't load.",
189
+
152
190
  "kumiko.nav.expand": "Expand",
153
191
  "kumiko.nav.collapse": "Collapse",
192
+ "kumiko.nav.search": "Search navigation…",
154
193
 
155
194
  "kumiko.dialog.confirm": "Confirm",
156
195
  "kumiko.dialog.cancel": "Cancel",
157
196
  "kumiko.dialog.close": "Close",
158
197
 
198
+ "kumiko.aiText.acceptHint": "Tab = accept, Esc = discard",
199
+ "kumiko.aiText.correct": "Correct",
200
+ "kumiko.aiText.translate": "Translate",
201
+ "kumiko.aiText.rewrite": "Rewrite",
202
+ "kumiko.aiText.diff.before": "Before",
203
+ "kumiko.aiText.diff.after": "After",
204
+ "kumiko.aiText.diff.generating": "Generating…",
205
+ "kumiko.aiText.style.formal": "Formal",
206
+ "kumiko.aiText.style.casual": "Casual",
207
+ "kumiko.aiText.style.concise": "Concise",
208
+ "kumiko.aiText.style.expand": "Expand",
209
+ "kumiko.aiText.capExceeded": "Monthly AI limit reached.",
210
+
159
211
  "kumiko.rowAction.failed": "Action failed",
160
212
 
161
213
  "kumiko.config.source.user": "My value",
@@ -206,6 +258,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
206
258
  "errors.unconfigured": "This feature isn't configured yet.",
207
259
  "errors.internal": "Something went wrong. Please try again later.",
208
260
  "errors.rate_limited": "Too many requests. Please try again shortly.",
261
+ "errors.cap.exceeded": "Limit reached. Upgrade your plan or wait for the next period.",
209
262
  "errors.download.urlMissing": "Download unavailable — please try again.",
263
+ "auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
264
+ "dispatcher.errors.network": "Network error. Please check your connection and try again.",
265
+ "dispatcher.errors.aborted": "Request was cancelled.",
210
266
  },
211
267
  };