ompchamber 3.2.0 → 3.3.1

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 (52) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +239 -230
  3. package/dist/client/static/js/{index-1q6165r3.js → index-4qx6m2p7.js} +640 -640
  4. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  8. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
  9. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  10. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
  11. package/src/client/components/settings/Modal.tsx +10 -17
  12. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  13. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  14. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
  15. package/src/client/components/workspace/editor/index.tsx +15 -7
  16. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  17. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  18. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  19. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  20. package/src/client/data/theme/terminal.ts +25 -8
  21. package/src/client/hooks/editor/font.ts +53 -0
  22. package/src/client/hooks/ui/clipboard.ts +17 -0
  23. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  24. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  25. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  26. package/src/client/hooks/ui/text-overflow.ts +78 -0
  27. package/src/client/hooks/ui/touch-device.ts +46 -0
  28. package/src/client/main.tsx +1 -0
  29. package/src/server/index.ts +4 -3
  30. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  31. package/src/server/lib/assets/conditional.server.ts +54 -0
  32. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  33. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  34. package/src/server/lib/assets/fonts.server.ts +9 -4
  35. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  36. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  37. package/src/server/lib/bundler/dependencies.test.ts +148 -0
  38. package/src/server/lib/lifecycle/listener.ts +29 -0
  39. package/src/server/plugins/shell.server.ts +52 -13
  40. package/src/server/plugins/ssr.ts +43 -7
  41. package/src/server/plugins/static.ts +51 -18
  42. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  43. package/src/shared/lib/code/editor/typography.ts +86 -0
  44. package/src/shared/lib/fonts/NOTICE.md +44 -0
  45. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  46. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  47. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  48. package/src/shared/lib/settings/diff.test.ts +1 -1
  49. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  50. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  51. package/src/shared/types/settings/state.ts +7 -16
  52. package/dist/client/static/js/index-01dv383b.css +0 -1
@@ -1,10 +1,22 @@
1
- import { useCallback, useRef } from 'preact/hooks';
1
+ import { useCallback, useRef, useState } from 'preact/hooks';
2
2
  import { useTerminal } from '@/client/hooks/workspace/terminal';
3
3
  import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
4
4
  import { useTheme } from '@/client/hooks/ui/theme';
5
+ import { useTouchDevice } from '@/client/hooks/ui/touch-device';
6
+ import { useKeyboardInset } from '@/client/hooks/ui/keyboard-inset';
7
+ import { copyToClipboard, readClipboardText } from '@/client/hooks/ui/clipboard';
5
8
  import { TerminalHeader } from '@/client/components/workspace/terminal-panel/Header';
6
9
  import { TerminalQuickActions } from '@/client/components/workspace/terminal-panel/QuickActions';
10
+ import { TerminalKeyBar } from '@/client/components/workspace/terminal-panel/KeyBar';
7
11
  import { RealtimeXtermView, type RealtimeXtermHandle } from '@/client/components/workspace/terminal-panel/RealtimeXtermView';
12
+ import {
13
+ NO_MODIFIERS,
14
+ applyModifiers,
15
+ resolveKeySequence,
16
+ type TerminalKeyId,
17
+ type TerminalModifier,
18
+ type TerminalModifiers,
19
+ } from '@/shared/lib/workspace/terminal/keys';
8
20
 
