@djangocfg/ui-tools 2.1.409 → 2.1.412

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 (83) hide show
  1. package/package.json +13 -13
  2. package/src/{tools/Chat/highlight → lib/browser-bridge}/README.md +46 -18
  3. package/src/lib/browser-bridge/commands/chat.ts +42 -0
  4. package/src/lib/browser-bridge/commands/highlight.ts +70 -0
  5. package/src/lib/browser-bridge/commands/index.ts +15 -0
  6. package/src/lib/browser-bridge/commands/inspect.ts +31 -0
  7. package/src/lib/browser-bridge/commands/scroll.ts +31 -0
  8. package/src/lib/browser-bridge/commands/write.ts +45 -0
  9. package/src/lib/browser-bridge/directive-bus.ts +120 -0
  10. package/src/lib/browser-bridge/index.ts +56 -0
  11. package/src/lib/browser-bridge/logger.ts +27 -0
  12. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/HighlightOverlay.tsx +14 -0
  13. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx +52 -0
  14. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts +39 -0
  15. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/index.ts +8 -5
  16. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/resolveRef.ts +5 -0
  17. package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/useHighlightTargets.ts +58 -27
  18. package/src/lib/browser-bridge/overlay/waitForVisible.ts +70 -0
  19. package/src/lib/browser-bridge/registry.ts +41 -0
  20. package/src/lib/browser-bridge/setBridgeResolver.ts +42 -0
  21. package/src/lib/browser-bridge/window.ts +76 -0
  22. package/src/lib/page-snapshot/capture/walk.ts +13 -5
  23. package/src/lib/page-snapshot/engine.ts +9 -4
  24. package/src/lib/page-snapshot/index.ts +5 -0
  25. package/src/lib/page-snapshot/react/provider.tsx +70 -3
  26. package/src/lib/page-snapshot/react/use-page-snapshot.ts +10 -0
  27. package/src/lib/page-snapshot/refs/__tests__/locator.test.ts +94 -0
  28. package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +59 -3
  29. package/src/lib/page-snapshot/refs/locator.ts +218 -0
  30. package/src/lib/page-snapshot/refs/registry.ts +29 -14
  31. package/src/tools/Chat/README.md +1 -1
  32. package/src/tools/Chat/composer/AttachContext.tsx +22 -0
  33. package/src/tools/Chat/composer/Composer.tsx +108 -6
  34. package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
  35. package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
  36. package/src/tools/Chat/composer/index.ts +16 -1
  37. package/src/tools/Chat/composer/types.ts +71 -0
  38. package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
  39. package/src/tools/Chat/constants.ts +24 -1
  40. package/src/tools/Chat/context/ChatProvider.tsx +17 -2
  41. package/src/tools/Chat/core/logger.ts +15 -2
  42. package/src/tools/Chat/hooks/useChat.ts +32 -0
  43. package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
  44. package/src/tools/Chat/index.ts +34 -2
  45. package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
  46. package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
  47. package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
  48. package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
  49. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
  50. package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
  51. package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
  52. package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
  53. package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
  54. package/src/tools/Chat/lazy.tsx +34 -2
  55. package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
  56. package/src/tools/Chat/public.ts +17 -0
  57. package/src/tools/Chat/settings/README.md +87 -0
  58. package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
  59. package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
  60. package/src/tools/Chat/settings/index.ts +23 -0
  61. package/src/tools/Chat/settings/types.ts +108 -0
  62. package/src/tools/Chat/settings/useChatSettings.ts +168 -0
  63. package/src/tools/Chat/types/events.ts +50 -0
  64. package/src/tools/Chat/types/index.ts +1 -1
  65. package/src/tools/Chat/types/message.ts +5 -0
  66. package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
  67. package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
  68. package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
  69. package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
  70. package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
  71. package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
  72. package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
  73. package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
  74. package/src/tools/CronScheduler/context/hooks.ts +8 -0
  75. package/src/tools/CronScheduler/context/index.ts +1 -0
  76. package/src/tools/CronScheduler/index.tsx +2 -0
  77. package/src/tools/CronScheduler/lazy.tsx +1 -0
  78. package/src/tools/CronScheduler/types/index.ts +18 -1
  79. package/src/tools/Map/lazy.tsx +11 -4
  80. package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
  81. package/src/tools/index.ts +2 -0
  82. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
  83. /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
