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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +239 -230
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-1q6165r3.js → index-qkt4z7x0.js} +640 -640
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
- package/src/client/components/settings/Modal.tsx +10 -17
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -0
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- package/src/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- package/src/shared/lib/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- package/src/shared/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/settings/state.ts +7 -16
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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(
|
|
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(
|
|
56
|
-
onZoomIn={() => setFontSize(prev => Math.min(
|
|
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
|
-
|
|
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:
|
|
103
|
-
fontSize
|
|
104
|
-
lineHeight:
|
|
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
|
-
{
|
|
87
|
-
|
|
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
|
-
|
|
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 = () => '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
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
67
|
-
//
|
|
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 ||
|
|
79
|
-
}, [taskNotice, notice,
|
|
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 &&
|
|
173
|
-
<span className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
|
|
174
|
-
{
|
|
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',
|
|
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(
|
|
237
|
-
onZoomOut={() => setZoomLevel(z => Math.max(
|
|
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:
|
|
299
|
-
fontFamily:
|
|
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:
|
|
317
|
+
fontFamily: editorFontFamily,
|
|
310
318
|
fontSize: zoomLevel,
|
|
311
|
-
lineHeight:
|
|
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
|