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,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The composer's attach-and-send pipeline: everything between "a file arrived"
|
|
8
|
+
* and "a prompt is dispatched", plus the one line of feedback each outcome
|
|
9
|
+
* produces.
|
|
10
|
+
*
|
|
11
|
+
* Extracted from ChatInput so the component stays a layout shell and the
|
|
12
|
+
* composer keeps room under the repo's per-file size ceiling.
|
|
13
|
+
*
|
|
14
|
+
* The rules encoded here, each of which was a bug once:
|
|
15
|
+
*
|
|
16
|
+
* - A drop registers its files in the attachment hook BEFORE the component
|
|
17
|
+
* re-renders, so a send clicked immediately afterwards must read the hook's
|
|
18
|
+
* list, not the component's `attachments` prop — that prop is a frame behind
|
|
19
|
+
* and an empty read clears the composer and dispatches nothing.
|
|
20
|
+
* - A send waits for in-flight reads and then uses the list those reads
|
|
21
|
+
* returned: an attachment whose bytes landed during the wait is sent
|
|
22
|
+
* complete, never as the pre-read entry.
|
|
23
|
+
* - A send that would carry neither text nor any readable attachment is
|
|
24
|
+
* REPORTED, never dispatched. An empty prompt reaching the model is worse
|
|
25
|
+
* than not sending, and it is what a failed read used to produce.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
29
|
+
import type { SetStateAction } from 'preact/compat';
|
|
30
|
+
import type { Attachment } from '@/shared/types';
|
|
31
|
+
import { attachmentName, describeAttachmentBudget } from '@/shared/lib/chat/attachments';
|
|
32
|
+
import { useComposerAttachments } from '@/client/hooks/chat/composer/attachments';
|
|
33
|
+
import { useFileDrop, DROP_LIMIT_NOTICE, type FileDropProps } from '@/client/hooks/chat/composer/file-drop';
|
|
34
|
+
import type { PrimedReads } from '@/client/hooks/chat/composer/file-reads';
|
|
35
|
+
import {
|
|
36
|
+
describeReferenceFailure,
|
|
37
|
+
describeUnreadable,
|
|
38
|
+
readDroppedReference,
|
|
39
|
+
referenceName,
|
|
40
|
+
type ReferenceFailure,
|
|
41
|
+
} from '@/client/hooks/chat/composer/drop-references';
|
|
42
|
+
|
|
43
|
+
export interface UseComposerPipelineOptions {
|
|
44
|
+
attachments: Attachment[];
|
|
45
|
+
setAttachments: (updater: SetStateAction<Attachment[]>) => void;
|
|
46
|
+
/** Current composer text, read at send time. */
|
|
47
|
+
value: string;
|
|
48
|
+
disabled: boolean;
|
|
49
|
+
/** Workspace root, used to resolve a dropped path reference. */
|
|
50
|
+
rootPath?: string | null;
|
|
51
|
+
onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ComposerPipeline {
|
|
55
|
+
/** Inline notice shown above the input; null when there is nothing to say. */
|
|
56
|
+
notice: string | null;
|
|
57
|
+
dismissNotice: () => void;
|
|
58
|
+
/** True while a file drag hovers the composer. */
|
|
59
|
+
isDragging: boolean;
|
|
60
|
+
/** Drag handlers for the composer card. */
|
|
61
|
+
dropProps: FileDropProps;
|
|
62
|
+
/** Attach a batch (picker, paste, or a drop the caller already parsed). */
|
|
63
|
+
acceptFiles: (files: File[], incomplete?: boolean, references?: string[], primed?: PrimedReads) => void;
|
|
64
|
+
/** Remove one attachment. */
|
|
65
|
+
removeAttachment: (id: string) => void;
|
|
66
|
+
/** Send the composer's contents. */
|
|
67
|
+
submit: (options?: { steering?: boolean }) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useComposerPipeline({
|
|
71
|
+
attachments,
|
|
72
|
+
setAttachments,
|
|
73
|
+
value,
|
|
74
|
+
disabled,
|
|
75
|
+
rootPath,
|
|
76
|
+
onSend,
|
|
77
|
+
}: UseComposerPipelineOptions): ComposerPipeline {
|
|
78
|
+
const { addFiles, removeAttachment, readyForSend, peek, clear } = useComposerAttachments({ attachments, setAttachments });
|
|
79
|
+
// Guards a double click: the composer is cleared only after the reads land, so
|
|
80
|
+
// without this a second click would dispatch the same attachment twice.
|
|
81
|
+
const sendingRef = useRef(false);
|
|
82
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
83
|
+
|
|
84
|
+
// A download dragged out of a web page carries no File — only a URL. The
|
|
85
|
+
// server reads it, because the browser cannot fetch an arbitrary path and the
|
|
86
|
+
// drop gives no bytes to work with.
|
|
87
|
+
const attachReferences = useCallback(async (references: string[]) => {
|
|
88
|
+
const resolved: File[] = [];
|
|
89
|
+
const failed: string[] = [];
|
|
90
|
+
const reasons = new Map<string, ReferenceFailure>();
|
|
91
|
+
await Promise.all(references.map(async (reference) => {
|
|
92
|
+
const name = referenceName(reference);
|
|
93
|
+
const result = await readDroppedReference(reference, rootPath ?? null);
|
|
94
|
+
if (result.ok) {
|
|
95
|
+
resolved.push(result.file);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
failed.push(name);
|
|
99
|
+
reasons.set(name, result.reason);
|
|
100
|
+
}));
|
|
101
|
+
if (resolved.length > 0) {
|
|
102
|
+
const { unreadable } = await addFiles(resolved);
|
|
103
|
+
setNotice(unreadable.length > 0 ? describeUnreadable(unreadable) : null);
|
|
104
|
+
}
|
|
105
|
+
if (failed.length > 0) {
|
|
106
|
+
// Named per cause: "outside the folders the chamber may read" is something
|
|
107
|
+
// the user can act on, while a bare "could not be read" is not.
|
|
108
|
+
const reason = reasons.get(failed[0]) ?? 'unavailable';
|
|
109
|
+
setNotice(describeReferenceFailure(failed, reason));
|
|
110
|
+
}
|
|
111
|
+
}, [addFiles, rootPath]);
|
|
112
|
+
|
|
113
|
+
const acceptFiles = useCallback((
|
|
114
|
+
files: File[],
|
|
115
|
+
incomplete = false,
|
|
116
|
+
references: string[] = [],
|
|
117
|
+
primed?: PrimedReads,
|
|
118
|
+
) => {
|
|
119
|
+
void (async () => {
|
|
120
|
+
const { added, skipped, unreadable } = await addFiles(files, primed);
|
|
121
|
+
if (skipped.length > 0) {
|
|
122
|
+
setNotice(describeAttachmentBudget(skipped));
|
|
123
|
+
} else if (incomplete) {
|
|
124
|
+
setNotice(DROP_LIMIT_NOTICE);
|
|
125
|
+
} else if (unreadable.length > 0) {
|
|
126
|
+
// Reported, never silent: a chip whose bytes never arrived would send a
|
|
127
|
+
// prompt that names a file the model cannot see.
|
|
128
|
+
setNotice(describeUnreadable(unreadable));
|
|
129
|
+
} else if (added > 0) {
|
|
130
|
+
setNotice(null);
|
|
131
|
+
}
|
|
132
|
+
if (references.length > 0) await attachReferences(references);
|
|
133
|
+
})();
|
|
134
|
+
}, [addFiles, attachReferences]);
|
|
135
|
+
|
|
136
|
+
// OS drag-and-drop. Bound to the composer CARD, not the textarea: the whole
|
|
137
|
+
// composer is the drop target, so a drop anywhere inside it (including on the
|
|
138
|
+
// textarea) bubbles here instead of letting the browser navigate away.
|
|
139
|
+
const { isDragging, dropProps } = useFileDrop({ disabled, onFiles: acceptFiles });
|
|
140
|
+
|
|
141
|
+
const submit = useCallback((options?: { steering?: boolean }) => {
|
|
142
|
+
// The hook's list, not the prop: a drop registers its files before this
|
|
143
|
+
// component re-renders, so a send clicked right after a drop would see an
|
|
144
|
+
// empty prop, clear the composer and dispatch a prompt carrying nothing.
|
|
145
|
+
const pending = peek();
|
|
146
|
+
if (!value.trim() && pending.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (sendingRef.current) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
sendingRef.current = true;
|
|
153
|
+
// Bytes are read at attach time; waiting here returns the list as it stands
|
|
154
|
+
// once those reads land, so an attachment whose read was still in flight is
|
|
155
|
+
// sent complete rather than empty.
|
|
156
|
+
void readyForSend().then((outgoing) => {
|
|
157
|
+
clear();
|
|
158
|
+
// "Readable" means there is something TO SEND. An attachment whose read
|
|
159
|
+
// produced an empty string has a `content` field but no content, and
|
|
160
|
+
// treating that as readable is how a prompt with an empty body went out.
|
|
161
|
+
const readable = outgoing.some((a) => (a.content?.length ?? 0) > 0 || a.dataBase64 !== undefined);
|
|
162
|
+
if (!value.trim() && outgoing.length > 0 && !readable) {
|
|
163
|
+
setNotice(describeUnreadable(outgoing.map((a) => attachmentName(a))));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
onSend(outgoing, options);
|
|
167
|
+
}).finally(() => {
|
|
168
|
+
sendingRef.current = false;
|
|
169
|
+
});
|
|
170
|
+
}, [clear, onSend, peek, readyForSend, value]);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
notice,
|
|
174
|
+
dismissNotice: useCallback(() => setNotice(null), []),
|
|
175
|
+
isDragging,
|
|
176
|
+
dropProps,
|
|
177
|
+
acceptFiles,
|
|
178
|
+
removeAttachment,
|
|
179
|
+
submit,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import type { ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
|
|
2
|
+
import type { AgentImage, ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
|
|
3
3
|
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
4
4
|
import { type ToolResultRecord, useOmpAgentStream } from '@/client/hooks/chat/omp/stream';
|
|
5
5
|
import { useOmpPromptSender } from '@/client/hooks/chat/omp/prompt-send';
|
|
@@ -120,7 +120,7 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
|
|
|
120
120
|
* arrives via the interruptPending guard. */
|
|
121
121
|
const sendInterruptAndReply = useCallback(async (
|
|
122
122
|
message: string,
|
|
123
|
-
images?:
|
|
123
|
+
images?: AgentImage[],
|
|
124
124
|
): Promise<boolean> => {
|
|
125
125
|
const sid = sessionIdRef.current;
|
|
126
126
|
if (!sid) return false;
|
|
@@ -153,7 +153,7 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
|
|
|
153
153
|
/** Enqueue a follow-up message the agent processes after the current turn. */
|
|
154
154
|
const sendFollowUp = useCallback(async (
|
|
155
155
|
message: string,
|
|
156
|
-
images?:
|
|
156
|
+
images?: AgentImage[],
|
|
157
157
|
): Promise<boolean> => {
|
|
158
158
|
const sid = sessionIdRef.current;
|
|
159
159
|
if (!sid) return false;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { useCallback } from 'preact/hooks';
|
|
19
19
|
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
20
|
-
import type { OmpAgentState } from '@/shared/types';
|
|
20
|
+
import type { AgentImage, OmpAgentState } from '@/shared/types';
|
|
21
21
|
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
22
22
|
|
|
23
23
|
export interface OmpPromptSenderDeps {
|
|
@@ -29,11 +29,11 @@ export interface OmpPromptSenderDeps {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface OmpPromptSender {
|
|
32
|
-
sendPrompt: (message: string, images?:
|
|
32
|
+
sendPrompt: (message: string, images?: AgentImage[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
|
|
33
33
|
sendNewPrompt: (
|
|
34
34
|
message: string,
|
|
35
35
|
cwd: string,
|
|
36
|
-
images?:
|
|
36
|
+
images?: AgentImage[],
|
|
37
37
|
composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
|
|
38
38
|
) => Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null>;
|
|
39
39
|
}
|
|
@@ -44,7 +44,7 @@ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
|
|
|
44
44
|
/** Send a prompt to the omp session via the RPC bridge. */
|
|
45
45
|
const sendPrompt = useCallback(async (
|
|
46
46
|
message: string,
|
|
47
|
-
images?:
|
|
47
|
+
images?: AgentImage[],
|
|
48
48
|
options?: { accessMode?: ApprovalMode },
|
|
49
49
|
) => {
|
|
50
50
|
const sid = sessionIdRef.current;
|
|
@@ -105,7 +105,7 @@ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
|
|
|
105
105
|
const sendNewPrompt = useCallback(async (
|
|
106
106
|
message: string,
|
|
107
107
|
cwd: string,
|
|
108
|
-
images?:
|
|
108
|
+
images?: AgentImage[],
|
|
109
109
|
composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
|
|
110
110
|
): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
|
|
111
111
|
setState((prev) => ({ ...prev, isGenerating: true, error: null }));
|
|
@@ -16,6 +16,9 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
|
16
16
|
import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
|
|
17
17
|
import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
|
|
18
18
|
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
19
|
+
import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
|
|
20
|
+
import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
|
|
21
|
+
import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
|
|
19
22
|
|
|
20
23
|
export interface ChatTimelineActionsDeps {
|
|
21
24
|
inputValue: string;
|
|
@@ -48,6 +51,9 @@ export interface ChatTimelineActionsDeps {
|
|
|
48
51
|
/** Live composer model/thinking mirror, snapshotted onto queued items so
|
|
49
52
|
* auto-delivery replays the exact settings. */
|
|
50
53
|
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
54
|
+
/** Picks made while a turn streams: held back so they cannot re-target the
|
|
55
|
+
* in-flight answer, then pushed onto the session before the next prompt. */
|
|
56
|
+
deferredComposerPickRef: DeferredModelStore;
|
|
51
57
|
/** Global access-control mode, snapshotted onto queued items. */
|
|
52
58
|
accessModeRef: { current: ApprovalMode };
|
|
53
59
|
setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
|
|
@@ -89,6 +95,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
89
95
|
pendingComposerModelRef,
|
|
90
96
|
pendingThinkingLevelRef,
|
|
91
97
|
composerModelRef,
|
|
98
|
+
deferredComposerPickRef,
|
|
92
99
|
accessModeRef,
|
|
93
100
|
setSearchParams,
|
|
94
101
|
} = deps;
|
|
@@ -101,6 +108,14 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
101
108
|
// resume delivering after this run ends.
|
|
102
109
|
stopHoldRef.current = false;
|
|
103
110
|
|
|
111
|
+
// `/btw [question]` is the side-question entry point, not chat text: the
|
|
112
|
+
// panel owns it (omp's `/btw` is TUI-only, so nothing downstream would
|
|
113
|
+
// understand the token). A bare `/btw` only opens the panel's history.
|
|
114
|
+
if (dispatchBtwCommand(textToSend, attachments)) {
|
|
115
|
+
setInputValue('');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
104
119
|
if (isGenerating) {
|
|
105
120
|
if (options?.steering) {
|
|
106
121
|
// Explicit steering while a run is active.
|
|
@@ -135,7 +150,12 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
135
150
|
// panel is a view of it. Delivery happens when the run ends — the
|
|
136
151
|
// wrapper's terminal `agent_end` claims the head and dispatches it.
|
|
137
152
|
setInputValue('');
|
|
138
|
-
|
|
153
|
+
// The queue row is JSON: a `File` does not survive it. Capture the text
|
|
154
|
+
// contents and the display fields now, so delivery can inline the files
|
|
155
|
+
// and render the chips without a live handle.
|
|
156
|
+
void prepareQueuedAttachments(attachments).then((prepared) => {
|
|
157
|
+
enqueueMessage({ text: textToSend, attachments: prepared, model });
|
|
158
|
+
});
|
|
139
159
|
return;
|
|
140
160
|
}
|
|
141
161
|
|
|
@@ -285,16 +305,32 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
285
305
|
pendingThinkingLevelRef.current = level;
|
|
286
306
|
return;
|
|
287
307
|
}
|
|
308
|
+
// A pick made mid-run is intent for the NEXT prompt: pushing it now would
|
|
309
|
+
// re-target the turn already streaming (omp applies set_thinking_level to
|
|
310
|
+
// the running turn's next LLM call).
|
|
311
|
+
if (isGenerating) {
|
|
312
|
+
stashComposerPick(deferredComposerPickRef, { thinkingLevel: level });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
consumeComposerPick(deferredComposerPickRef, { thinkingLevel: level });
|
|
288
316
|
void ompAgent.setThinkingLevel(level);
|
|
289
|
-
}, [isOmpSession, ompAgent, pendingThinkingLevelRef]);
|
|
317
|
+
}, [isGenerating, isOmpSession, ompAgent, pendingThinkingLevelRef, deferredComposerPickRef]);
|
|
290
318
|
|
|
291
319
|
const handleModelChange = useCallback((provider: string, modelId: string) => {
|
|
292
320
|
if (!isOmpSession) {
|
|
293
321
|
pendingComposerModelRef.current = { provider, modelId };
|
|
294
322
|
return;
|
|
295
323
|
}
|
|
296
|
-
|
|
297
|
-
|
|
324
|
+
// Same rule as the thinking pick: a mid-run model change is held back so
|
|
325
|
+
// the answer being streamed finishes on the model it started with, and is
|
|
326
|
+
// applied right before the next prompt this composer sends.
|
|
327
|
+
if (isGenerating) {
|
|
328
|
+
stashComposerPick(deferredComposerPickRef, { provider, modelId });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
consumeComposerPick(deferredComposerPickRef, { provider, modelId });
|
|
332
|
+
void applyComposerPick(ompAgent, { provider, modelId });
|
|
333
|
+
}, [isGenerating, isOmpSession, ompAgent, pendingComposerModelRef, deferredComposerPickRef]);
|
|
298
334
|
|
|
299
335
|
return {
|
|
300
336
|
handleSend,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A composer model/thinking pick made while a turn is streaming must not reach
|
|
8
|
+
* the live session: omp applies `set_model` to the RUNNING turn, so pushing it
|
|
9
|
+
* immediately re-targets the answer in flight (verified against omp's RPC
|
|
10
|
+
* handler and by driving a live session — one run answered its first turn on
|
|
11
|
+
* the old model and its next turns on the new one).
|
|
12
|
+
*
|
|
13
|
+
* The pick is held and pushed right before the next prompt instead. These run
|
|
14
|
+
* against a real DOM (happy-dom) because the hooks are Preact hooks: the
|
|
15
|
+
* modules below are imported inside `beforeAll`, after those globals exist,
|
|
16
|
+
* since Preact binds its environment at evaluation time.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { Window } from 'happy-dom';
|
|
21
|
+
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
22
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
23
|
+
import type { useChatTimelineActions as UseChatTimelineActions, ChatTimelineActionsResult } from '@/client/hooks/chat/timeline/actions';
|
|
24
|
+
import type { ComposerModelPick, DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
|
|
25
|
+
import { flushDeferredPick } from '@/client/hooks/chat/timeline/deferred-model';
|
|
26
|
+
import type { OmpAgentHandle } from '@/shared/types';
|
|
27
|
+
|
|
28
|
+
let useChatTimelineActions: typeof UseChatTimelineActions;
|
|
29
|
+
let h: typeof PreactH;
|
|
30
|
+
let render: typeof PreactRender;
|
|
31
|
+
let act: typeof PreactAct;
|
|
32
|
+
|
|
33
|
+
let container: HTMLElement;
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
calls: string[];
|
|
37
|
+
deferred: { current: ComposerModelPick | null };
|
|
38
|
+
actions: ChatTimelineActionsResult;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A recording agent handle plus the calls it received. */
|
|
42
|
+
function makeAgent() {
|
|
43
|
+
const calls: string[] = [];
|
|
44
|
+
const agent = {
|
|
45
|
+
setModel: async (provider: string, modelId: string) => { calls.push(`set_model:${provider}/${modelId}`); },
|
|
46
|
+
setThinkingLevel: async (level: string) => { calls.push(`set_thinking_level:${level}`); },
|
|
47
|
+
} as unknown as OmpAgentHandle;
|
|
48
|
+
return { calls, agent };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mounts the real hook and records every model/thinking RPC it pushes. */
|
|
52
|
+
async function mountActions(isGenerating: boolean, stashed: ComposerModelPick | null): Promise<Harness> {
|
|
53
|
+
const deferred: { current: ComposerModelPick | null } = { current: stashed };
|
|
54
|
+
const { calls, agent } = makeAgent();
|
|
55
|
+
|
|
56
|
+
const captured: ChatTimelineActionsResult[] = [];
|
|
57
|
+
const Probe = () => {
|
|
58
|
+
captured.push(useChatTimelineActions({
|
|
59
|
+
inputValue: '',
|
|
60
|
+
setInputValue: () => {},
|
|
61
|
+
setInputAttachments: () => {},
|
|
62
|
+
isGenerating,
|
|
63
|
+
isOmpSession: true,
|
|
64
|
+
sessionId: '01a0ca36-f3be-7252-a95c-3d45d180c539',
|
|
65
|
+
appSettings: {},
|
|
66
|
+
messageQueue: [],
|
|
67
|
+
enqueueMessage: () => {},
|
|
68
|
+
removeMessage: () => {},
|
|
69
|
+
executeSend: async () => {},
|
|
70
|
+
steerOmpAgent: async () => {},
|
|
71
|
+
ompAgent: agent,
|
|
72
|
+
abortControllerRef: { current: null },
|
|
73
|
+
setGenerating: () => {},
|
|
74
|
+
stopHoldRef: { current: false },
|
|
75
|
+
persistMessages: () => {},
|
|
76
|
+
setLocalMessages: () => {},
|
|
77
|
+
pendingComposerModelRef: { current: null },
|
|
78
|
+
pendingThinkingLevelRef: { current: null },
|
|
79
|
+
composerModelRef: { current: null },
|
|
80
|
+
deferredComposerPickRef: deferred,
|
|
81
|
+
accessModeRef: { current: 'yolo' },
|
|
82
|
+
setSearchParams: () => {},
|
|
83
|
+
}));
|
|
84
|
+
return null;
|
|
85
|
+
};
|
|
86
|
+
await act(async () => {
|
|
87
|
+
render(h(Probe, {}), container);
|
|
88
|
+
});
|
|
89
|
+
// The handlers fire-and-forget their RPC, so let the microtask chain settle.
|
|
90
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
91
|
+
return { calls, deferred, actions: captured[captured.length - 1] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The DOM surface this file installs for Preact to render into. */
|
|
95
|
+
function domGlobals(win: Window): Record<string, unknown> {
|
|
96
|
+
return {
|
|
97
|
+
window: win,
|
|
98
|
+
document: win.document,
|
|
99
|
+
navigator: win.navigator,
|
|
100
|
+
Node: win.Node,
|
|
101
|
+
Element: win.Element,
|
|
102
|
+
HTMLElement: win.HTMLElement,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const installed: Record<string, unknown> = {};
|
|
107
|
+
const displaced: Record<string, unknown> = {};
|
|
108
|
+
|
|
109
|
+
beforeAll(async () => {
|
|
110
|
+
const win = new Window({ url: 'http://localhost' });
|
|
111
|
+
for (const [key, value] of Object.entries(domGlobals(win))) {
|
|
112
|
+
displaced[key] = (globalThis as Record<string, unknown>)[key];
|
|
113
|
+
installed[key] = value;
|
|
114
|
+
(globalThis as Record<string, unknown>)[key] = value;
|
|
115
|
+
}
|
|
116
|
+
({ useChatTimelineActions } = await import('@/client/hooks/chat/timeline/actions'));
|
|
117
|
+
({ h, render } = await import('preact'));
|
|
118
|
+
({ act } = await import('preact/test-utils'));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Every test file shares one process, so the globals go back the way they were.
|
|
122
|
+
afterAll(() => {
|
|
123
|
+
for (const key of Object.keys(installed)) {
|
|
124
|
+
if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
|
|
125
|
+
else (globalThis as Record<string, unknown>)[key] = displaced[key];
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
container = document.createElement('div');
|
|
131
|
+
document.body.appendChild(container);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('composer model/thinking picks during a stream', () => {
|
|
135
|
+
test('a mid-stream model pick sends no RPC and is stashed', async () => {
|
|
136
|
+
const { calls, deferred, actions } = await mountActions(true, null);
|
|
137
|
+
actions.handleModelChange('kenari', 'deepseek-v4-pro');
|
|
138
|
+
|
|
139
|
+
expect(calls).toEqual([]);
|
|
140
|
+
expect(deferred.current).toEqual({ provider: 'kenari', modelId: 'deepseek-v4-pro' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a mid-stream thinking pick sends no RPC and is stashed', async () => {
|
|
144
|
+
const { calls, deferred, actions } = await mountActions(true, null);
|
|
145
|
+
actions.handleThinkingLevelChange('high');
|
|
146
|
+
|
|
147
|
+
expect(calls).toEqual([]);
|
|
148
|
+
expect(deferred.current).toEqual({ thinkingLevel: 'high' });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('model then thinking mid-stream both survive to the next prompt', async () => {
|
|
152
|
+
const { calls, deferred, actions } = await mountActions(true, null);
|
|
153
|
+
actions.handleModelChange('kenari', 'deepseek-v4-pro');
|
|
154
|
+
actions.handleThinkingLevelChange('max');
|
|
155
|
+
|
|
156
|
+
expect(calls).toEqual([]);
|
|
157
|
+
expect(deferred.current).toEqual({ provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('an idle pick reaches the session immediately', async () => {
|
|
161
|
+
const { calls, deferred, actions } = await mountActions(false, null);
|
|
162
|
+
actions.handleModelChange('kenari', 'deepseek-v4-pro');
|
|
163
|
+
actions.handleThinkingLevelChange('high');
|
|
164
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
165
|
+
|
|
166
|
+
expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro', 'set_thinking_level:high']);
|
|
167
|
+
expect(deferred.current).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('an idle model pick leaves a stashed thinking level pending', async () => {
|
|
171
|
+
const { calls, deferred, actions } = await mountActions(false, { thinkingLevel: 'high' });
|
|
172
|
+
actions.handleModelChange('kenari', 'deepseek-v4-pro');
|
|
173
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
174
|
+
|
|
175
|
+
// The level was never applied here, so it must still wait for the next
|
|
176
|
+
// prompt rather than being dropped or pushed now.
|
|
177
|
+
expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro']);
|
|
178
|
+
expect(deferred.current).toEqual({ thinkingLevel: 'high' });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("'auto' thinking is never pushed onto the live session", async () => {
|
|
182
|
+
const { calls, deferred, actions } = await mountActions(false, null);
|
|
183
|
+
actions.handleThinkingLevelChange('auto');
|
|
184
|
+
|
|
185
|
+
expect(calls).toEqual([]);
|
|
186
|
+
expect(deferred.current).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('flushing a pick onto the session', () => {
|
|
191
|
+
test('pushes the stashed model and thinking level, then clears the slot', async () => {
|
|
192
|
+
const { calls, agent } = makeAgent();
|
|
193
|
+
const store: DeferredModelStore = { current: { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' } };
|
|
194
|
+
|
|
195
|
+
await flushDeferredPick(agent, store);
|
|
196
|
+
|
|
197
|
+
expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro', 'set_thinking_level:max']);
|
|
198
|
+
expect(store.current).toBeNull();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a stashed 'auto' level is not pushed as a concrete level", async () => {
|
|
202
|
+
const { calls, agent } = makeAgent();
|
|
203
|
+
const store: DeferredModelStore = { current: { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'auto' } };
|
|
204
|
+
|
|
205
|
+
await flushDeferredPick(agent, store);
|
|
206
|
+
|
|
207
|
+
expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro']);
|
|
208
|
+
expect(store.current).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('flushing an empty slot sends nothing', async () => {
|
|
212
|
+
const { calls, agent } = makeAgent();
|
|
213
|
+
const store: DeferredModelStore = { current: null };
|
|
214
|
+
|
|
215
|
+
await flushDeferredPick(agent, store);
|
|
216
|
+
|
|
217
|
+
expect(calls).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Composer model/thinking picks that must NOT reach the live session yet.
|
|
8
|
+
*
|
|
9
|
+
* omp applies `set_model` / `set_thinking_level` to the RUNNING turn: the next
|
|
10
|
+
* LLM call inside the stream (the one after a tool result) goes out on the new
|
|
11
|
+
* model, so a pick made while an answer streams visibly re-targets that answer
|
|
12
|
+
* mid-flight. A composer pick is intent for the NEXT prompt, so while a turn is
|
|
13
|
+
* running it is stashed here and pushed to the session right before the next
|
|
14
|
+
* prompt the composer starts (plain send, steer, or queued delivery).
|
|
15
|
+
*
|
|
16
|
+
* The slot holds only what has NOT reached the session yet, so a pick applied
|
|
17
|
+
* directly (idle composer) clears exactly the fields it applied — a stashed
|
|
18
|
+
* thinking level survives a later model change instead of being resurrected
|
|
19
|
+
* over it.
|
|
20
|
+
*
|
|
21
|
+
* Verified against omp's RPC handler: `set_model` resolves the model and calls
|
|
22
|
+
* the session's `setModel` immediately — nothing defers it to a turn boundary.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { OmpAgentHandle } from '@/shared/types';
|
|
26
|
+
|
|
27
|
+
/** Fields a composer pick can carry; each handler owns its own. */
|
|
28
|
+
export interface ComposerModelPick {
|
|
29
|
+
provider?: string;
|
|
30
|
+
modelId?: string;
|
|
31
|
+
thinkingLevel?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Mutable slot the timeline hook owns; the composer and send paths share it. */
|
|
35
|
+
export interface DeferredModelStore {
|
|
36
|
+
current: ComposerModelPick | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Slot that never holds a pick, for composers with no stream to defer against
|
|
41
|
+
* (the New Chat modal): every guard reads it as "nothing pending", so their
|
|
42
|
+
* adoption behaviour stays exactly as it was.
|
|
43
|
+
*/
|
|
44
|
+
export const NO_PENDING_PICK: DeferredModelStore = { current: null };
|
|
45
|
+
|
|
46
|
+
/** A pick that names a model must name both halves of it. */
|
|
47
|
+
function hasModel(pick: ComposerModelPick): pick is ComposerModelPick & { provider: string; modelId: string } {
|
|
48
|
+
return Boolean(pick.provider && pick.modelId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Record a pick made while a turn is streaming. Merged over the previous stash
|
|
53
|
+
* so a model pick and a later thinking pick both survive to the next prompt.
|
|
54
|
+
*/
|
|
55
|
+
export function stashComposerPick(store: DeferredModelStore, pick: ComposerModelPick): void {
|
|
56
|
+
store.current = { ...store.current, ...pick };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Drop the fields a pick just applied directly, keeping any still-pending ones.
|
|
61
|
+
* Without this, a stashed pick would be flushed over the newer selection on the
|
|
62
|
+
* next send and silently revert the user's latest choice.
|
|
63
|
+
*/
|
|
64
|
+
export function consumeComposerPick(store: DeferredModelStore, pick: ComposerModelPick): void {
|
|
65
|
+
const pending = store.current;
|
|
66
|
+
if (!pending) return;
|
|
67
|
+
const next: ComposerModelPick = { ...pending };
|
|
68
|
+
if (pick.provider || pick.modelId) {
|
|
69
|
+
delete next.provider;
|
|
70
|
+
delete next.modelId;
|
|
71
|
+
}
|
|
72
|
+
if (pick.thinkingLevel) delete next.thinkingLevel;
|
|
73
|
+
store.current = next.provider || next.modelId || next.thinkingLevel ? next : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Push a pick onto the live session. `thinkingLevel: 'auto'` means "leave omp's
|
|
78
|
+
* level alone" — the same rule the send path applies — so it is skipped rather
|
|
79
|
+
* than translated into a concrete level.
|
|
80
|
+
*/
|
|
81
|
+
export async function applyComposerPick(agent: OmpAgentHandle, pick: ComposerModelPick): Promise<void> {
|
|
82
|
+
if (hasModel(pick)) await agent.setModel(pick.provider, pick.modelId);
|
|
83
|
+
if (pick.thinkingLevel && pick.thinkingLevel !== 'auto') await agent.setThinkingLevel(pick.thinkingLevel);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Push the stashed pick (if any) onto the session and clear the slot. */
|
|
87
|
+
export async function flushDeferredPick(agent: OmpAgentHandle, store: DeferredModelStore): Promise<void> {
|
|
88
|
+
const pick = store.current;
|
|
89
|
+
if (!pick) return;
|
|
90
|
+
store.current = null;
|
|
91
|
+
await applyComposerPick(agent, pick);
|
|
92
|
+
}
|