@@ -0,0 +1,87 @@
1
+ # Chat settings — `tools/Chat/settings/`
2
+
3
+ The single, centralized home for every **chat-owned persisted setting**.
4
+
5
+ ## Why this exists
6
+
7
+ Chat features used to each call `useLocalStorage` (or zustand `persist`)
8
+ with their own ad-hoc storage key. That made it impossible to reason
9
+ about, export, or reset "the chat's settings" as one thing, and
10
+ scattered the SSR / migration / coalescing concerns across the codebase.
11
+
12
+ This module gives chat **one typed object, one storage key, one hook**:
13
+
14
+ - `ChatSettings` — the typed shape of all chat-owned settings.
15
+ - `useChatSettings()` — the only hook a chat feature should use to read or
16
+ write a persisted preference. Built on the improved `useLocalStorage`
17
+ from `@djangocfg/ui-core`, which gives us:
18
+ - **Coalesced writes** — several features patching different slices in
19
+ the same render commit collapse into one `localStorage` write.
20
+ - **Cross-instance / cross-tab sync** — two `useChatSettings()`
21
+ consumers stay in lockstep, same tab and across tabs.
22
+ - **Safe partial updates** — a slice updater that changes nothing is a
23
+ no-op (no write, no re-render).
24
+
25
+ ## API
26
+
27
+ ```ts
28
+ const {
29
+ settings, // full typed ChatSettings object
30
+ patch, // grouped patch over whole slices
31
+ updateDock, // updateDock({ side: 'left' })
32
+ updateAudio,
33
+ updateSpeech,
34
+ updatePageContext,
35
+ setAudioMuted, // shortcut
36
+ setPageContextLinked,// shortcut
37
+ reset,
38
+ } = useChatSettings();
39
+ ```
40
+
41
+ Storage key: `djc.chat.settings`.
42
+
43
+ ## Migration status
44
+
45
+ ### Migrated
46
+
47
+ - **Page-context opt-in** — `lib/page-snapshot/react/provider.tsx` now
48
+ reads/writes `ChatSettings.pageContext.linked` via `useChatSettings`
49
+ instead of its own `useLocalStorage('djc.page-snapshot.linked')`.
50
+ Note: this changes the storage key, so an existing user's prior opt-in
51
+ is not carried over — they start from the default (`linked: false`).
52
+ That is acceptable: page-context is opt-in and a fresh default is safe.
53
+
54
+ ### To migrate (safe follow-up — deferred to keep this pass low-risk)
55
+
56
+ These are mature, working call sites. They each persist correctly today;
57
+ moving them into `ChatSettings` is a clear next step but was deliberately
58
+ deferred so a single PR does not churn the whole chat. Each migration
59
+ also changes a storage key (acceptable — fresh defaults).
60
+
61
+ - **Dock / layout prefs**
62
+ - `tools/Chat/hooks/useChatDockPrefs.ts` — key `chat.dock.prefs`,
63
+ shape `{ mode, side, sideWidth }` → `ChatSettings.dock`.
64
+ - `tools/Chat/hooks/useChatLayout.ts` — keys `djc-chat-mode`
65
+ (`STORAGE_KEYS.mode`) and `djc-chat-sidebar-width`
66
+ (`STORAGE_KEYS.sidebarWidth`) → `ChatSettings.dock.mode` /
67
+ `ChatSettings.dock.width`. Note `useChatLayout` stores `mode` and
68
+ width as two separate keys; consolidate into the `dock` slice.
69
+ - Constants live in `tools/Chat/constants.ts` (`STORAGE_KEYS`).
70
+
71
+ - **Audio prefs**
72
+ - `tools/Chat/hooks/useChatAudio.ts` delegates to ui-core's
73
+ `useNotificationSounds`, which persists under
74
+ `djangocfg-chat-audio:prefs`. Migrating means either teaching
75
+ `useNotificationSounds` to accept an external value/setter, or
76
+ syncing `ChatSettings.audio` ⇆ that hook. Non-trivial — leave as is.
77
+
78
+ - **Speech / language prefs**
79
+ - `tools/SpeechRecognition/store/prefsStore.ts` — a zustand `persist`
80
+ store under `djangocfg-stt:prefs`, shape `{ language, deviceId,
81
+ engineId, earcons }` → `ChatSettings.speech`. This is a different
82
+ persistence mechanism (zustand, not `useLocalStorage`); migrating it
83
+ means reconciling the store with `useChatSettings` and is the
84
+ highest-effort of the three. Leave as is.
85
+
86
+ When migrating any of the above: update the consuming components, delete
87
+ the old key from `STORAGE_KEYS` where applicable, and update this file.
@@ -0,0 +1,84 @@
1
+ // @vitest-environment jsdom
2
+ import { act, createElement } from 'react';
3
+ import { createRoot, type Root } from 'react-dom/client';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+
6
+ import { CHAT_SETTINGS_STORAGE_KEY } from '../types';
7
+ import { useChatSettings, type UseChatSettingsReturn } from '../useChatSettings';
8
+
9
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let container: HTMLDivElement;
12
+ let root: Root;
13
+ let api: UseChatSettingsReturn | null = null;
14
+
15
+ function Probe() {
16
+ api = useChatSettings();
17
+ return null;
18
+ }
19
+
20
+ beforeEach(() => {
21
+ window.localStorage.clear();
22
+ container = document.createElement('div');
23
+ document.body.appendChild(container);
24
+ root = createRoot(container);
25
+ });
26
+
27
+ afterEach(() => {
28
+ act(() => root.unmount());
29
+ container.remove();
30
+ window.localStorage.clear();
31
+ api = null;
32
+ });
33
+
34
+ async function flush() {
35
+ await act(async () => {
36
+ await Promise.resolve();
37
+ });
38
+ }
39
+
40
+ describe('useChatSettings', () => {
41
+ it('exposes the default settings before anything is stored', async () => {
42
+ await act(async () => root.render(createElement(Probe)));
43
+ expect(api!.settings.pageContext.linked).toBe(false);
44
+ expect(api!.settings.audio.muted).toBe(false);
45
+ expect(api!.settings.dock.side).toBe('right');
46
+ });
47
+
48
+ it('updates only the targeted slice, leaving siblings untouched', async () => {
49
+ await act(async () => root.render(createElement(Probe)));
50
+ const audioBefore = api!.settings.audio;
51
+
52
+ await act(async () => api!.updateDock({ side: 'left' }));
53
+
54
+ expect(api!.settings.dock.side).toBe('left');
55
+ // Audio slice identity is unchanged — a feature never touches another's.
56
+ expect(api!.settings.audio).toBe(audioBefore);
57
+ });
58
+
59
+ it('setPageContextLinked persists the opt-in under the central key', async () => {
60
+ await act(async () => root.render(createElement(Probe)));
61
+ await act(async () => api!.setPageContextLinked(true));
62
+ await flush();
63
+
64
+ expect(api!.settings.pageContext.linked).toBe(true);
65
+ const stored = JSON.parse(
66
+ window.localStorage.getItem(CHAT_SETTINGS_STORAGE_KEY)!,
67
+ );
68
+ expect(stored.pageContext.linked).toBe(true);
69
+ });
70
+
71
+ it('reset restores every slice to defaults', async () => {
72
+ await act(async () => root.render(createElement(Probe)));
73
+ await act(async () => {
74
+ api!.setAudioMuted(true);
75
+ api!.updateDock({ side: 'left' });
76
+ });
77
+ await flush();
78
+ expect(api!.settings.audio.muted).toBe(true);
79
+
80
+ await act(async () => api!.reset());
81
+ expect(api!.settings.audio.muted).toBe(false);
82
+ expect(api!.settings.dock.side).toBe('right');
83
+ });
84
+ });
@@ -0,0 +1,138 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // Covers the Part-1 additions to `@djangocfg/ui-core`'s `useLocalStorage`:
4
+ // partial-merge `patch`, no-op skip, write coalescing, and same-tab
5
+ // cross-instance sync. ui-core ships no test runner, so the test lives
6
+ // here (ui-tools has vitest + jsdom and resolves ui-core via workspace).
7
+ import { act, createElement } from 'react';
8
+ import { createRoot, type Root } from 'react-dom/client';
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
+
11
+ import { useLocalStorage } from '@djangocfg/ui-core/hooks';
12
+
13
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
14
+
15
+ interface Prefs {
16
+ a: number;
17
+ b: number;
18
+ c: string;
19
+ }
20
+
21
+ let container: HTMLDivElement;
22
+ let root: Root;
23
+
24
+ beforeEach(() => {
25
+ window.localStorage.clear();
26
+ container = document.createElement('div');
27
+ document.body.appendChild(container);
28
+ root = createRoot(container);
29
+ });
30
+
31
+ afterEach(() => {
32
+ act(() => root.unmount());
33
+ container.remove();
34
+ window.localStorage.clear();
35
+ });
36
+
37
+ /** Flush pending microtask-coalesced writes. */
38
+ async function flushMicrotasks() {
39
+ await act(async () => {
40
+ await Promise.resolve();
41
+ });
42
+ }
43
+
44
+ describe('useLocalStorage — patch / coalescing / sync', () => {
45
+ it('patch shallow-merges a subset of keys', async () => {
46
+ let api: ReturnType<typeof useLocalStorage<Prefs>> | null = null;
47
+ function Probe() {
48
+ api = useLocalStorage<Prefs>('t.patch', { a: 1, b: 2, c: 'x' });
49
+ return null;
50
+ }
51
+ await act(async () => root.render(createElement(Probe)));
52
+
53
+ await act(async () => {
54
+ api![3]({ b: 99 });
55
+ });
56
+ expect(api![0]).toEqual({ a: 1, b: 99, c: 'x' });
57
+
58
+ await flushMicrotasks();
59
+ expect(JSON.parse(window.localStorage.getItem('t.patch')!)).toEqual({
60
+ a: 1,
61
+ b: 99,
62
+ c: 'x',
63
+ });
64
+ });
65
+
66
+ it('patch is a no-op when nothing changes', async () => {
67
+ let renders = 0;
68
+ let api: ReturnType<typeof useLocalStorage<Prefs>> | null = null;
69
+ function Probe() {
70
+ renders += 1;
71
+ api = useLocalStorage<Prefs>('t.noop', { a: 1, b: 2, c: 'x' });
72
+ return null;
73
+ }
74
+ await act(async () => root.render(createElement(Probe)));
75
+ await flushMicrotasks();
76
+ const rendersAfterMount = renders;
77
+
78
+ // Patching with the same values must not trigger a write/re-render.
79
+ await act(async () => {
80
+ api![3]({ a: 1, b: 2 });
81
+ });
82
+ await flushMicrotasks();
83
+
84
+ expect(renders).toBe(rendersAfterMount);
85
+ // No write happened — key stays absent.
86
+ expect(window.localStorage.getItem('t.noop')).toBeNull();
87
+ });
88
+
89
+ it('coalesces several patches in one tick into a single write', async () => {
90
+ let api: ReturnType<typeof useLocalStorage<Prefs>> | null = null;
91
+ function Probe() {
92
+ api = useLocalStorage<Prefs>('t.coalesce', { a: 0, b: 0, c: '' });
93
+ return null;
94
+ }
95
+ await act(async () => root.render(createElement(Probe)));
96
+
97
+ // Spy on the prototype — jsdom's per-instance `setItem` is read-only.
98
+ let writes = 0;
99
+ const realSetItem = Storage.prototype.setItem;
100
+ Storage.prototype.setItem = function patched(this: Storage, k, v) {
101
+ if (k === 't.coalesce') writes += 1;
102
+ return realSetItem.call(this, k, v);
103
+ };
104
+
105
+ await act(async () => {
106
+ api![3]({ a: 1 });
107
+ api![3]({ b: 2 });
108
+ api![3]({ c: 'z' });
109
+ });
110
+ await flushMicrotasks();
111
+ Storage.prototype.setItem = realSetItem;
112
+
113
+ // State reflects all three patches...
114
+ expect(api![0]).toEqual({ a: 1, b: 2, c: 'z' });
115
+ // ...but they collapsed into exactly one localStorage write.
116
+ expect(writes).toBe(1);
117
+ });
118
+
119
+ it('two hook instances on the same key stay in sync (same tab)', async () => {
120
+ let a: ReturnType<typeof useLocalStorage<Prefs>> | null = null;
121
+ let b: ReturnType<typeof useLocalStorage<Prefs>> | null = null;
122
+ function Probe() {
123
+ a = useLocalStorage<Prefs>('t.sync', { a: 1, b: 1, c: 'x' });
124
+ b = useLocalStorage<Prefs>('t.sync', { a: 1, b: 1, c: 'x' });
125
+ return null;
126
+ }
127
+ await act(async () => root.render(createElement(Probe)));
128
+
129
+ await act(async () => {
130
+ a![3]({ b: 42 });
131
+ });
132
+ await flushMicrotasks();
133
+
134
+ // The sibling instance saw the write without its own setter being called.
135
+ expect(a![0]).toEqual({ a: 1, b: 42, c: 'x' });
136
+ expect(b![0]).toEqual({ a: 1, b: 42, c: 'x' });
137
+ });
138
+ });
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * @djangocfg/ui-tools — Chat / settings
5
+ *
6
+ * The one place every chat-owned persisted setting lives. See README.md.
7
+ */
8
+
9
+ export {
10
+ CHAT_SETTINGS_STORAGE_KEY,
11
+ DEFAULT_CHAT_SETTINGS,
12
+ type ChatSettings,
13
+ type ChatDockSettings,
14
+ type ChatAudioSettings,
15
+ type ChatSpeechSettings,
16
+ type ChatPageContextSettings,
17
+ } from './types';
18
+
19
+ export {
20
+ useChatSettings,
21
+ type UseChatSettingsOptions,
22
+ type UseChatSettingsReturn,
23
+ } from './useChatSettings';
@@ -0,0 +1,108 @@
1
+ 'use client';
2
+
3
+ import type { ChatDisplayMode } from '../types';
4
+
5
+ /**
6
+ * Dock / layout preferences.
7
+ *
8
+ * Mirrors the fields the existing `useChatDockPrefs` / `useChatLayout`
9
+ * persist today. Kept as a nested slice so a feature can `updateDock({...})`
10
+ * without touching audio / speech / page-context state.
11
+ */
12
+ export interface ChatDockSettings {
13
+ /** Floating popover vs. side-docked panel vs. embedded, etc. */
14
+ mode: ChatDisplayMode;
15
+ /** Which edge a side dock attaches to. */
16
+ side: 'left' | 'right';
17
+ /** Width in px when side-docked. */
18
+ width: number;
19
+ }
20
+
21
+ /**
22
+ * Audio / notification-sound preferences.
23
+ *
24
+ * `eventsMuted` is a sparse map: only events the user explicitly toggled
25
+ * off appear here. An absent key means "use the default for that event".
26
+ */
27
+ export interface ChatAudioSettings {
28
+ /** Master mute — silences every chat sound. */
29
+ muted: boolean;
30
+ /** Master volume, 0..1. */
31
+ volume: number;
32
+ /** Per-event opt-outs, keyed by chat audio event name. */
33
+ eventsMuted: Record<string, boolean>;
34
+ }
35
+
36
+ /**
37
+ * Speech-recognition / language-picker preferences.
38
+ *
39
+ * `language` is a BCP-47 tag the user explicitly picked, or `null` for
40
+ * "no override" — callers then fall back to the app locale.
41
+ */
42
+ export interface ChatSpeechSettings {
43
+ /** Explicit language override, or `null` to follow the app locale. */
44
+ language: string | null;
45
+ /** Preferred capture device id, or `null` for the system default. */
46
+ deviceId: string | null;
47
+ /** Preferred STT engine id, or `null` for auto-select. */
48
+ engineId: string | null;
49
+ /** Earcon (audio cue) feedback on dictation start/stop. */
50
+ earcons: boolean;
51
+ }
52
+
53
+ /** Page-context (screen-sharing) opt-in. */
54
+ export interface ChatPageContextSettings {
55
+ /**
56
+ * Whether the user has opted in to sharing a snapshot of the page they
57
+ * are viewing with the assistant. Persisted so the choice survives
58
+ * reloads — the user enables it once.
59
+ */
60
+ linked: boolean;
61
+ }
62
+
63
+ /**
64
+ * The single, typed shape for every chat-owned persisted setting.
65
+ *
66
+ * Centralization rationale: chat features used to each call
67
+ * `useLocalStorage` with their own ad-hoc key. That made it impossible to
68
+ * reason about (or export/import) "the chat's settings" as one thing, and
69
+ * scattered the SSR/migration concerns. One object, one storage key, one
70
+ * hook — every feature reads/writes its own slice through it.
71
+ */
72
+ export interface ChatSettings {
73
+ dock: ChatDockSettings;
74
+ audio: ChatAudioSettings;
75
+ speech: ChatSpeechSettings;
76
+ pageContext: ChatPageContextSettings;
77
+ }
78
+
79
+ /** Baseline defaults — used for SSR and first-run (nothing stored yet). */
80
+ export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
81
+ dock: {
82
+ mode: 'closed',
83
+ side: 'right',
84
+ width: 420,
85
+ },
86
+ audio: {
87
+ muted: false,
88
+ volume: 1,
89
+ eventsMuted: {},
90
+ },
91
+ speech: {
92
+ language: null,
93
+ deviceId: null,
94
+ engineId: null,
95
+ earcons: false,
96
+ },
97
+ pageContext: {
98
+ linked: false,
99
+ },
100
+ };
101
+
102
+ /**
103
+ * Single localStorage key for the whole chat-settings object.
104
+ *
105
+ * One key (vs. one-per-feature) so the entire chat configuration is a
106
+ * single read/write/coalesce unit — see {@link useChatSettings}.
107
+ */
108
+ export const CHAT_SETTINGS_STORAGE_KEY = 'djc.chat.settings';
@@ -0,0 +1,168 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useMemo } from 'react';
4
+
5
+ import { useLocalStorage } from '@djangocfg/ui-core/hooks';
6
+
7
+ import {
8
+ CHAT_SETTINGS_STORAGE_KEY,
9
+ DEFAULT_CHAT_SETTINGS,
10
+ type ChatAudioSettings,
11
+ type ChatDockSettings,
12
+ type ChatPageContextSettings,
13
+ type ChatSettings,
14
+ type ChatSpeechSettings,
15
+ } from './types';
16
+
17
+ /** Options for {@link useChatSettings}. */
18
+ export interface UseChatSettingsOptions {
19
+ /**
20
+ * Override the storage key (per-product scoping). Defaults to the
21
+ * shared {@link CHAT_SETTINGS_STORAGE_KEY}.
22
+ */
23
+ storageKey?: string;
24
+ /**
25
+ * Override baseline defaults — merged over {@link DEFAULT_CHAT_SETTINGS}
26
+ * at the slice level (per-product branding, host opt-ins, etc.).
27
+ */
28
+ defaults?: Partial<ChatSettings>;
29
+ }
30
+
31
+ /** Everything {@link useChatSettings} exposes. */
32
+ export interface UseChatSettingsReturn {
33
+ /** The full, typed settings object (each slice always present). */
34
+ settings: ChatSettings;
35
+
36
+ /**
37
+ * Top-level grouped patch — shallow-merges whole slices. Use this for
38
+ * cross-slice updates; for a single slice prefer the typed helpers.
39
+ */
40
+ patch: (partial: Partial<ChatSettings>) => void;
41
+
42
+ /** Merge a subset of dock fields. */
43
+ updateDock: (partial: Partial<ChatDockSettings>) => void;
44
+ /** Merge a subset of audio fields. */
45
+ updateAudio: (partial: Partial<ChatAudioSettings>) => void;
46
+ /** Merge a subset of speech fields. */
47
+ updateSpeech: (partial: Partial<ChatSpeechSettings>) => void;
48
+ /** Merge a subset of page-context fields. */
49
+ updatePageContext: (partial: Partial<ChatPageContextSettings>) => void;
50
+
51
+ /** Master audio mute shortcut. */
52
+ setAudioMuted: (muted: boolean) => void;
53
+ /** Page-context opt-in shortcut. */
54
+ setPageContextLinked: (linked: boolean) => void;
55
+
56
+ /** Reset every slice back to defaults. */
57
+ reset: () => void;
58
+ }
59
+
60
+ /** Shallow-merge `defaults` slices over the baseline. */
61
+ function resolveDefaults(defaults?: Partial<ChatSettings>): ChatSettings {
62
+ if (!defaults) return DEFAULT_CHAT_SETTINGS;
63
+ return {
64
+ dock: { ...DEFAULT_CHAT_SETTINGS.dock, ...defaults.dock },
65
+ audio: { ...DEFAULT_CHAT_SETTINGS.audio, ...defaults.audio },
66
+ speech: { ...DEFAULT_CHAT_SETTINGS.speech, ...defaults.speech },
67
+ pageContext: {
68
+ ...DEFAULT_CHAT_SETTINGS.pageContext,
69
+ ...defaults.pageContext,
70
+ },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The single entry point for chat-owned persisted settings.
76
+ *
77
+ * Every chat feature that wants to remember a preference goes through
78
+ * here instead of calling `useLocalStorage` with its own key. Why:
79
+ *
80
+ * - One typed object, one storage key — "the chat's settings" is a single
81
+ * thing that can be reasoned about, exported, or reset wholesale.
82
+ * - Coalesced writes: `useLocalStorage` (Part 1) updates React state
83
+ * synchronously but batches the actual `localStorage` write to one per
84
+ * tick — so several features patching different slices in the same
85
+ * render commit collapse into a single write, no thrash.
86
+ * - Cross-instance sync: two `useChatSettings()` consumers (e.g. the
87
+ * header audio toggle and the settings panel) stay in lockstep within
88
+ * the same tab and across tabs.
89
+ *
90
+ * Each helper updates only its own slice, leaving sibling slices
91
+ * untouched — a feature never has to know about the others.
92
+ *
93
+ * SSR-safe: returns defaults on the server, hydrates on mount.
94
+ */
95
+ export function useChatSettings(
96
+ opts: UseChatSettingsOptions = {},
97
+ ): UseChatSettingsReturn {
98
+ const key = opts.storageKey ?? CHAT_SETTINGS_STORAGE_KEY;
99
+ // Memoized so the resolved defaults keep a stable identity — passing a
100
+ // fresh object to `useLocalStorage` each render is harmless but wasteful.
101
+ const initial = useMemo(
102
+ () => resolveDefaults(opts.defaults),
103
+ [opts.defaults],
104
+ );
105
+
106
+ const [settings, setSettings, , patch] = useLocalStorage<ChatSettings>(
107
+ key,
108
+ initial,
109
+ );
110
+
111
+ // Slice updaters: read the current slice, shallow-merge the partial,
112
+ // write back via the coalescing top-level `patch`. The inner
113
+ // `useLocalStorage` `patch` already skips no-op writes.
114
+ const updateDock = useCallback(
115
+ (partial: Partial<ChatDockSettings>) => {
116
+ patch({ dock: { ...settings.dock, ...partial } });
117
+ },
118
+ [patch, settings.dock],
119
+ );
120
+
121
+ const updateAudio = useCallback(
122
+ (partial: Partial<ChatAudioSettings>) => {
123
+ patch({ audio: { ...settings.audio, ...partial } });
124
+ },
125
+ [patch, settings.audio],
126
+ );
127
+
128
+ const updateSpeech = useCallback(
129
+ (partial: Partial<ChatSpeechSettings>) => {
130
+ patch({ speech: { ...settings.speech, ...partial } });
131
+ },
132
+ [patch, settings.speech],
133
+ );
134
+
135
+ const updatePageContext = useCallback(
136
+ (partial: Partial<ChatPageContextSettings>) => {
137
+ patch({ pageContext: { ...settings.pageContext, ...partial } });
138
+ },
139
+ [patch, settings.pageContext],
140
+ );
141
+
142
+ const setAudioMuted = useCallback(
143
+ (muted: boolean) => updateAudio({ muted }),
144
+ [updateAudio],
145
+ );
146
+
147
+ const setPageContextLinked = useCallback(
148
+ (linked: boolean) => updatePageContext({ linked }),
149
+ [updatePageContext],
150
+ );
151
+
152
+ const reset = useCallback(
153
+ () => setSettings(initial),
154
+ [setSettings, initial],
155
+ );
156
+
157
+ return {
158
+ settings,
159
+ patch,
160
+ updateDock,
161
+ updateAudio,
162
+ updateSpeech,
163
+ updatePageContext,
164
+ setAudioMuted,
165
+ setPageContextLinked,
166
+ reset,
167
+ };
168
+ }
@@ -7,6 +7,32 @@
7
7
 
8
8
  import type { ChatSource } from './attachment';
9
9
 
10
+ /**
11
+ * Per-run metrics block. Emitted once per assistant turn alongside the
12
+ * terminal `message_end` event (a separate `message_metrics` event so
13
+ * `message_end` keeps its lean `tokensIn`/`tokensOut`/`sources` shape).
14
+ *
15
+ * All fields optional — the backend omits anything it could not measure.
16
+ */
17
+ export interface ChatMessageMetrics {
18
+ inputTokens?: number;
19
+ outputTokens?: number;
20
+ totalTokens?: number;
21
+ cacheReadTokens?: number;
22
+ cacheWriteTokens?: number;
23
+ processingTimeMs?: number;
24
+ firstTokenMs?: number;
25
+ turns?: number;
26
+ toolCallCount?: number;
27
+ toolNames?: string[];
28
+ /** Model the request was sent with (may be an alias). */
29
+ model?: string;
30
+ /** Concrete model the alias resolved to. */
31
+ resolvedModel?: string;
32
+ /** Short summary of the model's thinking, if available. */
33
+ thinkingSummary?: string;
34
+ }
35
+
10
36
  export type ChatStreamEvent =
11
37
  | { type: 'message_start'; messageId: string; sessionId: string }
12
38
  | { type: 'resume_start' }
@@ -32,4 +58,28 @@ export type ChatStreamEvent =
32
58
  tokensOut?: number;
33
59
  sources?: ChatSource[];
34
60
  }
61
+ | {
62
+ /**
63
+ * Per-turn metrics for the assistant message currently streaming.
64
+ * Non-terminal — arrives near the end of the run but does not
65
+ * close the stream (`message_end` / `error` do that).
66
+ */
67
+ type: 'message_metrics';
68
+ metrics: ChatMessageMetrics;
69
+ }
70
+ | {
71
+ /**
72
+ * One-shot model-alias resolution (e.g. `@code` → `glm-5.1`).
73
+ * Non-terminal — lets the UI update the model chip mid-run.
74
+ */
75
+ type: 'resolved_model';
76
+ /** Alias the user/request originally specified. */
77
+ originalAlias: string;
78
+ /** Concrete model id the alias resolved to. */
79
+ resolvedModel: string;
80
+ /** True when routing upgraded the model (e.g. for a larger context). */
81
+ upgraded?: boolean;
82
+ /** Human-readable reason the router picked this model. */
83
+ routingReason?: string;
84
+ }
35
85
  | { type: 'error'; code: string; message: string };
@@ -40,7 +40,7 @@ export type { ChatMessage } from './message';
40
40
  export { DEFAULT_LABELS } from './labels';
41
41
  export type { ChatLabels } from './labels';
42
42
  export type { ChatConfig, ChatPrefs, ChatDisplayMode } from './config';
43
- export type { ChatStreamEvent } from './events';
43
+ export type { ChatStreamEvent, ChatMessageMetrics } from './events';
44
44
  export type {
45
45
  CreateSessionOptions,
46
46
  SessionInfo,
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { ChatAttachment, ChatSource } from './attachment';
9
9
  import type { MessageBlock } from './block';
10
+ import type { ChatMessageMetrics } from './events';
10
11
  import type { ChatPersona, ChatRole } from './persona';
11
12
  import type { ChatToolCall } from './tool-call';
12
13
 
@@ -32,4 +33,8 @@ export interface ChatMessage {
32
33
  sources?: ChatSource[];
33
34
  tokensIn?: number;
34
35
  tokensOut?: number;
36
+ /** Per-turn metrics (from a `message_metrics` stream event). */
37
+ metrics?: ChatMessageMetrics;
38
+ /** Concrete model the request resolved to (from a `resolved_model` event). */
39
+ resolvedModel?: string;
35
40
  }