9
21
  interface TerminalPanelProps {
10
22
  className?: string;
@@ -18,9 +30,22 @@ const encoder = new TextEncoder();
18
30
  export function TerminalPanel({ className = '', enabled = true, rootPath, showHeader = true }: TerminalPanelProps) {
19
31
  const xtermRef = useRef<RealtimeXtermHandle>(null);
20
32
  const { isDark } = useTheme();
33
+ // Only a device without a physical keyboard gets the key bar: on a desktop
34
+ // every one of those keys is already under the user's fingers, and a strip of
35
+ // them would cost the canvas height for nothing.
36
+ const isTouchDevice = useTouchDevice();
37
+ // The inset is measured from the panel's OWN bottom edge, so it can never
38
+ // double-count a keyboard the container already shrank for.
39
+ const rootRef = useRef<HTMLDivElement>(null);
40
+ const keyboardInset = useKeyboardInset(rootRef);
41
+ const [modifiers, setModifiers] = useState<TerminalModifiers>(NO_MODIFIERS);
21
42
  const { activeRepo, setActiveRepo } = useRepoScope(rootPath, 'terminal.activeRepo');
22
43
  const { repos, scanning: reposScanning, rescan: rescanRepos } = useRepoList(rootPath, enabled);
23
44
  const gridRef = useRef<{ cols: number; rows: number } | null>(null);
45
+ // The latch is read inside `handleInput`, which xterm calls outside React's
46
+ // render — a ref is what makes it the value at the keystroke, not at mount.
47
+ const modifiersRef = useRef(modifiers);
48
+ modifiersRef.current = modifiers;
24
49
 
25
50
  const handleOutput = useCallback((bytes: Uint8Array) => {
26
51
  xtermRef.current?.write(bytes);
@@ -70,10 +95,59 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
70
95
 
71
96
  // Keystrokes arrive as text; `onBinary` payloads are latin-1 bytes and must
72
97
  // not be UTF-8 encoded on the way out.
98
+ //
99
+ // A latched modifier is folded in here, on the way out, because that is the
100
+ // only place a soft keyboard's letters pass through — Ctrl+C on a phone is a
101
+ // tap on `Ctrl` and then the `c` the OS keyboard produces.
73
102
  const handleInput = useCallback((data: string) => {
74
- input(encoder.encode(data));
103
+ const latched = modifiersRef.current;
104
+ const armed = latched.ctrl || latched.alt;
105
+ const encoded = applyModifiers(data, latched);
106
+ // Only a single typed character consumes the latch. A paste, a mouse report
107
+ // or a focus event arrives on this same event and must leave the armed
108
+ // modifier alone, or the tap that armed it is silently spent on a no-op.
109
+ if (armed && data.length === 1) setModifiers(NO_MODIFIERS);
110
+ input(encoder.encode(encoded));
75
111
  }, [input]);
76
112
 
113
+ const handleToggleModifier = useCallback((modifier: TerminalModifier) => {
114
+ setModifiers((previous) => ({ ...previous, [modifier]: !previous[modifier] }));
115
+ xtermRef.current?.focus();
116
+ }, []);
117
+
118
+ const handleKey = useCallback((id: TerminalKeyId) => {
119
+ const sequence = resolveKeySequence(id, {
120
+ applicationCursorKeys: xtermRef.current?.applicationCursorKeys() ?? false,
121
+ modifiers: modifiersRef.current,
122
+ });
123
+ if (!sequence) return;
124
+ input(encoder.encode(sequence));
125
+ setModifiers(NO_MODIFIERS);
126
+ // Keep the OS keyboard where it was; a key bar tap must not dismiss it.
127
+ xtermRef.current?.focus();
128
+ }, [input]);
129
+
130
+ /**
131
+ * Cmd+C / Cmd+V, the two chords a terminal actually has — a phone can neither
132
+ * select on the canvas nor reach its own clipboard from there. Copy reports
133
+ * whether it had anything to copy so the bar can say "nothing selected"
134
+ * instead of flashing success over an untouched clipboard.
135
+ */
136
+ const handleCopy = useCallback(async () => {
137
+ const selection = xtermRef.current?.getSelection() ?? '';
138
+ if (!selection) return false;
139
+ return copyToClipboard(selection);
140
+ }, []);
141
+
142
+ const handlePaste = useCallback(async () => {
143
+ const text = await readClipboardText();
144
+ if (text === null || text.length === 0) return false;
145
+ // Through xterm's own paste, so a bracketed-paste shell gets its markers.
146
+ xtermRef.current?.paste(text);
147
+ xtermRef.current?.focus();
148
+ return true;
149
+ }, []);
150
+
77
151
  const handleBinaryInput = useCallback((data: string) => {
78
152
  const bytes = new Uint8Array(data.length);
79
153
  for (let i = 0; i < data.length; i += 1) bytes[i] = data.charCodeAt(i) & 0xff;
@@ -98,7 +172,17 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
98
172
  }
99
173
 
100
174
  return (
101
- <div className={`flex flex-col h-full w-full bg-paper text-ink overflow-hidden ${className}`}>
175
+ <div
176
+ ref={rootRef}
177
+ className={`flex flex-col h-full w-full bg-paper text-ink overflow-hidden ${className}`}
178
+ // The soft keyboard covers the bottom of the visual viewport without
179
+ // shrinking the layout one, so the key bar would sit behind the keys the
180
+ // user is typing on. Lifting by the panel's actual overlap with the
181
+ // visible area keeps the bar — and the shell's own grid, which the fit
182
+ // observer re-measures — above the keyboard, and lifts nothing at all
183
+ // when the container already ends above it.
184
+ style={keyboardInset > 0 ? { paddingBottom: keyboardInset } : undefined}
185
+ >
102
186
  {showHeader && (
103
187
  <TerminalHeader
104
188
  status={status}
@@ -135,6 +219,17 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
135
219
  onGridChange={handleGridChange}
136
220
  />
137
221
  </div>
222
+
223
+ {isTouchDevice && (
224
+ <TerminalKeyBar
225
+ modifiers={modifiers}
226
+ onToggleModifier={handleToggleModifier}
227
+ onKey={handleKey}
228
+ onCopy={handleCopy}
229
+ onPaste={handlePaste}
230
+ disabled={status !== 'running'}
231
+ />
232
+ )}
138
233
  </div>
139
234
  );
140
235
  }
@@ -34,6 +34,7 @@ import type { FitAddon } from '@xterm/addon-fit';
34
34
  import type { WebglAddon } from '@xterm/addon-webgl';
35
35
  import {
36
36
  getXtermTheme,
37
+ NERD_SYMBOLS_FAMILY,
37
38
  XTERM_FONT_FAMILY,
38
39
  safePatchFitAddon,
39
40
  safePatchRenderService,
@@ -127,6 +128,11 @@ export async function mountXterm(
127
128
  const fitAddon = new FitAddonClass() as FitAddon;
128
129
  term.loadAddon(fitAddon);
129
130
 
131
+ // The bundled Nerd Font symbols face may still be in flight (a phone has no
132
+ // Nerd Font of its own). See the helper for why the atlas has to be dropped
133
+ // once it lands.
134
+ const stopNerdFaceWatch = refreshAtlasWhenNerdFaceLoads(term);
135
+
130
136
  term.attachCustomKeyEventHandler((event: globalThis.KeyboardEvent) => handleKeyNavigation(term, event));
131
137
 
132
138
  const disposables = [
@@ -228,6 +234,7 @@ export async function mountXterm(
228
234
  dispose() {
229
235
  if (frame !== null) cancelAnimationFrame(frame);
230
236
  clearTimeout(fitTimer ?? undefined);
237
+ stopNerdFaceWatch();
231
238
  touch();
232
239
  for (const disposable of disposables) disposable.dispose();
233
240
  resizeObserver.disconnect();
@@ -241,6 +248,46 @@ export async function mountXterm(
241
248
  };
242
249
  }
243
250
 
251
+ /**
252
+ * Re-rasterize the terminal once the bundled symbols face has loaded.
253
+ *
254
+ * xterm has no `document.fonts` listener: it rasterizes a glyph the first time
255
+ * it draws it and keeps the result in a texture atlas, so a PUA glyph written
256
+ * before the face arrived stays whatever the fallback painted — a tofu box —
257
+ * for the life of the buffer. The browser does re-lay-out DOM text when a face
258
+ * lands late (verified on the DOM renderer), but xterm's canvas does not get
259
+ * that for free.
260
+ *
261
+ * `loadingdone` naming our own family is exactly the signal that this machine
262
+ * had no Nerd Font of its own: the face is declared last in the stack and its
263
+ * `unicode-range` covers only PUA, so a device with an installed Nerd Font
264
+ * never fetches it (verified: zero requests with a local family present). That
265
+ * makes this a no-op on the developer's laptop and the fix on a phone.
266
+ *
267
+ * `clearTextureAtlas()` drops the atlas and forces a full refresh, which is
268
+ * what re-rasterizes the glyphs. It is a public API on `Terminal`, and the
269
+ * atlas is small enough that a full refresh is cheap — this runs at most once
270
+ * per page load.
271
+ *
272
+ * Returns a disposer.
273
+ */
274
+ function refreshAtlasWhenNerdFaceLoads(term: Terminal): () => void {
275
+ if (typeof document === 'undefined' || !document.fonts) return () => {};
276
+
277
+ const onLoadingDone = (event: FontFaceSetLoadEvent) => {
278
+ const loaded = [...event.fontfaces].some((face) => face.family.replace(/['"]/g, '') === NERD_SYMBOLS_FAMILY);
279
+ if (!loaded) return;
280
+ try {
281
+ term.clearTextureAtlas();
282
+ } catch {
283
+ // A disposed terminal has no render service; nothing to refresh.
284
+ }
285
+ };
286
+
287
+ document.fonts.addEventListener('loadingdone', onLoadingDone);
288
+ return () => document.fonts.removeEventListener('loadingdone', onLoadingDone);
289
+ }
290
+
244
291
  /** Shift+PageUp/Home/End scroll the viewport instead of reaching the shell. */
245
292
  function handleKeyNavigation(term: Terminal, event: globalThis.KeyboardEvent): boolean {
246
293
  if (event.type !== 'keydown' || !event.shiftKey) return true;
@@ -60,14 +60,21 @@ export function getXtermTheme(themeId: string) {
60
60
  * Material Design icons) that **no** coding webfont contains — `@fontsource/
61
61
  * fira-code`'s latin subset maps 226 codepoints and not one of them is above
62
62
  * U+FFFF — so without a face that supplies them the browser paints a hollow
63
- * tofu box for every prompt segment. The names below cover the Nerd Font
64
- * families a user is most likely to have installed (plus the *fontconfig*
65
- * symbol faces on Linux); a face that is absent is simply skipped by the
66
- * normal fallback chain, so listing many costs nothing.
63
+ * tofu box for every prompt segment.
67
64
  *
68
- * This is the same shape OpenChamber ships (`TERMINAL_GLYPH_FALLBACKS` in its
69
- * `ghostty/surface.ts`), which additionally bundles `SymbolsNerdFontMono` as a
70
- * webfont so the coverage never depends on the machine.
65
+ * The entries before the last one name the Nerd Font families a user is most
66
+ * likely to have installed (plus the *fontconfig* symbol faces on Linux); a
67
+ * face that is absent is simply skipped by the normal fallback chain, so
68
+ * listing many costs nothing.
69
+ *
70
+ * `'OMPChamber Nerd Symbols'` is the one the chamber bundles itself
71
+ * (`src/shared/lib/fonts/nerd-symbols.css`), and it is deliberately LAST: a
72
+ * machine that already has a Nerd Font renders from it and never fetches these
73
+ * bytes, while a phone — which has none — falls through to it instead of
74
+ * painting tofu. Verified: with the bundled face last and a local Nerd Font
75
+ * ahead of it, laying out a PUA glyph issued zero font requests; with no local
76
+ * family present, the face is fetched and all of the prompt's codepoints
77
+ * render.
71
78
  *
72
79
  * Only glyphs the text face lacks reach these entries, so Latin metrics are
73
80
  * untouched — verified: `A`/`m`/`W` keep identical advance and ink.
@@ -78,7 +85,17 @@ export const XTERM_GLYPH_FALLBACKS =
78
85
  "'MesloLGM Nerd Font', 'CaskaydiaCove Nerd Font', 'SauceCodePro Nerd Font', " +
79
86
  "'Symbols Nerd Font Mono', 'Symbols Nerd Font', 'PowerlineSymbols'";
80
87
 
81
- export const XTERM_FONT_FAMILY = `'Fira Code', ${XTERM_GLYPH_FALLBACKS}, Menlo, Monaco, 'Courier New', monospace`;
88
+ /**
89
+ * The bundled symbols face, kept separate so `mount.ts` can await it by name.
90
+ *
91
+ * It must stay last in the stack (see above) and its family name must match the
92
+ * `@font-face` in `nerd-symbols.css` — the two are one contract, which is why
93
+ * the name is a constant rather than a literal in each file.
94
+ */
95
+ export const NERD_SYMBOLS_FAMILY = 'OMPChamber Nerd Symbols';
96
+
97
+ export const XTERM_FONT_FAMILY =
98
+ `'Fira Code', ${XTERM_GLYPH_FALLBACKS}, '${NERD_SYMBOLS_FAMILY}', Menlo, Monaco, 'Courier New', monospace`;
82
99
 
83
100
  /** xterm's private render service, read only to size-check before fitting. */
84
101
  export interface XtermCore {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor font setting, and how a live change reaches an open editor.
8
+ *
9
+ * The setting is visual, so it has to apply to the editor the user is looking
10
+ * at — not on the next reload. That rules out reading it once from the
11
+ * bootstrap payload the way `keybindingSend` is read: `appSettings` is a prop
12
+ * captured at boot, so a settings change would not reach an already-mounted
13
+ * editor. The theme solved the same problem with a window event, and this
14
+ * follows it: the picker writes the setting AND announces it, and the editor
15
+ * subscribes.
16
+ *
17
+ * The stored value is the family name. `editorFontStack` puts it in front of
18
+ * the shared stack, so a face the device lacks falls through to the bundled
19
+ * Fira Code instead of replacing it — which is why the picker can offer faces
20
+ * the app does not ship.
21
+ */
22
+
23
+ import { useEffect, useState } from 'preact/hooks';
24
+ import { readChamberSetting } from '@/shared/lib/settings/client';
25
+ import {
26
+ EDITOR_DEFAULT_FONT_FAMILY,
27
+ editorFontStack,
28
+ } from '@/shared/lib/code/editor/typography';
29
+
30
+ /** Broadcast on every editor-font write. Detail is the new family name. */
31
+ export const EDITOR_FONT_CHANGED_EVENT = 'omp:editor-font-changed';
32
+
33
+ /** The family currently stored, falling back to the shipped default. */
34
+ export function currentEditorFont(): string {
35
+ if (typeof window === 'undefined') return EDITOR_DEFAULT_FONT_FAMILY;
36
+ return readChamberSetting<string>('editorFont') ?? EDITOR_DEFAULT_FONT_FAMILY;
37
+ }
38
+
39
+ /** The stored family and the stack it resolves to, kept in step with writes. */
40
+ export function useEditorFontStack(): string {
41
+ const [family, setFamily] = useState<string>(currentEditorFont);
42
+
43
+ useEffect(() => {
44
+ const onChange = (event: Event) => {
45
+ const detail = (event as CustomEvent<string>).detail;
46
+ setFamily(detail || currentEditorFont());
47
+ };
48
+ window.addEventListener(EDITOR_FONT_CHANGED_EVENT, onChange);
49
+ return () => window.removeEventListener(EDITOR_FONT_CHANGED_EVENT, onChange);
50
+ }, []);
51
+
52
+ return editorFontStack(family);
53
+ }
@@ -31,3 +31,20 @@ export async function copyToClipboard(text: string): Promise<boolean> {
31
31
  return false;
32
32
  }
33
33
  }
34
+
35
+ /**
36
+ * Read text from the clipboard, or null when the browser refused.
37
+ *
38
+ * There is no fallback, and there cannot be one: `execCommand('paste')` is
39
+ * blocked by every browser and `navigator.clipboard.readText` needs a secure
40
+ * context plus a permission the user grants once — so a denial is reported
41
+ * rather than papered over with an empty paste.
42
+ */
43
+ export async function readClipboardText(): Promise<string | null> {
44
+ try {
45
+ if (!navigator.clipboard || !window.isSecureContext) return null;
46
+ return await navigator.clipboard.readText();
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The keyboard-inset arithmetic.
8
+ *
9
+ * The failure this file exists for is an OVER-lift: computing the keyboard's
10
+ * height from `innerHeight - visualHeight` and applying it to a panel that does
11
+ * not reach the screen's bottom edge (a drawer with safe-area padding, a
12
+ * container a browser already shrank) lifts by the keyboard's height PLUS the
13
+ * gap the panel never had. Deriving the lift from the panel's own overlap makes
14
+ * every one of those cases zero.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+
19
+ import { resolveKeyboardInset } from '@/client/hooks/ui/keyboard-inset';
20
+
21
+ describe('resolveKeyboardInset', () => {
22
+ test('a panel that clears the visible bottom is not lifted', () => {
23
+ // Keyboard open, panel already above it: nothing to do.
24
+ expect(resolveKeyboardInset(500, 508)).toBe(0);
25
+ expect(resolveKeyboardInset(508, 508)).toBe(0);
26
+ });
27
+
28
+ test('a collapsed URL bar is not a keyboard', () => {
29
+ expect(resolveKeyboardInset(844, 780)).toBe(0);
30
+ expect(resolveKeyboardInset(844, 765)).toBe(0);
31
+ });
32
+
33
+ test('the lift is the overlap, not the keyboard height', () => {
34
+ // Panel bottom at 844, visible bottom at 508 -> 336px covered.
35
+ expect(resolveKeyboardInset(844, 508)).toBe(336);
36
+ // The same keyboard over a panel that ends 20px higher lifts 20px less,
37
+ // which is exactly the over-lift the height-difference form produced.
38
+ expect(resolveKeyboardInset(824, 508)).toBe(316);
39
+ expect(resolveKeyboardInset(508, 508)).toBe(0);
40
+ });
41
+
42
+ test('a nonsense visible bottom lifts nothing', () => {
43
+ expect(resolveKeyboardInset(844, 0)).toBe(0);
44
+ expect(resolveKeyboardInset(844, -100)).toBe(0);
45
+ });
46
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * How far a bottom-anchored panel must lift so a soft keyboard does not cover
8
+ * it.
9
+ *
10
+ * The measurement is taken from the PANEL'S OWN geometry, not from
11
+ * `window.innerHeight`: the overlap between the panel's bottom edge and the
12
+ * visible bottom edge is what actually matters, and it is self-correcting.
13
+ *
14
+ * The difference matters because `innerHeight` is not a stable reference. A
15
+ * browser that shrinks it when the keyboard opens, a `dvh` container that
16
+ * tracks it, or a panel that already ends above the keyboard all change what
17
+ * "the keyboard height" means — and computing `innerHeight - visualHeight`
18
+ * against a height that already moved lifts the panel by the keyboard's height
19
+ * a second time, which is the too-far margin. Deriving the lift from the
20
+ * overlap yields zero in every one of those cases and the exact height only
21
+ * when the panel genuinely runs under the keyboard.
22
+ *
23
+ * Deliberately local to the panel that uses it. Putting
24
+ * `interactive-widget=resizes-content` in the viewport meta would solve this
25
+ * globally, but it changes how every surface in the app sizes itself when the
26
+ * keyboard opens — a whole-app layout decision, not a terminal one.
27
+ */
28
+
29
+ import { useEffect, useState } from 'preact/hooks';
30
+
31
+ /** Ignore small overlaps: a collapsing URL bar is not a keyboard. */
32
+ const MIN_KEYBOARD_INSET_PX = 80;
33
+
34
+ /** The element shape the hook needs; keeps the signature free of a Preact import. */
35
+ export interface PanelRef {
36
+ readonly current: HTMLElement | null;
37
+ }
38
+
39
+ /**
40
+ * The lift for one measurement pair, in CSS px.
41
+ *
42
+ * `panelBottom` is the panel's bottom edge and `visibleBottom` the visible
43
+ * area's bottom edge, both in the same coordinate space. Pure so the cases
44
+ * below can be pinned without a DOM.
45
+ */
46
+ export function resolveKeyboardInset(panelBottom: number, visibleBottom: number): number {
47
+ // A non-positive visible bottom is a browser reporting nonsense; lifting by
48
+ // it would squeeze the panel to nothing, so the failure mode is "no keyboard".
49
+ if (visibleBottom <= 0) return 0;
50
+ const overlap = panelBottom - visibleBottom;
51
+ if (overlap < MIN_KEYBOARD_INSET_PX) return 0;
52
+ return Math.round(overlap);
53
+ }
54
+
55
+ export function useKeyboardInset(ref: PanelRef): number {
56
+ const [inset, setInset] = useState(0);
57
+
58
+ useEffect(() => {
59
+ const element = ref.current;
60
+ if (!element) return;
61
+
62
+ let frame: number | null = null;
63
+ const measure = () => {
64
+ frame = null;
65
+ const viewport = window.visualViewport;
66
+ if (!viewport) {
67
+ setInset((previous) => (previous === 0 ? previous : 0));
68
+ return;
69
+ }
70
+ // `offsetTop` + `height` is the visible area's bottom edge within the
71
+ // layout viewport, which is the space `getBoundingClientRect` reports in.
72
+ const next = resolveKeyboardInset(
73
+ element.getBoundingClientRect().bottom,
74
+ viewport.offsetTop + viewport.height,
75
+ );
76
+ setInset((previous) => (previous === next ? previous : next));
77
+ };
78
+
79
+ // Deferred by a frame: `visualViewport.resize` fires BEFORE the container
80
+ // reflows for the keyboard, so measuring in the handler reads the old, full
81
+ // height and lifts by the keyboard twice over. One frame later the layout
82
+ // has settled and the panel's own bottom edge is the truth.
83
+ const schedule = () => {
84
+ if (frame !== null) return;
85
+ frame = requestAnimationFrame(measure);
86
+ };
87
+
88
+ // The container can also shrink with no viewport event at all (a `dvh`
89
+ // wrapper tracking the keyboard, a drawer resized by the layout). Watching
90
+ // the panel's BORDER box catches that without a feedback loop: our own
91
+ // padding changes the content box only.
92
+ const observer = new ResizeObserver(schedule);
93
+ observer.observe(element, { box: 'border-box' });
94
+
95
+ const viewport = window.visualViewport;
96
+ viewport?.addEventListener('resize', schedule);
97
+ viewport?.addEventListener('scroll', schedule);
98
+ window.addEventListener('resize', schedule);
99
+ window.addEventListener('orientationchange', schedule);
100
+ schedule();
101
+
102
+ return () => {
103
+ if (frame !== null) cancelAnimationFrame(frame);
104
+ observer.disconnect();
105
+ viewport?.removeEventListener('resize', schedule);
106
+ viewport?.removeEventListener('scroll', schedule);
107
+ window.removeEventListener('resize', schedule);
108
+ window.removeEventListener('orientationchange', schedule);
109
+ };
110
+ }, [ref]);
111
+
112
+ return inset;
113
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Truncation measurement contract. The bug this hook replaces was a character
8
+ * count standing in for a width: 76 characters fit a desktop timeline and were
9
+ * clipped on a 340px phone card, so the notice rendered an ellipsis and shipped
10
+ * a disabled button — the chevron was dropped with it, and the tap did nothing.
11
+ *
12
+ * happy-dom does no layout, so `scrollWidth`/`clientWidth` are faked on the
13
+ * element exactly as the scroll tests fake container geometry.
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
17
+ import { Window } from 'happy-dom';
18
+ import type { h as PreactH, render as PreactRender } from 'preact';
19
+ import type { act as PreactAct } from 'preact/test-utils';
20
+ import type { RefObject } from 'preact';
21
+ import type { useIsTruncated as UseIsTruncated } from '@/client/hooks/ui/text-overflow';
22
+
23
+ let useIsTruncated: typeof UseIsTruncated;
24
+ let h: typeof PreactH;
25
+ let render: typeof PreactRender;
26
+ let act: typeof PreactAct;
27
+
28
+ let win: Window;
29
+
30
+ /** Box the hook measures. `clientWidth` is faked, so no layout is needed. */
31
+ class StubResizeObserver {
32
+ static latest: StubResizeObserver | null = null;
33
+ constructor(private readonly callback: () => void) {
34
+ StubResizeObserver.latest = this;
35
+ }
36
+ observe() {}
37
+ unobserve() {}
38
+ disconnect() {}
39
+ fire() { this.callback(); }
40
+ }
41
+
42
+ function installBox(el: HTMLElement, scrollWidth: number, clientWidth: number) {
43
+ Object.defineProperty(el, 'scrollWidth', { get: () => scrollWidth, configurable: true });
44
+ Object.defineProperty(el, 'clientWidth', { get: () => clientWidth, configurable: true });
45
+ }
46
+
47
+ interface ProbeProps {
48
+ content: string;
49
+ seen: { current: boolean | null };
50
+ /** Not named `ref`: Preact strips that key from props. */
51
+ targetRef: RefObject<HTMLSpanElement | null>;
52
+ /** When false the truncated row is not rendered — the expanded state. */
53
+ show?: boolean;
54
+ }
55
+
56
+ function Probe(props: ProbeProps) {
57
+ props.seen.current = useIsTruncated(props.targetRef, props.content);
58
+ if (props.show === false) return h('span', null, 'expanded body');
59
+ return h('span', { ref: props.targetRef, className: 'truncate' }, props.content);
60
+ }
61
+
62
+ async function mount(content: string): Promise<{
63
+ seen: { current: boolean | null };
64
+ ref: RefObject<HTMLSpanElement | null>;
65
+ node: HTMLDivElement;
66
+ el: HTMLSpanElement;
67
+ }> {
68
+ const seen: { current: boolean | null } = { current: null };
69
+ const ref: RefObject<HTMLSpanElement | null> = { current: null };
70
+ const node = document.createElement('div');
71
+ document.body.appendChild(node);
72
+ await act(async () => { render(h(Probe, { content, seen, targetRef: ref }), node); });
73
+ return { seen, ref, node, el: ref.current! };
74
+ }
75
+
76
+ beforeAll(async () => {
77
+ win = new Window({ url: 'http://localhost' });
78
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver'] as const) {
79
+ (globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
80
+ }
81
+ (globalThis as Record<string, unknown>).ResizeObserver = StubResizeObserver;
82
+ // Static imports cannot work here: Preact binds its environment at module
83
+ // evaluation time, so the modules below must load after those DOM globals.
84
+ ({ useIsTruncated } = await import('@/client/hooks/ui/text-overflow'));
85
+ ({ h, render } = await import('preact'));
86
+ ({ act } = await import('preact/test-utils'));
87
+ });
88
+
89
+ afterAll(() => {
90
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver']) {
91
+ delete (globalThis as Record<string, unknown>)[key];
92
+ }
93
+ });
94
+
95
+ describe('useIsTruncated', () => {
96
+ test('reports a clipped line even when it is short', async () => {
97
+ // The regression: 76 characters, clipped on a phone card. A character
98
+ // threshold called this "fits" and the expander was never offered.
99
+ const probe = await mount('Multi-step reasoning: think carefully through the problem before responding.');
100
+ installBox(probe.el, 480, 254);
101
+ await act(async () => { StubResizeObserver.latest!.fire(); });
102
+
103
+ expect(probe.seen.current).toBe(true);
104
+ render(null, probe.node);
105
+ probe.node.remove();
106
+ });
107
+
108
+ test('reports a fitting line as not truncated', async () => {
109
+ const probe = await mount('Late LSP diagnostics arrived after the edit returned:');
110
+ installBox(probe.el, 335, 335);
111
+ await act(async () => { StubResizeObserver.latest!.fire(); });
112
+
113
+ expect(probe.seen.current).toBe(false);
114
+ render(null, probe.node);
115
+ probe.node.remove();
116
+ });
117
+
118
+ test('keeps the last answer when the element has no layout to measure', async () => {
119
+ // An unmeasured element reports 0 for both values, which is not "fits" —
120
+ // treating it as one would retract the expander mid-interaction.
121
+ const probe = await mount('clipped');
122
+ installBox(probe.el, 500, 200);
123
+ await act(async () => { StubResizeObserver.latest!.fire(); });
124
+ expect(probe.seen.current).toBe(true);
125
+
126
+ installBox(probe.el, 0, 0);
127
+ await act(async () => { StubResizeObserver.latest!.fire(); });
128
+ expect(probe.seen.current).toBe(true);
129
+
130
+ render(null, probe.node);
131
+ probe.node.remove();
132
+ });
133
+
134
+ test('keeps its answer when the truncated row is replaced by the expanded body', async () => {
135
+ // Expanding swaps the truncated row out for the body, so the ref detaches
136
+ // while the answer is still the reason the expander is on screen. A reset
137
+ // there would collapse the card the user just opened.
138
+ const probe = await mount('clipped');
139
+ installBox(probe.el, 500, 200);
140
+ await act(async () => { StubResizeObserver.latest!.fire(); });
141
+ expect(probe.seen.current).toBe(true);
142
+
143
+ // Detached AND with new content, so the effect re-runs against a null ref.
144
+ await act(async () => {
145
+ render(h(Probe, { content: 'clipped and expanded', seen: probe.seen, targetRef: probe.ref, show: false }), probe.node);
146
+ });
147
+ expect(probe.seen.current).toBe(true);
148
+
149
+ render(null, probe.node);
150
+ probe.node.remove();
151
+ });
152
+ });