ompchamber 3.1.1 → 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 +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- 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/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -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/hooks/workspace/expanded-sessions.ts +62 -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/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- 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/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- 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/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- 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/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- 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/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- package/dist/client/static/js/index-5yfre1kw.css +0 -1
|
@@ -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.
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
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 {
|
|
@@ -32,6 +32,16 @@ export interface SidebarDataHandle {
|
|
|
32
32
|
initializing: boolean;
|
|
33
33
|
/** Fire a refresh (dedup: while a load is in flight this is a no-op). */
|
|
34
34
|
refresh: () => void;
|
|
35
|
+
/**
|
|
36
|
+
* User-initiated refresh: the same load as `refresh`, but it also raises
|
|
37
|
+
* `refreshing` until THAT load settles, so a toolbar button can spin while
|
|
38
|
+
* it runs. The background paths (idle poll, stream events, status ack) go
|
|
39
|
+
* through `refresh` and deliberately never raise the flag — a spinner that
|
|
40
|
+
* turns on by itself every 30s reads as a broken list.
|
|
41
|
+
*/
|
|
42
|
+
refreshNow: () => void;
|
|
43
|
+
/** True while a `refreshNow` load is in flight. */
|
|
44
|
+
refreshing: boolean;
|
|
35
45
|
/**
|
|
36
46
|
* Mark a session's one-shot terminal badge as seen NOW: strips the check
|
|
37
47
|
* optimistically for this mount and POSTs the server ack. No-op unless the
|
|
@@ -74,6 +84,22 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
|
|
|
74
84
|
};
|
|
75
85
|
}, [fetcher]);
|
|
76
86
|
|
|
87
|
+
// User-initiated refresh. Unlike `refresh` it does NOT skip an in-flight
|
|
88
|
+
// load — a click is an explicit "read it again now", and the fetcher aborts
|
|
89
|
+
// the superseded request rather than letting two answer. The spinner follows
|
|
90
|
+
// THIS load only: a token marks the newest click, so a background poll
|
|
91
|
+
// settling in between cannot stop a spin that is still running, and a
|
|
92
|
+
// superseded click cannot stop the newer one's.
|
|
93
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
94
|
+
const refreshTokenRef = useRef(0);
|
|
95
|
+
const refreshNow = useCallback(() => {
|
|
96
|
+
const token = ++refreshTokenRef.current;
|
|
97
|
+
setRefreshing(true);
|
|
98
|
+
void fetcher.load('/api/sessions/list').finally(() => {
|
|
99
|
+
if (refreshTokenRef.current === token) setRefreshing(false);
|
|
100
|
+
});
|
|
101
|
+
}, [fetcher]);
|
|
102
|
+
|
|
77
103
|
// Idle keep-alive: the throttled stream-event hook and useStreamPoll only
|
|
78
104
|
// fire while THIS client streams, so changes made elsewhere (another tab,
|
|
79
105
|
// a background omp process finishing, an archive from a second browser)
|
|
@@ -142,10 +168,12 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
|
|
|
142
168
|
isMock: data?.isMock ?? false,
|
|
143
169
|
initializing: !hasLoaded,
|
|
144
170
|
refresh,
|
|
171
|
+
refreshNow,
|
|
172
|
+
refreshing,
|
|
145
173
|
markSeen,
|
|
146
174
|
hasSeen: (id) => seen.has(String(id)),
|
|
147
175
|
};
|
|
148
|
-
}, [fetcher.data, initialFolders, hasLoaded, refresh, markSeen]);
|
|
176
|
+
}, [fetcher.data, initialFolders, hasLoaded, refresh, refreshNow, refreshing, markSeen]);
|
|
149
177
|
|
|
150
178
|
return <SidebarDataContext.Provider value={value}>{children}</SidebarDataContext.Provider>;
|
|
151
179
|
}
|
|
@@ -163,6 +191,8 @@ export function useSidebarData(): SidebarDataHandle {
|
|
|
163
191
|
isMock: false,
|
|
164
192
|
initializing: true,
|
|
165
193
|
refresh: () => {},
|
|
194
|
+
refreshNow: () => {},
|
|
195
|
+
refreshing: false,
|
|
166
196
|
markSeen: () => {},
|
|
167
197
|
hasSeen: () => false,
|
|
168
198
|
};
|
|
@@ -51,6 +51,10 @@ export interface SessionSidebarController {
|
|
|
51
51
|
folders: WorkspaceFolderData[];
|
|
52
52
|
initializing: boolean;
|
|
53
53
|
refresh: () => void;
|
|
54
|
+
/** User-initiated refresh — raises `refreshing` until that load settles. */
|
|
55
|
+
refreshNow: () => void;
|
|
56
|
+
/** True while a `refreshNow` load is in flight (drives the toolbar spinner). */
|
|
57
|
+
refreshing: boolean;
|
|
54
58
|
activeSessionId: number | string | null;
|
|
55
59
|
sessionParam: string | null;
|
|
56
60
|
searchQuery: string;
|
|
@@ -71,7 +75,7 @@ export function useSessionSidebarController(
|
|
|
71
75
|
options: SessionSidebarControllerOptions = {},
|
|
72
76
|
): SessionSidebarController {
|
|
73
77
|
const { includePendingSessions = false, onSelectSession, onAfterSelect } = options;
|
|
74
|
-
const { folders, initializing, refresh, markSeen, hasSeen } = useSidebarData();
|
|
78
|
+
const { folders, initializing, refresh, refreshNow, refreshing, markSeen, hasSeen } = useSidebarData();
|
|
75
79
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
76
80
|
const sessionParam = searchParams.get('sessionId');
|
|
77
81
|
const activeSessionId = sessionParam
|
|
@@ -232,6 +236,8 @@ export function useSessionSidebarController(
|
|
|
232
236
|
folders,
|
|
233
237
|
initializing,
|
|
234
238
|
refresh: revalidate,
|
|
239
|
+
refreshNow,
|
|
240
|
+
refreshing,
|
|
235
241
|
activeSessionId,
|
|
236
242
|
sessionParam,
|
|
237
243
|
searchQuery,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import type { ChatMessageData, SubagentInfo } from '@/shared/types';
|
|
4
4
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
5
|
-
import { parseSubagentLifecycle,
|
|
5
|
+
import { parseSubagentLifecycle, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
|
|
6
6
|
import { convertMessages, mergeMessages, requestHistoryPage, requestSubagentPage } from '@/shared/lib/omp/subagent/transcript-client';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -86,6 +86,9 @@ export function useSubagentTranscript(
|
|
|
86
86
|
const nextByteRef = useRef(0);
|
|
87
87
|
const sessionFileRef = useRef<string | undefined>(undefined);
|
|
88
88
|
const readyRef = useRef(false);
|
|
89
|
+
/** False once the live RPC has refused this subagent (its process is gone).
|
|
90
|
+
* Later tail fetches then read the on-disk transcript directly. */
|
|
91
|
+
const rpcAvailableRef = useRef(true);
|
|
89
92
|
const flushingRef = useRef(false);
|
|
90
93
|
const queuedRef = useRef(false);
|
|
91
94
|
const flushTimerRef = useRef<number | null>(null);
|
|
@@ -103,8 +106,25 @@ export function useSubagentTranscript(
|
|
|
103
106
|
flushingRef.current = true;
|
|
104
107
|
const generation = generationRef.current;
|
|
105
108
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
109
|
+
const fromByte = nextByteRef.current;
|
|
110
|
+
// Once the RPC has answered "not managed", stop probing it: the child is
|
|
111
|
+
// gone for the rest of this view's life, and every later tail would pay a
|
|
112
|
+
// refused round-trip before falling back anyway. The disk path below is
|
|
113
|
+
// the same transcript file the RPC was reading.
|
|
114
|
+
let page = rpcAvailableRef.current
|
|
115
|
+
? await requestSubagentPage(sid, active.id, sessionFileRef.current, fromByte)
|
|
116
|
+
: null;
|
|
117
|
+
if (generation !== generationRef.current) return;
|
|
118
|
+
if (!page) {
|
|
119
|
+
// The session's omp process is gone (the child finished, the idle
|
|
120
|
+
// reclaim took it, or the chamber restarted) — the RPC is refused for
|
|
121
|
+
// an unmanaged session rather than respawning one for a read. Continue
|
|
122
|
+
// on disk; without this the view froze on whatever the last live page
|
|
123
|
+
// held, which is exactly the final turns of a just-finished subagent.
|
|
124
|
+
rpcAvailableRef.current = false;
|
|
125
|
+
page = await requestHistoryPage(sid, active.id, fromByte);
|
|
126
|
+
if (generation !== generationRef.current || !page) return;
|
|
127
|
+
}
|
|
108
128
|
sessionFileRef.current = page.sessionFile || sessionFileRef.current;
|
|
109
129
|
const converted = convertMessages(page.messages, true);
|
|
110
130
|
if (page.reset) {
|
|
@@ -163,6 +183,7 @@ export function useSubagentTranscript(
|
|
|
163
183
|
nextByteRef.current = 0;
|
|
164
184
|
sessionFileRef.current = initial?.sessionFile;
|
|
165
185
|
readyRef.current = false;
|
|
186
|
+
rpcAvailableRef.current = true;
|
|
166
187
|
|
|
167
188
|
const scheduleTail = () => {
|
|
168
189
|
if (flushTimerRef.current !== null) return;
|
|
@@ -188,8 +209,10 @@ export function useSubagentTranscript(
|
|
|
188
209
|
if (!isCurrent()) return;
|
|
189
210
|
if (!result && !viaHistory && fromByte === 0) {
|
|
190
211
|
// The parent RPC dropped this subagent (already finished) — the
|
|
191
|
-
// on-disk transcript is authoritative from here on
|
|
212
|
+
// on-disk transcript is authoritative from here on, and the tail
|
|
213
|
+
// fetch must not keep probing a refused RPC.
|
|
192
214
|
viaHistory = true;
|
|
215
|
+
rpcAvailableRef.current = false;
|
|
193
216
|
result = await requestHistoryPage(sessionId, subagentId, 0);
|
|
194
217
|
if (!isCurrent()) return;
|
|
195
218
|
}
|
|
@@ -236,15 +259,18 @@ export function useSubagentTranscript(
|
|
|
236
259
|
if (!detail) return;
|
|
237
260
|
const payload = isRecord(detail.payload) ? detail.payload : null;
|
|
238
261
|
if (!payload) return;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
262
|
+
// `subagent_progress` nests the child's AgentProgress under `progress`
|
|
263
|
+
// (SubagentProgressPayload), and only the NESTED snapshot carries the id
|
|
264
|
+
// plus the resolved model/thinking level — reading the wrapper alone
|
|
265
|
+
// dropped every field it reports.
|
|
266
|
+
const progress = readSubagentProgressFrame(payload);
|
|
267
|
+
if (!progress) return;
|
|
268
|
+
if (progress.id !== undefined) {
|
|
269
|
+
if (progress.id !== subagentId) return;
|
|
270
|
+
} else {
|
|
243
271
|
// Index is only unique within a spawning batch — require the exact match.
|
|
244
|
-
if (
|
|
272
|
+
if (progress.index === undefined || progress.index !== (subagentRef.current?.index ?? -1)) return;
|
|
245
273
|
}
|
|
246
|
-
const progress = parseSubagentProgress(payload);
|
|
247
|
-
if (!progress) return;
|
|
248
274
|
setStatus(prev => {
|
|
249
275
|
if (!prev || prev.id !== subagentId) return prev;
|
|
250
276
|
const next: SubagentInfo = { ...prev, progress: { ...prev.progress, ...progress } };
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import { getLanguageFromPath } from '@/shared/lib/code/
|
|
2
|
+
import { getLanguageFromPath } from '@/shared/lib/code/language';
|
|
3
3
|
import { getImageMimeType } from '@/shared/lib/fs/file-kind';
|
|
4
4
|
import { buildFsRawUrl } from '@/shared/lib/fs/paths';
|
|
5
5
|
import { detectLineEnding, toDiskText, toLf, type LineEnding } from '@/shared/lib/code/line-endings';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useCallback, useState } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
/** Which of a two-pane settings category's screens a phone is showing. */
|
|
4
|
+
export type SettingsPane = 'list' | 'detail';
|
|
5
|
+
|
|
6
|
+
export interface SettingsMasterDetailHandle {
|
|
7
|
+
pane: SettingsPane;
|
|
8
|
+
/** Called by the category's own row handler, never derived from selection. */
|
|
9
|
+
openDetail: () => void;
|
|
10
|
+
back: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pane state for the settings categories that are two panes on a desktop.
|
|
15
|
+
*
|
|
16
|
+
* A phone has no room for a 224px list beside a detail pane: measured on a
|
|
17
|
+
* 412x915 Android viewport, the detail was left 186px wide, and the categories
|
|
18
|
+
* whose container stacked gave it 48px of height. Below `md` the two panes are
|
|
19
|
+
* therefore two screens, and this is which one is up.
|
|
20
|
+
*
|
|
21
|
+
* The pane is opened by an EXPLICIT row choice, never by a selection that
|
|
22
|
+
* merely exists: `useCrudList` selects its first row on load and the provider
|
|
23
|
+
* list falls back to `connectedProviders[0]`, so deriving the pane from
|
|
24
|
+
* `selectedId` would drop the user into a detail pane they never asked for and
|
|
25
|
+
* hide the list they came to browse. Desktop ignores this state entirely —
|
|
26
|
+
* `md:` shows both panes.
|
|
27
|
+
*/
|
|
28
|
+
export function useSettingsMasterDetail(): SettingsMasterDetailHandle {
|
|
29
|
+
const [pane, setPane] = useState<SettingsPane>('list');
|
|
30
|
+
const openDetail = useCallback(() => setPane('detail'), []);
|
|
31
|
+
const back = useCallback(() => setPane('list'), []);
|
|
32
|
+
return { pane, openDetail, back };
|
|
33
|
+
}
|
|
@@ -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
|
+
}
|