ompchamber 3.2.0 → 3.3.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 (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-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-1q6165r3.js → index-qkt4z7x0.js} +640 -640
  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 +134 -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -50,6 +50,7 @@
50
50
  "@fontsource/fira-code": "^5.3.0",
51
51
  "@shikijs/langs": "^4.4.3",
52
52
  "@shikijs/themes": "^4.4.3",
53
+ "@tailwindcss/postcss": "^4.1.14",
53
54
  "@xterm/addon-fit": "^0.11.0",
54
55
  "@xterm/addon-webgl": "^0.19.0",
55
56
  "@xterm/xterm": "^6.0.0",
@@ -60,10 +61,12 @@
60
61
  "marked": "^18.0.11",
61
62
  "marked-linkify-it": "^4.0.2",
62
63
  "mermaid": "^12.0.0",
64
+ "postcss": "^8.5.16",
63
65
  "preact": "^10.29.8",
64
66
  "remend": "^1.3.1",
65
67
  "ripgrep": "^0.3.1",
66
68
  "shiki": "^4.4.3",
69
+ "tailwindcss": "^4.1.14",
67
70
  "yaml": "^2.9.1"
68
71
  },
69
72
  "devDependencies": {
@@ -73,13 +76,11 @@
73
76
  "@semantic-release/github": "^12.0.10",
74
77
  "@semantic-release/npm": "^13.2.0",
75
78
  "@semantic-release/release-notes-generator": "^14.1.1",
76
- "@tailwindcss/postcss": "^4.1.14",
77
79
  "@types/bun": "^1.4.2",
78
80
  "@types/node": "^22.14.0",
79
81
  "conventional-changelog-conventionalcommits": "^9.3.1",
80
82
  "happy-dom": "^20.14.5",
81
83
  "semantic-release": "^25.0.9",
82
- "tailwindcss": "^4.1.14",
83
84
  "typescript": "~5.8.2"
84
85
  }
85
86
  }
package/public/sw.js CHANGED
@@ -1,6 +1,37 @@
1
- const CACHE_NAME = 'ompchamber-v1';
1
+ /**
2
+ * OMPChamber service worker.
3
+ *
4
+ * Two jobs, and only the first one is about installability.
5
+ *
6
+ * 1. Chromium requires a fetch handler for PWA installability.
7
+ *
8
+ * 2. The dev client bundle is cached here rather than by the HTTP cache,
9
+ * because the HTTP cache CANNOT hold it. Chrome declines to store a response
10
+ * whose decoded body reaches ~5 MB — measured with `Cache-Control: no-cache`
11
+ * and a working `ETag`: 1 MB and 4 MB answered `304` on the next load, while
12
+ * 5, 6, 7, 8, 16 and 20 MB were re-fetched in full every time, never sending
13
+ * `If-None-Match`. Bun's development bundle is ~19.7 MB (unminified, by
14
+ * design), so it was re-downloaded on every single page load — the whole of
15
+ * the dev-mode bandwidth complaint. Cache Storage has no such per-entry
16
+ * limit, so this is the one layer that can hold it.
17
+ *
18
+ * Only `/_dev-assets/client/*` is cached: that is the bundle, whose filename
19
+ * carries a build-generation hash that changes on every rebuild, so a URL is a
20
+ * reliable key for its bytes. `/_dev-assets/asset/*` is deliberately NOT
21
+ * cached — a CSS asset keeps its name while its content changes (appending a
22
+ * rule to `tailwind.css` changes the bytes served at the same URL), so
23
+ * cache-first there would serve stale styles; those go to the network and are
24
+ * revalidated by the server's own `ETag`, which answers `304`.
25
+ *
26
+ * One bundle generation is kept at a time: storing a new one drops the rest, so
27
+ * the cache cannot grow by one bundle per rebuild. Nothing here runs in
28
+ * production — the URLs it matches do not exist there.
29
+ */
2
30
 
