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
|
@@ -6,6 +6,8 @@ interface EditorToolbarProps {
|
|
|
6
6
|
wordWrap: boolean;
|
|
7
7
|
isMd: boolean;
|
|
8
8
|
isPreview: boolean;
|
|
9
|
+
/** Raster image: no text to save or wrap, and zoom belongs to the picture. */
|
|
10
|
+
isImage?: boolean;
|
|
9
11
|
onSave: () => void;
|
|
10
12
|
onTogglePreview: () => void;
|
|
11
13
|
onToggleWordWrap: () => void;
|
|
@@ -22,6 +24,7 @@ export function EditorToolbar({
|
|
|
22
24
|
wordWrap,
|
|
23
25
|
isMd,
|
|
24
26
|
isPreview,
|
|
27
|
+
isImage = false,
|
|
25
28
|
onSave,
|
|
26
29
|
onTogglePreview,
|
|
27
30
|
onToggleWordWrap,
|
|
@@ -40,22 +43,24 @@ export function EditorToolbar({
|
|
|
40
43
|
|
|
41
44
|
{/* Right actions */}
|
|
42
45
|
<div className="flex items-center space-x-3 text-ink/40">
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
46
|
+
{!isImage && (
|
|
47
|
+
<div className="flex items-center pr-3 border-r border-ink/10">
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={onSave}
|
|
51
|
+
className="flex items-center justify-center rounded hover:bg-ink/5 transition-colors"
|
|
52
|
+
title="Save file (Ctrl+S)"
|
|
53
|
+
>
|
|
54
|
+
{saveStatus === 'saving' ? (
|
|
55
|
+
<Save size={14} className="text-amber-500 animate-pulse" />
|
|
56
|
+
) : saveStatus === 'saved' ? (
|
|
57
|
+
<Check size={14} className="text-success" />
|
|
58
|
+
) : (
|
|
59
|
+
<Save size={14} className="hover:text-ink cursor-pointer" />
|
|
60
|
+
)}
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
59
64
|
|
|
60
65
|
{isMd && (
|
|
61
66
|
<div onClick={onTogglePreview} className="flex items-center pr-3 border-r border-ink/10">
|
|
@@ -67,14 +72,18 @@ export function EditorToolbar({
|
|
|
67
72
|
</div>
|
|
68
73
|
)}
|
|
69
74
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
{!isImage && (
|
|
76
|
+
<>
|
|
77
|
+
<div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
|
|
78
|
+
<WrapText size={14} className={`cursor-pointer ${wordWrap ? 'text-ink' : 'hover:text-ink'}`} onClick={onToggleWordWrap} />
|
|
79
|
+
</div>
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
<div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
|
|
82
|
+
<ZoomOut size={14} className="hover:text-ink cursor-pointer" onClick={onZoomOut} />
|
|
83
|
+
<ZoomIn size={14} className="hover:text-ink cursor-pointer" onClick={onZoomIn} />
|
|
84
|
+
</div>
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
78
87
|
|
|
79
88
|
<div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
|
|
80
89
|
<Copy size={14} className="hover:text-ink cursor-pointer" onClick={onCopy} />
|
|
@@ -5,6 +5,7 @@ import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
|
|
|
5
5
|
import { useScrollbarFade, scrollbarFadeClass } from '@/client/hooks/ui/scrollbar-fade';
|
|
6
6
|
import { EditorTabs } from '@/client/components/workspace/editor/Tabs';
|
|
7
7
|
import { EditorToolbar } from '@/client/components/workspace/editor/Toolbar';
|
|
8
|
+
import { ImageViewer } from '@/client/components/common/image-viewer';
|
|
8
9
|
import { getDefaultContent } from '@/shared/lib/code/editor-utils';
|
|
9
10
|
import { DiffPanel } from '@/client/components/workspace/diff-panel';
|
|
10
11
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
@@ -134,6 +135,7 @@ export function Editor({
|
|
|
134
135
|
wordWrap={wordWrap}
|
|
135
136
|
isMd={isMd}
|
|
136
137
|
isPreview={isPreview}
|
|
138
|
+
isImage={editor.isImage}
|
|
137
139
|
onSave={editor.saveNow}
|
|
138
140
|
onTogglePreview={togglePreview}
|
|
139
141
|
onToggleWordWrap={() => setWordWrap(!wordWrap)}
|
|
@@ -144,42 +146,49 @@ export function Editor({
|
|
|
144
146
|
onToggleMaximize={() => setIsMaximized(!isMaximized)}
|
|
145
147
|
/>
|
|
146
148
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
149
|
+
{editor.isImage && editor.imageUrl ? (
|
|
150
|
+
// The picture owns its own pan/zoom, so it must NOT sit inside
|
|
151
|
+
// the scrolling text container — a wheel there would scroll the
|
|
152
|
+
// pane instead of zooming the image.
|
|
153
|
+
<ImageViewer src={editor.imageUrl} name={activeFile.name} className="flex-1 min-h-0" />
|
|
154
|
+
) : (
|
|
155
|
+
<div onScroll={handleScroll} className={`flex-1 overflow-auto bg-paper flex ${scrollbarFadeClass(isScrolling)}`}>
|
|
156
|
+
{(isMd && isPreview) ? (
|
|
157
|
+
<div className="p-6 prose prose-sm max-w-4xl mx-auto font-sans flex-1" style={{ fontSize: `${zoomLevel}px` }}>
|
|
158
|
+
<MarkdownRenderer content={currentContent} />
|
|
159
|
+
</div>
|
|
160
|
+
) : (
|
|
161
|
+
<CodeSurface
|
|
162
|
+
value={currentContent}
|
|
163
|
+
onValueChange={editor.onChange}
|
|
164
|
+
language={editor.language}
|
|
165
|
+
wordWrap={wordWrap}
|
|
166
|
+
rootClassName="flex"
|
|
167
|
+
gutterClassName="flex flex-col text-right pl-4 pr-3 select-none text-ink/30 font-mono border-r border-ink/10 bg-canvas sticky left-0 z-10"
|
|
168
|
+
gutterStyle={{
|
|
169
|
+
fontSize: zoomLevel,
|
|
170
|
+
paddingTop: 16,
|
|
171
|
+
paddingBottom: 16,
|
|
172
|
+
lineHeight: 1.5,
|
|
173
|
+
fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
|
|
174
|
+
}}
|
|
175
|
+
gutterLineClassName="min-w-[1.5rem]"
|
|
176
|
+
// `min-w-max` keeps a long line intact and lets the panel
|
|
177
|
+
// scroll sideways; while wrapping it would instead widen the
|
|
178
|
+
// column past the panel, so the toggle had no effect at all.
|
|
179
|
+
editorWrapperClassName={`flex-1 code-surface ${wordWrap ? 'min-w-0' : 'min-w-max'}`}
|
|
180
|
+
editorPadding={16}
|
|
181
|
+
editorClassName="font-mono focus:outline-none"
|
|
182
|
+
editorStyle={{
|
|
183
|
+
fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
|
|
184
|
+
fontSize: zoomLevel,
|
|
185
|
+
lineHeight: 1.5,
|
|
186
|
+
minHeight: '100%',
|
|
187
|
+
}}
|
|
188
|
+
/>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
183
192
|
</>
|
|
184
193
|
)
|
|
185
194
|
) : (
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import type { AIModelOption } from '@/shared/types';
|
|
2
7
|
import { modelKey } from '@/shared/lib/models/identity';
|
|
3
8
|
|
|
@@ -13,6 +18,25 @@ export interface ModelPickerGroups {
|
|
|
13
18
|
index: Record<string, number>;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Models named by an ordered key list, in that order.
|
|
23
|
+
*
|
|
24
|
+
* The order is the point: RECENT means "most recently used first", and the
|
|
25
|
+
* catalog's own order is alphabetical by name — so reading recents off a flag
|
|
26
|
+
* (the previous `isRecent`) could not express the ranking at all, and a
|
|
27
|
+
* re-picked model never moved back to the top.
|
|
28
|
+
*/
|
|
29
|
+
function modelsInKeyOrder(models: AIModelOption[], keys: readonly string[]): AIModelOption[] {
|
|
30
|
+
if (keys.length === 0) return [];
|
|
31
|
+
const byKey = new Map(models.map((model) => [modelKey(model), model]));
|
|
32
|
+
const ordered: AIModelOption[] = [];
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
const match = byKey.get(key);
|
|
35
|
+
if (match) ordered.push(match);
|
|
36
|
+
}
|
|
37
|
+
return ordered;
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
/**
|
|
17
41
|
* All derived picker state in one place: search filtering, the favorites/recent
|
|
18
42
|
* rails, the per-provider grouping, and the flattened key→index map the
|
|
@@ -21,11 +45,16 @@ export interface ModelPickerGroups {
|
|
|
21
45
|
* Keying `index` on the composite identity matters — the same model id is served
|
|
22
46
|
* by several providers, so an id-only key collapsed their rows onto one index
|
|
23
47
|
* and hovering one highlighted every other provider's row.
|
|
48
|
+
*
|
|
49
|
+
* A row never appears twice: a favorite that is also recent is only listed under
|
|
50
|
+
* FAVORITES, and neither rail repeats the row in its provider section.
|
|
24
51
|
*/
|
|
25
52
|
export function buildPickerGroups(
|
|
26
53
|
models: AIModelOption[],
|
|
27
54
|
search: string,
|
|
28
55
|
collapsedSections: Record<string, boolean>,
|
|
56
|
+
favoriteKeys: readonly string[],
|
|
57
|
+
recentKeys: readonly string[],
|
|
29
58
|
): ModelPickerGroups {
|
|
30
59
|
const query = search.trim().toLowerCase();
|
|
31
60
|
const filtered = query
|
|
@@ -37,8 +66,12 @@ export function buildPickerGroups(
|
|
|
37
66
|
)
|
|
38
67
|
: models;
|
|
39
68
|
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
// The rails are ordered by the stored key lists, not by the catalog order.
|
|
70
|
+
// A search narrows them like every other section — a starred model that does
|
|
71
|
+
// not match the query must not sit above the results.
|
|
72
|
+
const favorites = modelsInKeyOrder(filtered, favoriteKeys);
|
|
73
|
+
const favoriteSet = new Set(favoriteKeys);
|
|
74
|
+
const recent = modelsInKeyOrder(filtered, recentKeys).filter(m => !favoriteSet.has(modelKey(m)));
|
|
42
75
|
|
|
43
76
|
const byProvider: Record<string, AIModelOption[]> = {};
|
|
44
77
|
for (const model of filtered) {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { TargetedMouseEvent } from 'preact';
|
|
2
2
|
import { useMemo, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import { ChevronDown, Sparkles } from 'lucide-preact';
|
|
4
|
-
import type { AIModelOption } from '@/shared/types';
|
|
4
|
+
import type { AIModelOption, ModelPreferences } from '@/shared/types';
|
|
5
5
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
6
6
|
import { invalidateModelsCache } from '@/shared/lib/models/client';
|
|
7
7
|
import { ModelDropdownPanel } from '@/client/components/workspace/model-dropdown/Panel';
|
|
8
8
|
import { useModelCatalog } from '@/client/components/workspace/model-dropdown/use-catalog';
|
|
9
9
|
import { buildPickerGroups } from '@/client/components/workspace/model-dropdown/groups';
|
|
10
10
|
import { modelKey } from '@/shared/lib/models/identity';
|
|
11
|
+
import { RECENT_MODELS_LIMIT, filterKnownKeys, readModelPreferences, recordRecentKey, toggleFavoriteKey } from '@/shared/lib/models/preferences';
|
|
11
12
|
|
|
12
13
|
interface ModelDropdownProps {
|
|
13
14
|
selectedModel?: AIModelOption;
|
|
@@ -29,7 +30,15 @@ export function ModelDropdown({
|
|
|
29
30
|
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
|
30
31
|
const [hoveredModel, setHoveredModel] = useState<AIModelOption | null>(null);
|
|
31
32
|
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
|
32
|
-
const {
|
|
33
|
+
const {
|
|
34
|
+
models,
|
|
35
|
+
isLoading,
|
|
36
|
+
setModels,
|
|
37
|
+
selectedModel,
|
|
38
|
+
setSelectedModel,
|
|
39
|
+
preferences,
|
|
40
|
+
applyPreferences,
|
|
41
|
+
} = useModelCatalog(externalSelectedModel);
|
|
33
42
|
|
|
34
43
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
35
44
|
useOnClickOutside(containerRef, () => {
|
|
@@ -46,8 +55,8 @@ export function ModelDropdown({
|
|
|
46
55
|
visibleFlatList,
|
|
47
56
|
index: modelIndex,
|
|
48
57
|
} = useMemo(
|
|
49
|
-
() => buildPickerGroups(models, search, collapsedSections),
|
|
50
|
-
[models, search, collapsedSections],
|
|
58
|
+
() => buildPickerGroups(models, search, collapsedSections, preferences.favorites, preferences.recentKeys),
|
|
59
|
+
[models, search, collapsedSections, preferences],
|
|
51
60
|
);
|
|
52
61
|
|
|
53
62
|
const selectedModelKey = modelKey(selectedModel);
|
|
@@ -70,17 +79,40 @@ export function ModelDropdown({
|
|
|
70
79
|
setCollapsedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
|
71
80
|
};
|
|
72
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Flip one row's star. The optimistic stamp and the rollback both go through
|
|
84
|
+
* the preference pair — the flag on a row is derived state, so patching the
|
|
85
|
+
* row alone would be undone by the next `applyModelPreferences` (every reload
|
|
86
|
+
* re-stamps rows from the store).
|
|
87
|
+
*/
|
|
73
88
|
const handleToggleFavorite = async (model: AIModelOption, e: TargetedMouseEvent<HTMLElement>) => {
|
|
74
89
|
e.stopPropagation();
|
|
75
90
|
const key = modelKey(model);
|
|
76
|
-
|
|
91
|
+
const previous = preferences;
|
|
92
|
+
const optimistic: ModelPreferences = {
|
|
93
|
+
...previous,
|
|
94
|
+
favorites: toggleFavoriteKey(previous.favorites, key),
|
|
95
|
+
};
|
|
96
|
+
applyPreferences(optimistic);
|
|
77
97
|
try {
|
|
78
|
-
await fetch('/api/models', {
|
|
98
|
+
const response = await fetch('/api/models', {
|
|
79
99
|
method: 'POST',
|
|
80
100
|
headers: { 'Content-Type': 'application/json' },
|
|
81
101
|
body: JSON.stringify({ actionType: 'toggleFavorite', provider: model.provider, modelId: model.id }),
|
|
82
102
|
});
|
|
83
|
-
|
|
103
|
+
if (!response.ok) throw new Error(`toggleFavorite failed: ${response.status}`);
|
|
104
|
+
const data = await response.json() as { modelPreferences?: unknown };
|
|
105
|
+
// The server is the authority on the resulting list — it also drops keys
|
|
106
|
+
// the registry no longer serves. An absent/malformed payload keeps the
|
|
107
|
+
// optimistic list rather than blanking the rail.
|
|
108
|
+
if (data.modelPreferences) {
|
|
109
|
+
applyPreferences(readModelPreferences(data.modelPreferences));
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// A failed write must not leave the star lit: the next reload would show
|
|
113
|
+
// it unlit anyway, so the rollback keeps the panel honest immediately.
|
|
114
|
+
applyPreferences(previous);
|
|
115
|
+
}
|
|
84
116
|
};
|
|
85
117
|
|
|
86
118
|
const cycleThinkingLevel = (model: AIModelOption) => {
|
|
@@ -125,6 +157,15 @@ export function ModelDropdown({
|
|
|
125
157
|
onSelectModel?.(model);
|
|
126
158
|
setIsOpen(false);
|
|
127
159
|
setHoveredModel(null);
|
|
160
|
+
// Move the pick to the top of RECENT straight away — the server records it
|
|
161
|
+
// alongside the selection, and waiting for the round trip would leave the
|
|
162
|
+
// rail stale until the next full reload. Same rules as the server's write:
|
|
163
|
+
// pruned to the models the registry serves, de-duplicated, capped.
|
|
164
|
+
const known = new Set(models.map(modelKey));
|
|
165
|
+
applyPreferences({
|
|
166
|
+
favorites: filterKnownKeys(preferences.favorites, known),
|
|
167
|
+
recentKeys: recordRecentKey(filterKnownKeys(preferences.recentKeys, known), modelKey(model), RECENT_MODELS_LIMIT),
|
|
168
|
+
});
|
|
128
169
|
try {
|
|
129
170
|
await fetch('/api/models', {
|
|
130
171
|
method: 'POST',
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import type { AIModelOption, ModelEntry } from '@/shared/types';
|
|
2
|
+
import type { AIModelOption, ModelEntry, ModelPreferences } from '@/shared/types';
|
|
3
3
|
import { fetchModelsData, subscribeModelsUpdated } from '@/shared/lib/models/client';
|
|
4
|
+
import {
|
|
5
|
+
EMPTY_MODEL_PREFERENCES,
|
|
6
|
+
applyModelPreferences,
|
|
7
|
+
readModelPreferences,
|
|
8
|
+
} from '@/shared/lib/models/preferences';
|
|
4
9
|
|
|
5
10
|
function toOption(m: ModelEntry): AIModelOption {
|
|
6
11
|
const ladder = Array.isArray(m.thinkingLevels) ? m.thinkingLevels : [];
|
|
@@ -28,6 +33,10 @@ export interface ModelCatalogState {
|
|
|
28
33
|
setModels: (updater: (prev: AIModelOption[]) => AIModelOption[]) => void;
|
|
29
34
|
selectedModel: AIModelOption | null;
|
|
30
35
|
setSelectedModel: (model: AIModelOption | null) => void;
|
|
36
|
+
/** Favorite + recent rails, ordered by the stored key lists. */
|
|
37
|
+
preferences: ModelPreferences;
|
|
38
|
+
/** Merge a server-returned preference pair into the rails. */
|
|
39
|
+
applyPreferences: (preferences: ModelPreferences) => void;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/**
|
|
@@ -42,11 +51,17 @@ export interface ModelCatalogState {
|
|
|
42
51
|
* An externally-driven selection (the composer's session model) always wins over
|
|
43
52
|
* the API's own default — it is read through a ref so a thinking-level change
|
|
44
53
|
* flowing back through the prop does not re-arm the fetch.
|
|
54
|
+
*
|
|
55
|
+
* Favorites arrive as a key list, never as a flag on the registry rows: the
|
|
56
|
+
* registry is a read-only view of omp's config, so the only place a star can
|
|
57
|
+
* live is the preference store. The rows are stamped from it here, which is why
|
|
58
|
+
* a toggle must go through {@link applyPreferences} rather than patching a row.
|
|
45
59
|
*/
|
|
46
60
|
export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCatalogState {
|
|
47
61
|
const [models, setModelsState] = useState<AIModelOption[]>([]);
|
|
48
62
|
const [isLoading, setIsLoading] = useState(true);
|
|
49
63
|
const [selectedModel, setSelectedModel] = useState<AIModelOption | null>(externalSelectedModel ?? null);
|
|
64
|
+
const [preferences, setPreferences] = useState<ModelPreferences>(EMPTY_MODEL_PREFERENCES);
|
|
50
65
|
|
|
51
66
|
const externalSelectedRef = useRef(externalSelectedModel);
|
|
52
67
|
externalSelectedRef.current = externalSelectedModel;
|
|
@@ -56,11 +71,18 @@ export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCat
|
|
|
56
71
|
setModelsState(updater);
|
|
57
72
|
}, []);
|
|
58
73
|
|
|
74
|
+
const applyPreferences = useCallback((next: ModelPreferences) => {
|
|
75
|
+
setPreferences(next);
|
|
76
|
+
setModelsState(prev => applyModelPreferences(prev, next));
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
59
79
|
const load = useCallback(async () => {
|
|
60
80
|
try {
|
|
61
81
|
const data = await fetchModelsData();
|
|
62
82
|
if (Array.isArray(data.modelList) && data.modelList.length > 0) {
|
|
63
|
-
const
|
|
83
|
+
const prefs = readModelPreferences(data.modelPreferences);
|
|
84
|
+
const realModels = applyModelPreferences(data.modelList.map(toOption), prefs);
|
|
85
|
+
setPreferences(prefs);
|
|
64
86
|
// Keep a thinking level the user already picked, but only when it is
|
|
65
87
|
// still a real level of this model's ladder (legacy mock values like
|
|
66
88
|
// 'Default'/'High' must not be preserved).
|
|
@@ -76,7 +98,9 @@ export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCat
|
|
|
76
98
|
if (match) setSelectedModel(match);
|
|
77
99
|
}
|
|
78
100
|
} else if (Array.isArray(data.models) && data.models.length > 0) {
|
|
79
|
-
|
|
101
|
+
const prefs = readModelPreferences(data.modelPreferences);
|
|
102
|
+
setPreferences(prefs);
|
|
103
|
+
setModelsState(applyModelPreferences(data.models, prefs));
|
|
80
104
|
}
|
|
81
105
|
if (data.selectedModel && !externalSelectedRef.current) {
|
|
82
106
|
setSelectedModel(data.selectedModel);
|
|
@@ -107,5 +131,5 @@ export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCat
|
|
|
107
131
|
));
|
|
108
132
|
}, [externalSelectedModel]);
|
|
109
133
|
|
|
110
|
-
return { models, isLoading, setModels, selectedModel, setSelectedModel };
|
|
134
|
+
return { models, isLoading, setModels, selectedModel, setSelectedModel, preferences, applyPreferences };
|
|
111
135
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Live BTW state for one session: the topic list, the answer streaming right
|
|
8
|
+
* now, and the commands that drive them.
|
|
9
|
+
*
|
|
10
|
+
* The server owns the state — every frame is a full snapshot or a delta, and
|
|
11
|
+
* every command answers with the canonical state — so this hook keeps no
|
|
12
|
+
* merge logic of its own and a reload can never disagree with the server.
|
|
13
|
+
* The stream is attached only while the panel is open: a side question is a
|
|
14
|
+
* deliberate action, not background traffic.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
18
|
+
import type { AgentImage, BtwState, StreamTransport } from '@/shared/types';
|
|
19
|
+
import { BtwRequestError, abortBtwQuestion, askBtwQuestion, connectBtwStream, deleteBtwTopic, fetchBtwState, promoteBtwTopic } from '@/shared/lib/chat/btw/client';
|
|
20
|
+
import type { StreamConnection } from '@/shared/lib/chat/omp/transport';
|
|
21
|
+
|
|
22
|
+
/** Images the side session accepts, already read as base64. */
|
|
23
|
+
export type BtwImage = AgentImage;
|
|
24
|
+
|
|
25
|
+
/** The turn streaming right now; `text` accumulates its deltas. */
|
|
26
|
+
export interface BtwLiveAnswer {
|
|
27
|
+
topicId: string;
|
|
28
|
+
turnIndex: number;
|
|
29
|
+
text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BtwSessionHandle {
|
|
33
|
+
state: BtwState | null;
|
|
34
|
+
live: BtwLiveAnswer | null;
|
|
35
|
+
error: string | null;
|
|
36
|
+
/** A command is in flight — the panel disables its actions meanwhile. */
|
|
37
|
+
busy: boolean;
|
|
38
|
+
/** Resolves with the canonical state the command produced, or null when it
|
|
39
|
+
* was refused (the reason is then in `error`). */
|
|
40
|
+
ask: (question: string, images?: BtwImage[], topicId?: string) => Promise<BtwState | null>;
|
|
41
|
+
abort: (topicId: string) => Promise<void>;
|
|
42
|
+
/** Returns the new session id to open, or null when it was refused. */
|
|
43
|
+
promote: (topicId: string) => Promise<string | null>;
|
|
44
|
+
remove: (topicId: string) => Promise<void>;
|
|
45
|
+
clearError: () => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BtwSessionOptions {
|
|
49
|
+
/** Attach the frame stream (the panel is open). */
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
transport: StreamTransport;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useBtwSession(sessionId: string | null, options: BtwSessionOptions): BtwSessionHandle {
|
|
55
|
+
const [state, setState] = useState<BtwState | null>(null);
|
|
56
|
+
const [live, setLive] = useState<BtwLiveAnswer | null>(null);
|
|
57
|
+
const [error, setError] = useState<string | null>(null);
|
|
58
|
+
const [busy, setBusy] = useState(false);
|
|
59
|
+
const { enabled, transport } = options;
|
|
60
|
+
|
|
61
|
+
// Commands must not be bound to the render that produced them: `/btw <q>`
|
|
62
|
+
// asks from the same tick that opens the form, before the stream attached and
|
|
63
|
+
// before this hook re-rendered with a session — a captured `sessionId` there
|
|
64
|
+
// would be null and the question would silently go nowhere.
|
|
65
|
+
const sessionIdRef = useRef(sessionId);
|
|
66
|
+
sessionIdRef.current = sessionId;
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!enabled || !sessionId) return;
|
|
70
|
+
|
|
71
|
+
const connection: StreamConnection = connectBtwStream(sessionId, transport, {
|
|
72
|
+
onOpen: () => {},
|
|
73
|
+
onFrame: (frame) => {
|
|
74
|
+
switch (frame.type) {
|
|
75
|
+
case 'btw_state':
|
|
76
|
+
setState(frame.state);
|
|
77
|
+
// A settled turn's text is in the snapshot now; holding the live
|
|
78
|
+
// buffer any longer would keep painting the partial answer.
|
|
79
|
+
setLive((current) => {
|
|
80
|
+
if (!current) return null;
|
|
81
|
+
const topic = frame.state.topics.find((candidate) => candidate.id === current.topicId);
|
|
82
|
+
const turn = topic?.turns.find((candidate) => candidate.index === current.turnIndex);
|
|
83
|
+
return turn && turn.status === 'running' ? current : null;
|
|
84
|
+
});
|
|
85
|
+
break;
|
|
86
|
+
case 'btw_delta':
|
|
87
|
+
setLive((current) =>
|
|
88
|
+
current && current.topicId === frame.topicId && current.turnIndex === frame.turnIndex
|
|
89
|
+
? { ...current, text: current.text + frame.text }
|
|
90
|
+
: { topicId: frame.topicId, turnIndex: frame.turnIndex, text: frame.text },
|
|
91
|
+
);
|
|
92
|
+
break;
|
|
93
|
+
case 'btw_error':
|
|
94
|
+
setError(frame.message);
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
onClose: () => {},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
void fetchBtwState(sessionId)
|
|
104
|
+
.then(setState)
|
|
105
|
+
.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)));
|
|
106
|
+
|
|
107
|
+
return () => connection.close();
|
|
108
|
+
}, [enabled, sessionId, transport]);
|
|
109
|
+
|
|
110
|
+
const run = useCallback(
|
|
111
|
+
async (operation: (id: string) => Promise<BtwState>): Promise<BtwState | null> => {
|
|
112
|
+
const id = sessionIdRef.current;
|
|
113
|
+
if (!id) return null;
|
|
114
|
+
setBusy(true);
|
|
115
|
+
try {
|
|
116
|
+
const next = await operation(id);
|
|
117
|
+
setState(next);
|
|
118
|
+
return next;
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
setError(cause instanceof BtwRequestError || cause instanceof Error ? cause.message : String(cause));
|
|
121
|
+
return null;
|
|
122
|
+
} finally {
|
|
123
|
+
setBusy(false);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const ask = useCallback(
|
|
130
|
+
async (question: string, images?: BtwImage[], topicId?: string): Promise<BtwState | null> => {
|
|
131
|
+
setError(null);
|
|
132
|
+
const next = await run((id) => askBtwQuestion(id, { question, images, topicId }));
|
|
133
|
+
setLive(null);
|
|
134
|
+
if (next && !next.runningTopicId) {
|
|
135
|
+
setError('The side question was accepted but did not start running.');
|
|
136
|
+
}
|
|
137
|
+
return next;
|
|
138
|
+
},
|
|
139
|
+
[run],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const abort = useCallback(
|
|
143
|
+
async (topicId: string) => {
|
|
144
|
+
setError(null);
|
|
145
|
+
await run((id) => abortBtwQuestion(id, topicId));
|
|
146
|
+
},
|
|
147
|
+
[run],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const remove = useCallback(
|
|
151
|
+
async (topicId: string) => {
|
|
152
|
+
setError(null);
|
|
153
|
+
await run((id) => deleteBtwTopic(id, topicId));
|
|
154
|
+
},
|
|
155
|
+
[run],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const promote = useCallback(
|
|
159
|
+
async (topicId: string): Promise<string | null> => {
|
|
160
|
+
const id = sessionIdRef.current;
|
|
161
|
+
if (!id) return null;
|
|
162
|
+
setError(null);
|
|
163
|
+
setBusy(true);
|
|
164
|
+
try {
|
|
165
|
+
const created = await promoteBtwTopic(id, topicId);
|
|
166
|
+
return created.sessionId;
|
|
167
|
+
} catch (cause) {
|
|
168
|
+
setError(cause instanceof BtwRequestError || cause instanceof Error ? cause.message : String(cause));
|
|
169
|
+
return null;
|
|
170
|
+
} finally {
|
|
171
|
+
setBusy(false);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
[],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const clearError = useCallback(() => setError(null), []);
|
|
178
|
+
|
|
179
|
+
return { state, live, error, busy, ask, abort, promote, remove, clearError };
|
|
180
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/btw [question]` typed into the main composer is the side-question entry
|
|
8
|
+
* point, not chat text: the panel owns the token (omp's `/btw` is TUI-only, so
|
|
9
|
+
* nothing downstream would understand it). This module is the whole
|
|
10
|
+
* interception — recognize the command, carry the composer's images across as
|
|
11
|
+
* provider payloads, and hand the question to the panel over `omp:btw`.
|
|
12
|
+
*
|
|
13
|
+
* It lives beside the rest of the client-side btw state (`hooks/chat/btw/`)
|
|
14
|
+
* rather than in the send handler: the attachment read goes through the shared
|
|
15
|
+
* accessors, because `Attachment.file` is absent on every replayed attachment.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Attachment } from '@/shared/types';
|
|
19
|
+
import { attachmentImage } from '@/shared/lib/chat/attachments';
|
|
20
|
+
|
|
21
|
+
/** A bare `/btw` opens the form on the current history; anything after it is
|
|
22
|
+
* the first question. Case-insensitive, because omp's command tokens are. */
|
|
23
|
+
const BTW_COMMAND_RE = /^\/btw(?:\s+([\s\S]*))?$/i;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hand a `/btw` draft to the side-question panel. Returns false when the text
|
|
27
|
+
* is not a btw command, so the caller keeps its normal send path.
|
|
28
|
+
*/
|
|
29
|
+
export function dispatchBtwCommand(text: string, attachments: Attachment[]): boolean {
|
|
30
|
+
const match = BTW_COMMAND_RE.exec(text);
|
|
31
|
+
if (!match) return false;
|
|
32
|
+
|
|
33
|
+
const images = attachments
|
|
34
|
+
.map((attachment) => attachmentImage(attachment))
|
|
35
|
+
.filter((image): image is NonNullable<typeof image> => image !== null);
|
|
36
|
+
|
|
37
|
+
window.dispatchEvent(
|
|
38
|
+
new CustomEvent('omp:btw', {
|
|
39
|
+
detail: { question: match[1]?.trim() ?? '', ...(images.length ? { images } : {}) },
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
return true;
|
|
43
|
+
}
|