ompchamber 1.0.0 → 1.2.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/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +35 -9
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +40 -4
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The priming contract, which is a TIMING contract.
|
|
8
|
+
*
|
|
9
|
+
* A dropped `File` may only be read from a read begun while the drop handler is
|
|
10
|
+
* still running; a read started afterwards fails with `NotReadableError` and
|
|
11
|
+
* keeps failing. These tests pin the two halves of that: `primeFileReads`
|
|
12
|
+
* issues its read synchronously, and the read pipeline consumes the primed
|
|
13
|
+
* result instead of starting a second, unauthorized one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
17
|
+
import { primeFileReads, readFileTextWithRetry } from '@/client/hooks/chat/composer/file-reads';
|
|
18
|
+
|
|
19
|
+
/** A `FileReader` that answers from a lookup, counting the reads it was asked for. */
|
|
20
|
+
function installFakeReader(
|
|
21
|
+
contents: Map<string, string>,
|
|
22
|
+
options: { failDataUrl?: boolean; unreadable?: Set<File> } = {},
|
|
23
|
+
) {
|
|
24
|
+
const calls: string[] = [];
|
|
25
|
+
/** A file the browser refuses to read, whatever its metadata says. */
|
|
26
|
+
const dead = (file: File) => options.unreadable?.has(file) ?? false;
|
|
27
|
+
class FakeReader {
|
|
28
|
+
result: string | ArrayBuffer | null = null;
|
|
29
|
+
error: { name: string; message: string } | null = null;
|
|
30
|
+
onload: (() => void) | null = null;
|
|
31
|
+
onerror: (() => void) | null = null;
|
|
32
|
+
onabort: (() => void) | null = null;
|
|
33
|
+
|
|
34
|
+
readAsArrayBuffer() {
|
|
35
|
+
calls.push('arrayBuffer');
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (options.failDataUrl) {
|
|
38
|
+
this.error = { name: 'NotReadableError', message: 'unreadable' };
|
|
39
|
+
this.onerror?.();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.result = new Uint8Array([1, 2, 3]).buffer;
|
|
43
|
+
this.onload?.();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
readAsDataURL(file: File) {
|
|
48
|
+
calls.push(`dataURL:${file.name}`);
|
|
49
|
+
const body = dead(file) ? undefined : contents.get(file.name);
|
|
50
|
+
queueMicrotask(() => {
|
|
51
|
+
if (options.failDataUrl || body === undefined) {
|
|
52
|
+
this.error = {
|
|
53
|
+
name: 'NotReadableError',
|
|
54
|
+
message: 'permission problems that have occurred after a reference to a file was acquired',
|
|
55
|
+
};
|
|
56
|
+
this.onerror?.();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// `data:<mime>;base64,<payload>` — the real reader's shape.
|
|
60
|
+
const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(body)));
|
|
61
|
+
this.result = `data:text/plain;base64,${base64}`;
|
|
62
|
+
this.onload?.();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
readAsText(file: File) {
|
|
67
|
+
calls.push(`text:${file.name}`);
|
|
68
|
+
const body = dead(file) ? undefined : contents.get(file.name);
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
if (body === undefined) {
|
|
71
|
+
this.error = { name: 'NotReadableError', message: 'unreadable' };
|
|
72
|
+
this.onerror?.();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.result = body;
|
|
76
|
+
this.onload?.();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
(globalThis as unknown as { FileReader: unknown }).FileReader = FakeReader;
|
|
81
|
+
return calls;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const originalFileReader = (globalThis as unknown as { FileReader: unknown }).FileReader;
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
(globalThis as unknown as { FileReader: unknown }).FileReader = originalFileReader;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('primeFileReads', () => {
|
|
91
|
+
test('starts the read synchronously, in the caller\'s own tick', () => {
|
|
92
|
+
const calls = installFakeReader(new Map([['a.md', 'body']]));
|
|
93
|
+
const file = new File(['body'], 'a.md', { type: 'text/markdown' });
|
|
94
|
+
|
|
95
|
+
primeFileReads([file]);
|
|
96
|
+
|
|
97
|
+
// The point of the module: the calls are already made when this returns. A
|
|
98
|
+
// read deferred to a microtask would be outside the drop handler's tick and
|
|
99
|
+
// therefore unauthorized.
|
|
100
|
+
expect(calls.filter((c) => c.startsWith('dataURL:'))).toEqual(['dataURL:a.md']);
|
|
101
|
+
expect(calls.filter((c) => c === 'arrayBuffer').length).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('reads each file once however many batches carry it', () => {
|
|
105
|
+
const calls = installFakeReader(new Map([['a.md', 'body']]));
|
|
106
|
+
const file = new File(['body'], 'a.md', { type: 'text/markdown' });
|
|
107
|
+
|
|
108
|
+
primeFileReads([file, file]);
|
|
109
|
+
|
|
110
|
+
expect(calls.filter((c) => c.startsWith('dataURL:'))).toEqual(['dataURL:a.md']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('stays inside the attach cap, so an oversized drop is not read whole', () => {
|
|
114
|
+
const calls = installFakeReader(new Map());
|
|
115
|
+
const files = Array.from({ length: 80 }, (_, i) => new File(['x'], `f${i}.txt`));
|
|
116
|
+
|
|
117
|
+
primeFileReads(files);
|
|
118
|
+
|
|
119
|
+
expect(calls.filter((c) => c.startsWith('dataURL:')).length).toBeLessThanOrEqual(50);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('a drop that exposes one file as two objects', () => {
|
|
124
|
+
// A drop can hand over the same file twice — once in `files`, once in the item
|
|
125
|
+
// list — and only one of the two objects actually reads. Which one is not
|
|
126
|
+
// knowable up front: the same drop read fine in a browser tab and failed in an
|
|
127
|
+
// installed app window, with the unreadable object still reporting the right
|
|
128
|
+
// name and size. So the read path tries the counterpart instead of trusting
|
|
129
|
+
// the choice.
|
|
130
|
+
test('falls back to the counterpart object when the primary will not read', async () => {
|
|
131
|
+
const primary = new File(['x'.repeat(40)], 'notes.md', { type: 'text/markdown' });
|
|
132
|
+
const counterpart = new File(['rescued body'], 'notes.md', { type: 'text/markdown' });
|
|
133
|
+
// The primary refuses to read while declaring bytes; the counterpart reads.
|
|
134
|
+
const calls = installFakeReader(
|
|
135
|
+
new Map([['notes.md', 'rescued body']]),
|
|
136
|
+
{ unreadable: new Set([primary]) },
|
|
137
|
+
);
|
|
138
|
+
const primed = primeFileReads([primary], new Map([[primary, counterpart]]));
|
|
139
|
+
|
|
140
|
+
const content = await readFileTextWithRetry(primary, primed);
|
|
141
|
+
|
|
142
|
+
expect(content).toBe('rescued body');
|
|
143
|
+
expect(calls.filter((c) => c.startsWith('dataURL:')).length).toBe(2);
|
|
144
|
+
|
|
145
|
+
// Not vacuous: with no counterpart primed the same file stays unreadable,
|
|
146
|
+
// which is exactly the failure this pairing exists to prevent.
|
|
147
|
+
const alone = primeFileReads([primary]);
|
|
148
|
+
expect(await readFileTextWithRetry(primary, alone)).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('the counterpart is primed in the same tick, not read on demand', () => {
|
|
152
|
+
const primary = new File(['x'], 'a.md', { type: 'text/markdown' });
|
|
153
|
+
const counterpart = new File(['y'], 'a.md', { type: 'text/markdown' });
|
|
154
|
+
const calls = installFakeReader(new Map());
|
|
155
|
+
|
|
156
|
+
primeFileReads([primary], new Map([[primary, counterpart]]));
|
|
157
|
+
|
|
158
|
+
// Both reads must already be in flight when the call returns: one started
|
|
159
|
+
// later would be outside the drop's permission window.
|
|
160
|
+
expect(calls.filter((c) => c.startsWith('dataURL:')).length).toBe(2);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('readFileTextWithRetry with a primed read', () => {
|
|
165
|
+
test('returns the primed body without starting another read', async () => {
|
|
166
|
+
const calls = installFakeReader(new Map([['a.md', 'primed body']]));
|
|
167
|
+
const file = new File(['primed body'], 'a.md', { type: 'text/markdown' });
|
|
168
|
+
const primed = primeFileReads([file]);
|
|
169
|
+
|
|
170
|
+
const content = await readFileTextWithRetry(file, primed);
|
|
171
|
+
|
|
172
|
+
expect(content).toBe('primed body');
|
|
173
|
+
// Exactly one read: a second would be the unauthorized one, and its failure
|
|
174
|
+
// would be indistinguishable from an unreadable file.
|
|
175
|
+
expect(calls.filter((c) => c.startsWith('dataURL:'))).toEqual(['dataURL:a.md']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('falls back to its own reads when the file was never primed', async () => {
|
|
179
|
+
const calls = installFakeReader(new Map([['b.md', 'late body']]));
|
|
180
|
+
const file = new File(['late body'], 'b.md', { type: 'text/markdown' });
|
|
181
|
+
|
|
182
|
+
const content = await readFileTextWithRetry(file);
|
|
183
|
+
|
|
184
|
+
expect(content).toBe('late body');
|
|
185
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('retries a primed read that came back empty, for a promised file', async () => {
|
|
189
|
+
// The primed read lands before the bytes do; the retry loop is what covers
|
|
190
|
+
// that, so it must not be short-circuited by the primed attempt.
|
|
191
|
+
const calls = installFakeReader(new Map());
|
|
192
|
+
const file = new File(['x'.repeat(100)], 'promised.md', { type: 'text/markdown' });
|
|
193
|
+
const primed = primeFileReads([file]);
|
|
194
|
+
|
|
195
|
+
const content = await readFileTextWithRetry(file, primed);
|
|
196
|
+
|
|
197
|
+
// Null, not '': the file DECLARES bytes, so an empty body is a failure the
|
|
198
|
+
// caller reports rather than a genuinely empty file it accepts.
|
|
199
|
+
expect(content).toBeNull();
|
|
200
|
+
expect(calls.filter((c) => c === 'text:promised.md').length).toBeGreaterThan(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('accepts an empty body for a file that declares no bytes', async () => {
|
|
204
|
+
// A genuinely empty file is empty forever; retrying it cannot change that,
|
|
205
|
+
// so it settles as empty rather than as a failure.
|
|
206
|
+
installFakeReader(new Map());
|
|
207
|
+
const file = new File([], 'empty.md', { type: 'text/markdown' });
|
|
208
|
+
|
|
209
|
+
expect(await readFileTextWithRetry(file, primeFileReads([file]))).toBe('');
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reading a dropped `File`'s bytes — and the one browser rule that makes the
|
|
8
|
+
* TIMING of that read part of the contract.
|
|
9
|
+
*
|
|
10
|
+
* A file that arrives through a drop is not an ordinary `Blob`. The browser
|
|
11
|
+
* holds a permission reference to the real file, and that reference is released
|
|
12
|
+
* when the `drop` event's handler returns. A read started after that point
|
|
13
|
+
* fails with `NotReadableError: ... permission problems that have occurred
|
|
14
|
+
* after a reference to a file was acquired`, and it fails PERMANENTLY — every
|
|
15
|
+
* retry after it fails the same way, because the reference is gone rather than
|
|
16
|
+
* busy. This is why a dropped file showed a chip with a correct name and size
|
|
17
|
+
* while its contents never arrived, and why three retries did not help.
|
|
18
|
+
*
|
|
19
|
+
* `FileReader.readAs*` checks the permission when it is CALLED, not when it
|
|
20
|
+
* completes. So the read must be STARTED synchronously inside the drop handler;
|
|
21
|
+
* awaiting its result later is fine. `primeFileReads` is that start, and every
|
|
22
|
+
* later consumer takes the already-running read rather than beginning its own.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/** How many times a read that came back empty is retried before giving up. */
|
|
27
|
+
export const READ_ATTEMPTS = 3;
|
|
28
|
+
|
|
29
|
+
/** Base delay between read attempts; a promised file needs a beat to land. */
|
|
30
|
+
export const READ_RETRY_MS = 60;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How many files a single drop primes up front. Matches `MAX_DROPPED_FILES`:
|
|
34
|
+
* priming beyond what can be attached would hold bytes the budget will reject.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_PRIMED_FILES = 50;
|
|
37
|
+
|
|
38
|
+
export function delay(ms: number): Promise<void> {
|
|
39
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
40
|
+
setTimeout(resolve, ms);
|
|
41
|
+
return promise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start a data-URL read and return the still-running promise.
|
|
46
|
+
*
|
|
47
|
+
* MUST be called synchronously from the drop handler — see the module note. The
|
|
48
|
+
* result is the whole `data:<mime>;base64,<payload>` string, because the two
|
|
49
|
+
* consumers want different halves of it: an image takes the payload, a text file
|
|
50
|
+
* decodes it back to text.
|
|
51
|
+
*/
|
|
52
|
+
function startDataUrlRead(file: File): Promise<string | null> {
|
|
53
|
+
const { promise, resolve } = Promise.withResolvers<string | null>();
|
|
54
|
+
const reader = new FileReader();
|
|
55
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null);
|
|
56
|
+
reader.onerror = () => {
|
|
57
|
+
resolve(null);
|
|
58
|
+
};
|
|
59
|
+
reader.onabort = () => resolve(null);
|
|
60
|
+
reader.readAsDataURL(file);
|
|
61
|
+
return promise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A read already in flight, started while the drop's permission was live.
|
|
66
|
+
*
|
|
67
|
+
* `dataUrl` and `prefix` are the PRIMARY file's reads. `spare` holds the reads
|
|
68
|
+
* of the same drop's other `File` object, tried when the primary yields nothing:
|
|
69
|
+
* a drop can expose one file twice and only one of the two objects reads, with
|
|
70
|
+
* neither the metadata nor the context saying which. See `drop-entries.ts`.
|
|
71
|
+
*/
|
|
72
|
+
export interface PrimedRead {
|
|
73
|
+
/** The whole file as a data URL — the authorized read of a dropped file. */
|
|
74
|
+
dataUrl: Promise<string | null>;
|
|
75
|
+
/**
|
|
76
|
+
* The leading bytes, for the text sniff and the empty-stub check.
|
|
77
|
+
*
|
|
78
|
+
* Those two ask a `File` for bytes through `slice().arrayBuffer()`, which is
|
|
79
|
+
* subject to the same permission as `FileReader` — so they have to be served
|
|
80
|
+
* from a read started in the drop's tick as well, not started on demand.
|
|
81
|
+
*/
|
|
82
|
+
prefix: Promise<Uint8Array | null>;
|
|
83
|
+
/** The counterpart object's reads, when the payload exposed the file twice. */
|
|
84
|
+
spare?: PrimedRead;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type PrimedReads = Map<File, PrimedRead>;
|
|
88
|
+
|
|
89
|
+
/** Bytes sampled to decide whether an unclassified file is text. */
|
|
90
|
+
const PREFIX_BYTES = 4096;
|
|
91
|
+
|
|
92
|
+
function startPrefixRead(file: File): Promise<Uint8Array | null> {
|
|
93
|
+
const { promise, resolve } = Promise.withResolvers<Uint8Array | null>();
|
|
94
|
+
const reader = new FileReader();
|
|
95
|
+
reader.onload = () => resolve(reader.result instanceof ArrayBuffer ? new Uint8Array(reader.result) : null);
|
|
96
|
+
reader.onerror = () => resolve(null);
|
|
97
|
+
reader.onabort = () => resolve(null);
|
|
98
|
+
reader.readAsArrayBuffer(file.slice(0, PREFIX_BYTES));
|
|
99
|
+
return promise;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Begin reading every file NOW, in the drop handler's own tick.
|
|
104
|
+
*
|
|
105
|
+
* Returns a lookup the later async pipeline consults instead of reading the
|
|
106
|
+
* `File` itself. Files that were not primed (a directory walk discovers its
|
|
107
|
+
* children after the tick) simply have no entry and fall back to a fresh read.
|
|
108
|
+
*/
|
|
109
|
+
export function primeFileReads(files: readonly File[], fallbacks?: Map<File, File>): PrimedReads {
|
|
110
|
+
const primed: PrimedReads = new Map();
|
|
111
|
+
// Bounded by the same cap the attach path applies: a read is buffered as a
|
|
112
|
+
// data URL, so priming a thousand-file drop would hold every byte twice
|
|
113
|
+
// before the budget had a chance to reject any of them.
|
|
114
|
+
for (const file of files.slice(0, MAX_PRIMED_FILES)) {
|
|
115
|
+
if (primed.has(file)) continue;
|
|
116
|
+
const counterpart = fallbacks?.get(file);
|
|
117
|
+
primed.set(file, {
|
|
118
|
+
dataUrl: startDataUrlRead(file),
|
|
119
|
+
prefix: startPrefixRead(file),
|
|
120
|
+
// Primed in the same tick for the same reason: a fallback whose own read
|
|
121
|
+
// starts after the drop window closes is not authorized either.
|
|
122
|
+
...(counterpart
|
|
123
|
+
? { spare: { dataUrl: startDataUrlRead(counterpart), prefix: startPrefixRead(counterpart) } }
|
|
124
|
+
: {}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return primed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The primed leading bytes, or `undefined` when the file was never primed.
|
|
132
|
+
*
|
|
133
|
+
* An empty array means the read was attempted and yielded nothing — which is
|
|
134
|
+
* also what a genuine empty file gives, and what the sniff should see.
|
|
135
|
+
*/
|
|
136
|
+
export async function primedPrefix(file: File, primed?: PrimedReads): Promise<Uint8Array | undefined> {
|
|
137
|
+
const started = primed?.get(file);
|
|
138
|
+
if (!started) return undefined;
|
|
139
|
+
const bytes = await started.prefix;
|
|
140
|
+
if (bytes && bytes.byteLength > 0) return bytes;
|
|
141
|
+
if (started.spare) {
|
|
142
|
+
const spare = await started.spare.prefix;
|
|
143
|
+
if (spare && spare.byteLength > 0) return spare;
|
|
144
|
+
}
|
|
145
|
+
return bytes ?? new Uint8Array(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The base64 payload of a data URL, or null when there is none. */
|
|
149
|
+
export function base64Of(dataUrl: string | null): string | null {
|
|
150
|
+
if (dataUrl === null) return null;
|
|
151
|
+
const comma = dataUrl.indexOf(',');
|
|
152
|
+
return comma === -1 ? null : dataUrl.slice(comma + 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Decode a data URL's payload back to text, or null when it cannot be decoded.
|
|
157
|
+
*
|
|
158
|
+
* Null covers both "no payload" and "payload is not text": a dropped binary
|
|
159
|
+
* arrives through the same data-URL read, and its bytes are not a UTF-8 string.
|
|
160
|
+
*/
|
|
161
|
+
export function textOf(dataUrl: string | null): string | null {
|
|
162
|
+
const base64 = base64Of(dataUrl);
|
|
163
|
+
if (base64 === null) return null;
|
|
164
|
+
try {
|
|
165
|
+
const binary = atob(base64);
|
|
166
|
+
const bytes = new Uint8Array(binary.length);
|
|
167
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
168
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Read a File as base64, or null when the read fails. */
|
|
175
|
+
export async function readAsDataUrl(file: File, primed?: PrimedReads): Promise<string | null> {
|
|
176
|
+
const started = primed?.get(file);
|
|
177
|
+
if (started) {
|
|
178
|
+
const payload = base64Of(await started.dataUrl);
|
|
179
|
+
if (payload !== null) return payload;
|
|
180
|
+
// The primary object did not read; the drop's other object for the same file
|
|
181
|
+
// may. One of the two is unreadable in a given context and it is not the one
|
|
182
|
+
// the metadata suggests.
|
|
183
|
+
if (started.spare) return base64Of(await started.spare.dataUrl);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return base64Of(await startDataUrlRead(file));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Read a File as text through `readAsText`, or null when the read fails. */
|
|
190
|
+
function readAsTextDirect(file: File): Promise<string | null> {
|
|
191
|
+
const { promise, resolve } = Promise.withResolvers<string | null>();
|
|
192
|
+
const reader = new FileReader();
|
|
193
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null);
|
|
194
|
+
reader.onerror = () => {
|
|
195
|
+
resolve(null);
|
|
196
|
+
};
|
|
197
|
+
reader.onabort = () => resolve(null);
|
|
198
|
+
reader.readAsText(file);
|
|
199
|
+
return promise;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read a File's text, preferring a read that was already started.
|
|
204
|
+
*
|
|
205
|
+
* The primed read is the one that matters: it is the only one the browser is
|
|
206
|
+
* guaranteed to authorize for a dropped file (see the module note), so it is
|
|
207
|
+
* consulted first and a non-empty body from it ends the call. The loop behind it
|
|
208
|
+
* exists for a PROMISED file — another app's drag, whose bytes land a moment
|
|
209
|
+
* after the entry exists — and for a file that was never primed at all.
|
|
210
|
+
*
|
|
211
|
+
* Returns null when no attempt produced a body for a file that declares bytes.
|
|
212
|
+
*/
|
|
213
|
+
export async function readFileTextWithRetry(file: File, primed?: PrimedReads): Promise<string | null> {
|
|
214
|
+
const started = primed?.get(file);
|
|
215
|
+
if (started) {
|
|
216
|
+
const content = textOf(await started.dataUrl);
|
|
217
|
+
if (content !== null && content.length > 0) return content;
|
|
218
|
+
if (started.spare) {
|
|
219
|
+
const spare = textOf(await started.spare.dataUrl);
|
|
220
|
+
if (spare !== null && spare.length > 0) return spare;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let last: string | null = null;
|
|
225
|
+
for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
|
|
226
|
+
last = await readAsTextDirect(file);
|
|
227
|
+
if (last === null || last.length === 0) {
|
|
228
|
+
const viaDataUrl = textOf(await startDataUrlRead(file));
|
|
229
|
+
if (viaDataUrl !== null && viaDataUrl.length > 0) return viaDataUrl;
|
|
230
|
+
}
|
|
231
|
+
if (last !== null && last.length > 0) return last;
|
|
232
|
+
// An empty body is the real answer once the file has had a beat to
|
|
233
|
+
// materialize AND declares no bytes of its own.
|
|
234
|
+
if (file.size === 0 && attempt > 0) return last ?? '';
|
|
235
|
+
await delay(READ_RETRY_MS);
|
|
236
|
+
}
|
|
237
|
+
return last;
|
|
238
|
+
}
|
|
@@ -2,7 +2,7 @@ import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from '
|
|
|
2
2
|
import type { ChangeEvent, RefObject } from 'preact/compat';
|
|
3
3
|
import type { TargetedKeyboardEvent } from 'preact';
|
|
4
4
|
import type { ComposerMatchItem, ComposerPickItem, ComposerTrigger } from '@/shared/types';
|
|
5
|
-
import { detectComposerTrigger, insertToken, tokenForItem } from '@/shared/lib/chat/composer/trigger';
|
|
5
|
+
import { detectComposerTrigger, insertToken, sendInsteadOfAccept, tokenForItem } from '@/shared/lib/chat/composer/trigger';
|
|
6
6
|
import { filterComposerItems } from '@/shared/lib/chat/composer/filter';
|
|
7
7
|
import { useComposerItems } from '@/client/hooks/chat/composer/items';
|
|
8
8
|
|
|
@@ -42,7 +42,18 @@ function isComposingInput(native: Event): boolean {
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Whether the popup may open for this trigger. oh-my-pi only shows the command
|
|
47
|
+
* popup once the slash token has a real match — a bare `/`, a `/tmp/fo` path,
|
|
48
|
+
* or a completed `/retry ` must not cover the composer with an empty list.
|
|
49
|
+
* Mentions keep their own rule: the empty state tells the user what `@` accepts.
|
|
50
|
+
*/
|
|
51
|
+
function shouldOpen(trigger: ComposerTrigger | null, matches: ComposerMatchItem[]): boolean {
|
|
52
|
+
if (!trigger) return false;
|
|
53
|
+
return trigger.kind === 'mention' || matches.length > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Orchestrates the composer `@`-mention / `/`-command autocomplete flow. */
|
|
46
57
|
export function useComposerTrigger(options: UseComposerTriggerOptions): ComposerTriggerController {
|
|
47
58
|
const { value, setValue, textareaRef, disabled = false, rootPath } = options;
|
|
48
59
|
|
|
@@ -54,8 +65,8 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
54
65
|
const { items, loading, error } = useComposerItems(trigger?.kind ?? null, rootPath ?? null);
|
|
55
66
|
|
|
56
67
|
const matches = useMemo(
|
|
57
|
-
() => filterComposerItems(items, trigger
|
|
58
|
-
[items, trigger
|
|
68
|
+
() => (trigger ? filterComposerItems(items, trigger) : []),
|
|
69
|
+
[items, trigger],
|
|
59
70
|
);
|
|
60
71
|
|
|
61
72
|
const clampedIndex = matches.length > 0
|
|
@@ -66,6 +77,8 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
66
77
|
|
|
67
78
|
const optionId = useCallback((index: number) => `${listboxId}-option-${index}`, [listboxId]);
|
|
68
79
|
|
|
80
|
+
const open = shouldOpen(trigger, matches);
|
|
81
|
+
|
|
69
82
|
const close = useCallback(() => {
|
|
70
83
|
setTrigger(null);
|
|
71
84
|
setActiveIndexState(0);
|
|
@@ -112,15 +125,22 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
112
125
|
const next = insertToken(value, trigger, tokenForItem(item));
|
|
113
126
|
pendingCaretRef.current = next.caret;
|
|
114
127
|
setValue(next.value);
|
|
115
|
-
|
|
128
|
+
// Re-query instead of closing: oh-my-pi reopens on the accepted text, so
|
|
129
|
+
// `/fast` → `/fast ` immediately offers its subcommands and the collapsed
|
|
130
|
+
// `/skill:` row expands into the individual skills. `shouldOpen` keeps a
|
|
131
|
+
// completed command from leaving an empty popup behind.
|
|
132
|
+
setTrigger(detectComposerTrigger(next.value, next.caret));
|
|
133
|
+
setActiveIndexState(0);
|
|
116
134
|
},
|
|
117
|
-
[value, trigger, setValue
|
|
135
|
+
[value, trigger, setValue],
|
|
118
136
|
);
|
|
119
137
|
|
|
120
138
|
const handleKeyDown = useCallback(
|
|
121
139
|
(e: TargetedKeyboardEvent<HTMLTextAreaElement>): boolean => {
|
|
122
140
|
if (composingRef.current || e.keyCode === 229) return false;
|
|
123
|
-
|
|
141
|
+
// A closed popup owns nothing: `/tmp/fo` must keep Enter/Tab meaning send
|
|
142
|
+
// and indent rather than being swallowed by an invisible list.
|
|
143
|
+
if (!trigger || !open) return false;
|
|
124
144
|
|
|
125
145
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
126
146
|
e.preventDefault();
|
|
@@ -132,6 +152,12 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
132
152
|
}
|
|
133
153
|
|
|
134
154
|
if ((e.key === 'Enter' || e.key === 'Tab') && matches.length > 0) {
|
|
155
|
+
// A complete command with nothing left to complete: Enter is a send, so
|
|
156
|
+
// drop the popup rather than consuming the keystroke.
|
|
157
|
+
if (e.key === 'Enter' && sendInsteadOfAccept(trigger, matches[clampedIndex])) {
|
|
158
|
+
close();
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
135
161
|
e.preventDefault();
|
|
136
162
|
e.stopPropagation();
|
|
137
163
|
selectItem(matches[clampedIndex]);
|
|
@@ -147,7 +173,7 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
147
173
|
|
|
148
174
|
return false;
|
|
149
175
|
},
|
|
150
|
-
[trigger, matches, clampedIndex, selectItem, close],
|
|
176
|
+
[trigger, open, matches, clampedIndex, selectItem, close],
|
|
151
177
|
);
|
|
152
178
|
|
|
153
179
|
useLayoutEffect(() => {
|
|
@@ -163,7 +189,7 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
163
189
|
|
|
164
190
|
return {
|
|
165
191
|
trigger,
|
|
166
|
-
isOpen:
|
|
192
|
+
isOpen: open,
|
|
167
193
|
matches,
|
|
168
194
|
loading,
|
|
169
195
|
error,
|