3
- self.addEventListener('install', (event) => {
31
+ const BUNDLE_CACHE = 'ompchamber-dev-bundle';
32
+ const BUNDLE_PREFIX = '/_dev-assets/client/';
33
+
34
+ self.addEventListener('install', () => {
4
35
  self.skipWaiting();
5
36
  });
6
37
 
@@ -9,7 +40,25 @@ self.addEventListener('activate', (event) => {
9
40
  });
10
41
 
11
42
  self.addEventListener('fetch', (event) => {
12
- // A simple fetch handler is required by Chromium for PWA installability.
13
- // We'll just let network requests pass through normally.
14
- event.respondWith(fetch(event.request).catch(() => caches.match(event.request)));
43
+ const url = new URL(event.request.url);
44
+ if (event.request.method !== 'GET') return;
45
+ if (url.origin !== self.location.origin) return;
46
+ if (!url.pathname.startsWith(BUNDLE_PREFIX)) return;
47
+
48
+ event.respondWith(
49
+ (async () => {
50
+ const cache = await caches.open(BUNDLE_CACHE);
51
+ const hit = await cache.match(url.pathname);
52
+ if (hit) return hit;
53
+
54
+ const response = await fetch(event.request);
55
+ if (response.ok) {
56
+ await cache.put(url.pathname, response.clone());
57
+ // The previous generation is dead the moment a new one is stored.
58
+ const stale = await cache.keys();
59
+ await Promise.all(stale.filter((key) => new URL(key.url).pathname !== url.pathname).map((key) => cache.delete(key)));
60
+ }
61
+ return response;
62
+ })(),
63
+ );
15
64
  });
@@ -6,6 +6,13 @@ import { ImageViewer } from '@/client/components/common/image-viewer';
6
6
  import { EditorHeader } from '@/client/components/mobile/mobile-right-sidebar/Header';
7
7
  import { useScrollbarFade, scrollbarFadeClass } from '@/client/hooks/ui/scrollbar-fade';
8
8
  import { useFileEditor } from '@/client/hooks/editor/use-file-editor';
9
+ import {
10
+ EDITOR_DEFAULT_FONT_SIZE,
11
+ EDITOR_LINE_HEIGHT,
12
+ EDITOR_MAX_FONT_SIZE,
13
+ EDITOR_MIN_FONT_SIZE,
14
+ } from '@/shared/lib/code/editor/typography';
15
+ import { useEditorFontStack } from '@/client/hooks/editor/font';
9
16
 
10
17
  interface MobileFullEditorProps {
11
18
  file: {
@@ -22,9 +29,10 @@ interface MobileFullEditorProps {
22
29
 
23
30
  export function MobileFullEditor({ file, onClose, onFileSaved }: MobileFullEditorProps) {
24
31
  const [isPreview, setIsPreview] = useState(file.name.endsWith('.md'));
25
- const [fontSize, setFontSize] = useState(12);
32
+ const [fontSize, setFontSize] = useState(EDITOR_DEFAULT_FONT_SIZE);
26
33
  const [wordWrap, setWordWrap] = useState(true);
27
34
  const { isScrolling, handleScroll } = useScrollbarFade();
35
+ const editorFontFamily = useEditorFontStack();
28
36
 
29
37
  const isMd = file.name.endsWith('.md');
30
38
 
@@ -52,8 +60,8 @@ export function MobileFullEditor({ file, onClose, onFileSaved }: MobileFullEdito
52
60
  onClose={onClose}
53
61
  onTogglePreview={() => setIsPreview(!isPreview)}
54
62
  onToggleWrap={() => setWordWrap(!wordWrap)}
55
- onZoomOut={() => setFontSize(prev => Math.max(10, prev - 1))}
56
- onZoomIn={() => setFontSize(prev => Math.min(18, prev + 1))}
63
+ onZoomOut={() => setFontSize(prev => Math.max(EDITOR_MIN_FONT_SIZE, prev - 1))}
64
+ onZoomIn={() => setFontSize(prev => Math.min(EDITOR_MAX_FONT_SIZE, prev + 1))}
57
65
  onCopy={editor.copy}
58
66
  onDownload={editor.download}
59
67
  />
@@ -80,7 +88,7 @@ export function MobileFullEditor({ file, onClose, onFileSaved }: MobileFullEdito
80
88
  /* Markdown Preview Mode — styled by `.prose-content` (the chat
81
89
  timeline's system), see the desktop editor for why there is no
82
90
  `prose` wrapper. */
83
- <div className="p-4 bg-paper text-ink min-h-full font-sans">
91
+ <div className="p-4 bg-paper text-ink min-h-full font-sans" style={{ fontSize: `${fontSize}px` }}>
84
92
  <MarkdownRenderer
85
93
  content={content}
86
94
  document
@@ -95,13 +103,24 @@ export function MobileFullEditor({ file, onClose, onFileSaved }: MobileFullEdito
95
103
  language={lang}
96
104
  wordWrap={wordWrap}
97
105
  rootClassName="flex min-h-full bg-paper"
98
- gutterClassName="w-10 py-3 pr-2 select-none text-right text-[10px] text-ink/30 bg-canvas border-r border-ink/10 font-mono leading-[20px] flex-shrink-0"
106
+ // The gutter auto-sizes to its widest number rather than taking a
107
+ // fixed width: the numbers scale with the zoom control, and a
108
+ // pinned `w-10` clipped a 3-digit line number at 18px.
109
+ gutterClassName="flex flex-col text-right pr-2 select-none text-ink/30 bg-canvas border-r border-ink/10 font-mono flex-shrink-0"
110
+ gutterLineClassName="min-w-[1.5rem]"
111
+ gutterStyle={{
112
+ fontSize,
113
+ paddingTop: 12,
114
+ paddingBottom: 12,
115
+ lineHeight: EDITOR_LINE_HEIGHT,
116
+ fontFamily: editorFontFamily,
117
+ }}
99
118
  editorWrapperClassName="flex-1 p-3 overflow-x-auto min-w-0 bg-paper text-ink code-surface"
100
119
  editorClassName="focus:outline-none"
101
120
  editorStyle={{
102
- fontFamily: 'monospace',
103
- fontSize: `${fontSize}px`,
104
- lineHeight: '20px',
121
+ fontFamily: editorFontFamily,
122
+ fontSize,
123
+ lineHeight: EDITOR_LINE_HEIGHT,
105
124
  backgroundColor: 'transparent',
106
125
  minHeight: '100%',
107
126
  color: 'var(--theme-ink)',
@@ -188,6 +188,7 @@ export function MobileSessionCategory({
188
188
  session={session}
189
189
  isActive={isSessionActive && !isViewingSubagent}
190
190
  status={sessionStatus[sessionKey]}
191
+ awaitingInput={Boolean(session.awaitingInput)}
191
192
  timeAgo={relativeTimeAgo(session.updated_at ?? session.created_at)}
192
193
  hasSubagents={hasSubagents}
193
194
  isExpanded={isRosterOpen}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The mobile session row's status slot.
8
+ *
9
+ * The phone must draw the same cue the desktop row does: a session whose agent
10
+ * is blocked on a question shows the question mark, NOT the run spinner. The
11
+ * child is still mid-run while it waits (`streamStatus` stays `stream`), so a
12
+ * row that only knew the stream state drew a spinner over a turn that was
13
+ * actually waiting for the user — the bug this pins.
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
17
+ import { Window } from 'happy-dom';
18
+ import { h, render } from 'preact';
19
+ import { act } from 'preact/test-utils';
20
+ import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
21
+ import type { MobileSessionRowProps } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
22
+ import type { SessionItemData } from '@/shared/types';
23
+
24
+ let container: HTMLElement;
25
+
26
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
27
+
28
+ beforeAll(() => {
29
+ const win = new Window({ url: 'http://localhost' });
30
+ const target = globalThis as unknown as Record<string, unknown>;
31
+ for (const key of DOM_GLOBALS) target[key] = (win as unknown as Record<string, unknown>)[key];
32
+ });
33
+
34
+ afterAll(() => {
35
+ const target = globalThis as unknown as Record<string, unknown>;
36
+ for (const key of DOM_GLOBALS) delete target[key];
37
+ });
38
+
39
+ const session: SessionItemData = { id: 's1', folder_id: 1, title: 'hello world' };
40
+
41
+ async function mount(overrides: Partial<MobileSessionRowProps>) {
42
+ container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+ await act(async () => {
45
+ render(h(MobileSessionRow, {
46
+ session,
47
+ isActive: false,
48
+ timeAgo: '1m',
49
+ onSelect: () => {},
50
+ onArchive: () => {},
51
+ ...overrides,
52
+ }), container);
53
+ });
54
+ return container;
55
+ }
56
+
57
+ function svgClasses(el: HTMLElement) {
58
+ return Array.from(el.querySelectorAll('svg')).map((s) => s.getAttribute('class') ?? '').join('|');
59
+ }
60
+
61
+ describe('mobile session row status slot', () => {
62
+ test('awaiting input beats the stream spinner', async () => {
63
+ const el = await mount({ status: 'stream', awaitingInput: true });
64
+ expect(svgClasses(el)).toContain('circle-question-mark');
65
+ expect(svgClasses(el)).not.toContain('animate-spin');
66
+ });
67
+
68
+ test('stream alone still spins', async () => {
69
+ const el = await mount({ status: 'stream' });
70
+ expect(svgClasses(el)).toContain('animate-spin');
71
+ expect(svgClasses(el)).not.toContain('circle-question-mark');
72
+ });
73
+
74
+ test('terminal badge draws the check', async () => {
75
+ const el = await mount({ status: 'finish' });
76
+ expect(svgClasses(el)).toContain('lucide-check');
77
+ });
78
+
79
+ test('no status draws no glyph', async () => {
80
+ const el = await mount({});
81
+ const slot = el.querySelector('span.w-4');
82
+ expect(slot?.querySelector('svg')).toBeNull();
83
+ });
84
+ });
@@ -1,4 +1,4 @@
1
- import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, Loader2, Pencil } from 'lucide-preact';
1
+ import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
2
2
  import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
4
 
@@ -6,6 +6,8 @@ export interface MobileSessionRowProps {
6
6
  session: SessionItemData;
7
7
  isActive: boolean;
8
8
  status?: 'stream' | 'finish' | 'abort';
9
+ /** The session's agent is blocked on a question until the user answers it. */
10
+ awaitingInput?: boolean;
9
11
  /** Relative age of the session, or null when no usable timestamp exists. */
10
12
  timeAgo: string | null;
11
13
  /** The session has a subagent roster (on disk or live), so it can be expanded. */
@@ -21,6 +23,7 @@ export function MobileSessionRow({
21
23
  session,
22
24
  isActive,
23
25
  status,
26
+ awaitingInput = false,
24
27
  timeAgo,
25
28
  hasSubagents = false,
26
29
  isExpanded = false,
@@ -49,7 +52,6 @@ export function MobileSessionRow({
49
52
  // pinned whenever the row has a roster; a hidden one would leave the roster
50
53
  // unreachable on a phone.
51
54
  const showChevron = hasSubagents && Boolean(onToggleExpand);
52
- const showStatus = Boolean(status);
53
55
  const title = session.title.charAt(0).toUpperCase() + session.title.slice(1);
54
56
 
55
57
  if (isEditing) {
@@ -81,10 +83,17 @@ export function MobileSessionRow({
81
83
  >
82
84
  <div className="flex items-center min-w-0 pr-2">
83
85
  {/* Status slot, 16px at the same x as the folder header's icon slot,
84
- so the left column reads as one line. */}
86
+ so the left column reads as one line. Waiting on an answer
87
+ outranks the run spinner — the spinner says work is happening,
88
+ the question mark says it is YOUR turn (desktop parity). */}
85
89
  <span className="w-4 flex-shrink-0 flex items-center justify-center">
86
- {showStatus && status === 'stream' && <Loader2 size={13} className="text-ink/50 animate-spin" />}
87
- {showStatus && (status === 'finish' || status === 'abort') && <Check size={13} className="text-ink/50" />}
90
+ {awaitingInput ? (
91
+ <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
92
+ ) : status === 'stream' ? (
93
+ <Loader2 size={13} className="text-ink/50 animate-spin" />
94
+ ) : status ? (
95
+ <Check size={13} className="text-ink/50" />
96
+ ) : null}
88
97
  </span>
89
98
  <span className="text-xs truncate leading-snug ml-2">{title}</span>
90
99
  </div>
@@ -6,6 +6,8 @@ import { diffSettings } from '@/shared/lib/settings/diff';
6
6
  import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
7
7
  import { applyDocumentTheme } from '@/client/hooks/ui/theme';
8
8
  import { DEFAULT_THEME_ID } from '@/shared/lib/theme/catalog';
9
+ import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
10
+ import { EDITOR_FONT_CHANGED_EVENT } from '@/client/hooks/editor/font';
9
11
  import { useChamberEvent } from '@/client/hooks/ui/window-event';
10
12
  import { SETTINGS_CATEGORIES, SettingsSidebar } from '@/client/components/settings/Sidebar';
11
13
  import { AppearanceSettings } from '@/client/components/settings/categories/AppearanceSettings';
@@ -33,28 +35,12 @@ export interface SettingsModalProps {
33
35
  }
34
36
 
35
37
  const DEFAULT_SETTINGS: SettingsState = {
36
- binaryPath: '/Users/you/.bun/bin/omp',
37
- showUpdateNotifications: true,
38
- agentControlTool: true,
39
- ompChamberWebTool: true,
40
38
  theme: DEFAULT_THEME_ID,
41
- fontSize: 'standard',
42
- editorFont: 'JetBrains Mono',
43
- streamResponses: true,
39
+ editorFont: EDITOR_DEFAULT_FONT_FAMILY,
44
40
  streamTransport: 'websocket',
45
41
  autoSessionTitle: true,
46
- expandedThinking: true,
47
- detailedToolCalls: false,
48
- notificationsEnabled: true,
49
- buildFailureAlert: true,
50
42
  soundAlerts: true,
51
43
  chatCompletionSound: true,
52
- defaultWorkspacePath: '~/Projects/ompchamber',
53
- tunnelEnabled: true,
54
- tunnelSubdomain: 'omp-dev-preview',
55
- activeProvider: 'claude',
56
- autoApproveSafeCmds: true,
57
- autoPatchErrors: true,
58
44
  followUpBehavior: 'queue',
59
45
  keybindingSend: 'Enter',
60
46
  keybindingNewLine: 'Shift + Enter',
@@ -110,6 +96,13 @@ export function SettingsModal({
110
96
  if (typeof document !== 'undefined' && next.theme) {
111
97
  applyDocumentTheme(next.theme);
112
98
  }
99
+ // The editor font is visual, so an open editor has to hear about the
100
+ // change now — `appSettings` is a boot-time prop and would only show it
101
+ // after a reload. Announced only when the value actually moved, so a
102
+ // re-render of an unrelated setting does not churn every editor's stack.
103
+ if (typeof window !== 'undefined' && next.editorFont !== prev.editorFont) {
104
+ window.dispatchEvent(new CustomEvent(EDITOR_FONT_CHANGED_EVENT, { detail: next.editorFont }));
105
+ }
113
106
  // Only the keys this update actually touched are persisted. The modal
114
107
  // holds a full `SettingsState` snapshot taken when it opened, so writing
115
108
  // the whole object back would also republish every value it read at that
@@ -2,6 +2,7 @@ import { Palette } from 'lucide-preact';
2
2
  import type { SettingsState } from '@/shared/types';
3
3
  import { resolveThemeId } from '@/shared/lib/theme/catalog';
4
4
  import { ThemeGrid } from '@/client/components/settings/categories/appearance-settings/ThemeGrid';
5
+ import { EditorFontSection } from '@/client/components/settings/categories/appearance-settings/EditorFontSection';
5
6
 
6
7
  interface AppearanceSettingsProps {
7
8
  settings: SettingsState;
@@ -22,6 +23,8 @@ export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsPro
22
23
  onSelect={(theme) => onUpdate({ theme: resolveThemeId(theme) })}
23
24
  />
24
25
  </section>
26
+
27
+ <EditorFontSection settings={settings} onUpdate={onUpdate} />
25
28
  </div>
26
29
  );
27
30
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { Check, Type } from 'lucide-preact';
7
+ import type { SettingsState } from '@/shared/types';
8
+ import { EDITOR_FONT_CHOICES } from '@/shared/lib/code/editor/typography';
9
+
10
+ interface EditorFontSectionProps {
11
+ settings: SettingsState;
12
+ onUpdate: (updater: Partial<SettingsState> | ((prev: SettingsState) => SettingsState)) => void;
13
+ }
14
+
15
+ /**
16
+ * The code editor's typeface.
17
+ *
18
+ * Only Fira Code is bundled, so each row states whether the face ships with the
19
+ * app or has to come from the device. That is the difference between a choice
20
+ * that always works and one that silently falls back — and the fallback is
21
+ * visible in the row's own preview, which is set in the face it names.
22
+ */
23
+ export function EditorFontSection({ settings, onUpdate }: EditorFontSectionProps) {
24
+ const active = settings.editorFont || EDITOR_FONT_CHOICES[0].id;
25
+
26
+ return (
27
+ <section className="space-y-3">
28
+ <div>
29
+ <h3 className="font-semibold text-sm uppercase tracking-wider text-ink/80 flex items-center gap-2">
30
+ <Type size={16} className="text-ink/60" />
31
+ Editor Font
32
+ </h3>
33
+ <p className="text-[11px] text-ink/60 mt-1">
34
+ Typeface for the code editor and its line numbers, in both the desktop panel and the phone's
35
+ full-screen editor. Only Fira Code ships with the app; the others are used when your device
36
+ has them, and fall back to Fira Code when it does not.
37
+ </p>
38
+ </div>
39
+
40
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
41
+ {EDITOR_FONT_CHOICES.map((choice) => {
42
+ const selected = active === choice.id;
43
+ return (
44
+ <button
45
+ key={choice.id}
46
+ type="button"
47
+ onClick={() => onUpdate({ editorFont: choice.id })}
48
+ aria-pressed={selected}
49
+ className={`flex items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors ${
50
+ selected ? 'border-ink/40 bg-ink/5' : 'border-ink/12 hover:bg-ink/5'
51
+ }`}
52
+ >
53
+ <span className="min-w-0">
54
+ {/* Preview in the face it names: an uninstalled choice shows its
55
+ own fallback here rather than after the user commits to it. */}
56
+ <span
57
+ className="block truncate text-xs text-ink"
58
+ style={{ fontFamily: `"${choice.id}", monospace` }}
59
+ >
60
+ const greet = () =&gt; 'hi'
61
+ </span>
62
+ <span className="mt-0.5 flex items-center gap-1.5 text-[10px] text-ink/50">
63
+ <span>{choice.label}</span>
64
+ {choice.bundled && (
65
+ <span className="rounded bg-ink/8 px-1 py-px text-[9px] uppercase tracking-wide">
66
+ Bundled
67
+ </span>
68
+ )}
69
+ </span>
70
+ </span>
71
+ {selected && <Check size={14} className="text-ink flex-shrink-0" />}
72
+ </button>
73
+ );
74
+ })}
75
+ </div>
76
+ </section>
77
+ );
78
+ }
@@ -1,8 +1,9 @@
1
- import { useMemo, useState } from 'preact/hooks';
1
+ import { useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { AlertCircle, Bell, Bot, CheckCircle2, ChevronDown, Clock, Info, Layers } from 'lucide-preact';
3
3
  import { isCodeLike } from '@/shared/lib/code/language';
4
4
  import { highlightCode } from '@/shared/lib/code/syntax-highlight';
5
5
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
6
+ import { useIsTruncated } from '@/client/hooks/ui/text-overflow';
6
7
  import { parseTaskNotice } from '@/shared/lib/chat/task-result-parser';
7
8
  import { TaskResultContent } from '@/client/components/workspace/chat-timeline/TaskResultContent';
8
9
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
@@ -60,12 +61,17 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
60
61
  }, [notice]);
61
62
 
62
63
  const rawTitle = taskNotice?.intro || reminderInfo?.title || genericInfo.firstLine || notice;
63
- const isTruncated = rawTitle.length > 100;
64
- const displayTitle = rawTitle.slice(0, 100) + (isTruncated ? '...' : '');
64
+ // The subtitle is clipped by CSS at the card's own width, so the decision to
65
+ // offer an expander is a MEASUREMENT, not a character count: 76 characters
66
+ // fit a desktop timeline and are cut on a phone card. A char threshold left
67
+ // those rows showing an ellipsis with nothing to click (the chevron was
68
+ // dropped with the button disabled). Measured on the exact node that carries
69
+ // `truncate`, so it cannot disagree with the ellipsis the user sees.
70
+ const subtitleRef = useRef<HTMLSpanElement>(null);
71
+ const subtitleClipped = useIsTruncated(subtitleRef, rawTitle);
65
72
 
66
- // If notice has only 1 line of content AND does not exceed the 100-character ellipsis limit,
67
- // disable expand since the entire text is already fully visible in the sub-header.
68
- // If it exceeds the ellipsis limit (>100 chars) or has multiple lines, keep expand active.
73
+ // Expandable when the subtitle is visually clipped, or when the body holds
74
+ // something the one-line header cannot show (more lines, a task-result card).
69
75
  const isExpandable = useMemo(() => {
70
76
  if (taskNotice) return true;
71
77
  const clean = notice
@@ -75,8 +81,8 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
75
81
  .split(/\r?\n/)
76
82
  .map((l) => l.trim())
77
83
  .filter(Boolean);
78
- return lines.length > 1 || clean.length > 100 || rawTitle.length > 100;
79
- }, [taskNotice, notice, rawTitle]);
84
+ return lines.length > 1 || subtitleClipped;
85
+ }, [taskNotice, notice, subtitleClipped]);
80
86
 
81
87
  const noticeStyle = useMemo(() => {
82
88
  if (taskNotice) {
@@ -169,9 +175,9 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
169
175
  </span>
170
176
  )}
171
177
  </span>
172
- {!isOpen && displayTitle && (
173
- <span className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
174
- {displayTitle}
178
+ {!isOpen && rawTitle && (
179
+ <span ref={subtitleRef} className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
180
+ {rawTitle}
175
181
  </span>
176
182
  )}
177
183
  </span>
@@ -10,6 +10,13 @@ import { EditorToolbar } from '@/client/components/workspace/editor/Toolbar';
10
10
  import { FindWidget } from '@/client/components/workspace/editor/FindWidget';
11
11
  import { CommandPalette } from '@/client/components/workspace/editor/CommandPalette';
12
12
  import { EDITOR_KEY_BINDINGS, isFindBarCommand, type EditorCommand } from '@/shared/lib/code/editor/keymap';
13
+ import {
14
+ EDITOR_DEFAULT_FONT_SIZE,
15
+ EDITOR_LINE_HEIGHT,
16
+ EDITOR_MAX_FONT_SIZE,
17
+ EDITOR_MIN_FONT_SIZE,
18
+ } from '@/shared/lib/code/editor/typography';
19
+ import { useEditorFontStack } from '@/client/hooks/editor/font';
13
20
  import type { TextRange } from '@/shared/lib/code/editor/commands';
14
21
  import { resolveBinding } from '@/shared/lib/ui/key-binding';
15
22
  import { ImageViewer } from '@/client/components/common/image-viewer';
@@ -45,7 +52,7 @@ export function Editor({
45
52
  const activeFile = openedFiles.find(f => f.id === activeFileId);
46
53
 
47
54
  const [previewMode, setPreviewMode, previewReady] = useSessionState<Record<string | number, boolean>>('editor.previewMode', {});
48
- const [zoomLevel, setZoomLevel] = useSessionState<number>('editor.zoomLevel', 12);
55
+ const [zoomLevel, setZoomLevel] = useSessionState<number>('editor.zoomLevel', EDITOR_DEFAULT_FONT_SIZE);
49
56
  const [isMaximized, setIsMaximized] = useState(false);
50
57
  const [wordWrap, setWordWrap] = useSessionState<boolean>('editor.wordWrap', true);
51
58
  /** Extra ⌘D ranges; the surface paints them and the editor edits them by replication. */
@@ -54,6 +61,7 @@ export function Editor({
54
61
  const { sessionId } = useSessionStateContext();
55
62
  const { isScrolling, handleScroll } = useScrollbarFade();
56
63
  const surfaceRef = useRef<CodeSurfaceHandle | null>(null);
64
+ const editorFontFamily = useEditorFontStack();
57
65
 
58
66
  const editor = useFileEditor(activeFile ?? null, {
59
67
  // A file that could not be read must report the failure, not substitute
@@ -233,8 +241,8 @@ export function Editor({
233
241
  onSave={editor.saveNow}
234
242
  onTogglePreview={togglePreview}
235
243
  onToggleWordWrap={() => setWordWrap(!wordWrap)}
236
- onZoomIn={() => setZoomLevel(z => Math.min(24, z + 1))}
237
- onZoomOut={() => setZoomLevel(z => Math.max(8, z - 1))}
244
+ onZoomIn={() => setZoomLevel(z => Math.min(EDITOR_MAX_FONT_SIZE, z + 1))}
245
+ onZoomOut={() => setZoomLevel(z => Math.max(EDITOR_MIN_FONT_SIZE, z - 1))}
238
246
  onCopy={editor.copy}
239
247
  onDownload={editor.download}
240
248
  onToggleMaximize={() => setIsMaximized(!isMaximized)}
@@ -295,8 +303,8 @@ export function Editor({
295
303
  fontSize: zoomLevel,
296
304
  paddingTop: 16,
297
305
  paddingBottom: 16,
298
- lineHeight: 1.5,
299
- fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
306
+ lineHeight: EDITOR_LINE_HEIGHT,
307
+ fontFamily: editorFontFamily,
300
308
  }}
301
309
  gutterLineClassName="min-w-[1.5rem]"
302
310
  // `min-w-max` keeps a long line intact and lets the panel
@@ -306,9 +314,9 @@ export function Editor({
306
314
  editorPadding={16}
307
315
  editorClassName="font-mono focus:outline-none"
308
316
  editorStyle={{
309
- fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
317
+ fontFamily: editorFontFamily,
310
318
  fontSize: zoomLevel,
311
- lineHeight: 1.5,
319
+ lineHeight: EDITOR_LINE_HEIGHT,
312
320
  minHeight: '100%',
313
321
  // Breathing room under the last line. It has to live on the
314
322
  // surface, not on the scroll container: a scroll container's