ompchamber 3.3.3 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +275 -268
- package/dist/client/static/js/{index-8ejde0b7.js → index-h05z90mw.js} +581 -581
- package/package.json +1 -1
- package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
- package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
- package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
- package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
- package/src/client/components/workspace/btw-panel/index.tsx +20 -27
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
- package/src/client/hooks/chat/btw/index.ts +7 -73
- package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
- package/src/client/hooks/chat/btw/intercept.ts +5 -2
- package/src/client/hooks/chat/btw/mode.ts +16 -76
- package/src/client/hooks/chat/timeline/actions.ts +22 -30
- package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
- package/src/client/hooks/chat/timeline/send.ts +8 -1
- package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
- package/src/server/lib/btw/child.server.test.ts +68 -0
- package/src/server/lib/btw/child.server.ts +17 -16
- package/src/server/lib/btw/frames.test.ts +3 -3
- package/src/server/lib/btw/frames.ts +1 -1
- package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
- package/src/server/lib/btw/lifecycle.server.ts +41 -31
- package/src/server/lib/btw/prompt.ts +7 -11
- package/src/server/lib/btw/registry.server.ts +1 -9
- package/src/server/lib/btw/reply.ts +54 -0
- package/src/server/lib/btw/runtime.server.test.ts +128 -0
- package/src/server/lib/btw/runtime.server.ts +77 -86
- package/src/server/lib/btw/schema.server.test.ts +84 -0
- package/src/server/lib/btw/schema.server.ts +8 -0
- package/src/server/lib/btw/service.server.ts +47 -78
- package/src/server/lib/btw/store.server.ts +9 -20
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/session-commands.ts +45 -0
- package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
- package/src/server/lib/omp/session/merge-stored.ts +70 -1
- package/src/server/lib/omp/session/messages.ts +27 -2
- package/src/server/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- package/src/shared/lib/chat/btw/client.ts +0 -30
- package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
- package/src/shared/lib/chat/composer/tui-only.ts +138 -0
- package/src/shared/lib/chat/notice-text.ts +33 -0
- package/src/shared/lib/chat/omp/agent-events.ts +18 -37
- package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
- package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
- package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
- package/src/shared/lib/chat/timeline/command-output.ts +65 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
- package/src/shared/lib/omp/session/messages-map.ts +6 -1
- package/src/shared/types/btw.ts +13 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -11,7 +11,7 @@ interface AppearanceSettingsProps {
|
|
|
11
11
|
|
|
12
12
|
export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsProps) {
|
|
13
13
|
return (
|
|
14
|
-
<div className="w-full space-y-8 text-xs text-ink">
|
|
14
|
+
<div className="w-full space-y-8 text-xs text-ink pb-3">
|
|
15
15
|
<section className="space-y-4">
|
|
16
16
|
<div className="flex items-center space-x-2 border-b border-ink/10 pb-2">
|
|
17
17
|
<Palette size={16} className="text-ink/60" />
|
|
@@ -11,7 +11,7 @@ interface ChatSettingsProps {
|
|
|
11
11
|
|
|
12
12
|
export function ChatSettings({ settings, onUpdate }: ChatSettingsProps) {
|
|
13
13
|
return (
|
|
14
|
-
<div className="w-full space-y-7 text-xs text-ink">
|
|
14
|
+
<div className="w-full space-y-7 text-xs text-ink pb-3">
|
|
15
15
|
{/* 1. Streaming Transport (WebSocket vs SSE) */}
|
|
16
16
|
<ChatTransportSection settings={settings} onUpdate={onUpdate} />
|
|
17
17
|
|
|
@@ -31,6 +31,8 @@ export interface BtwAskCardProps {
|
|
|
31
31
|
onNewQuestion: () => void;
|
|
32
32
|
/** `✕` — leave the side-question form. */
|
|
33
33
|
onClose: () => void;
|
|
34
|
+
/** Escape — cancel the running answer first. Returns whether one was running. */
|
|
35
|
+
onCancel: () => boolean;
|
|
34
36
|
/** A side answer is streaming: starting over waits for it. */
|
|
35
37
|
running: boolean;
|
|
36
38
|
topics: BtwTopic[];
|
|
@@ -50,6 +52,7 @@ export function BtwAskCard({
|
|
|
50
52
|
onSubmit,
|
|
51
53
|
onNewQuestion,
|
|
52
54
|
onClose,
|
|
55
|
+
onCancel,
|
|
53
56
|
running,
|
|
54
57
|
topics,
|
|
55
58
|
activeTopicId,
|
|
@@ -72,7 +75,9 @@ export function BtwAskCard({
|
|
|
72
75
|
const handleKeyDown = (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
|
|
73
76
|
if (event.key === 'Escape') {
|
|
74
77
|
event.preventDefault();
|
|
75
|
-
|
|
78
|
+
// Cancel first, close only when nothing was running — omp's Escape handler
|
|
79
|
+
// does the same, so one keypress cannot abandon a question mid-flight.
|
|
80
|
+
if (!onCancel()) onClose();
|
|
76
81
|
return;
|
|
77
82
|
}
|
|
78
83
|
if (event.key !== 'Enter' || event.shiftKey) return;
|
|
@@ -48,7 +48,7 @@ function AnswerStatus({ status }: { status: BtwTurnStatus }) {
|
|
|
48
48
|
return <div className="text-[11px] text-ink/50">Cancelled</div>;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
return <div className="text-[11px] text-error">{status === '
|
|
51
|
+
return <div className="text-[11px] text-error">{status === 'error' ? 'Error' : 'Interrupted'}</div>;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** The question row for a turn, in the chat's own user-bubble shape. */
|
|
@@ -23,11 +23,12 @@
|
|
|
23
23
|
* question — and promotion is the one explicit action that moves an answer into
|
|
24
24
|
* the chat, as a session branched from it.
|
|
25
25
|
*
|
|
26
|
-
* The composer card is the CHAT's own `ChatInput`, not a look-alike:
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* The composer card is the CHAT's own `ChatInput`, not a look-alike: the same
|
|
27
|
+
* attachments, drop targets and keybindings. Only the autocomplete is off
|
|
28
|
+
* (`enablePicker`) — a side question has no file tree, commands or skills to
|
|
29
|
+
* complete against — and so are the model / thinking / access controls: the side
|
|
30
|
+
* child runs on the chat's model and thinking selector, without tools, so those
|
|
31
|
+
* dropdowns would change nothing. That is exactly what omp's own `/btw` offers.
|
|
31
32
|
*/
|
|
32
33
|
|
|
33
34
|
import { useState } from 'preact/hooks';
|
|
@@ -37,7 +38,6 @@ import { BtwAskCard } from '@/client/components/workspace/btw-panel/AskCard';
|
|
|
37
38
|
import { BtwMessages } from '@/client/components/workspace/btw-panel/Messages';
|
|
38
39
|
import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
|
|
39
40
|
import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
|
|
40
|
-
import { ExtensionDialog } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
|
|
41
41
|
|
|
42
42
|
/** Tallest the answer card grows before its own scrollbar takes over. */
|
|
43
43
|
const ANSWERS_MAX_HEIGHT_PX = 520;
|
|
@@ -70,9 +70,15 @@ export function BtwForm({
|
|
|
70
70
|
// the same click burst would be a second request for work already running.
|
|
71
71
|
// An already-promoted topic needs no second button either — its branch
|
|
72
72
|
// session exists, and the server hands that id back on a repeat promote.
|
|
73
|
+
// A topic with follow-ups cannot be promoted (a branch carries one pair), so
|
|
74
|
+
// the action is offered only where the server would accept it.
|
|
73
75
|
const canPromote =
|
|
74
|
-
Boolean(mode.activeTopic) &&
|
|
75
|
-
|
|
76
|
+
Boolean(mode.activeTopic) &&
|
|
77
|
+
mode.activeTopic!.turns.length === 1 &&
|
|
78
|
+
!mode.runningTopicId &&
|
|
79
|
+
!mode.busy &&
|
|
80
|
+
!mode.activeTopic?.promotedSessionId &&
|
|
81
|
+
lastTurn?.status === 'complete';
|
|
76
82
|
const running = Boolean(mode.runningTopicId);
|
|
77
83
|
|
|
78
84
|
return (
|
|
@@ -91,6 +97,7 @@ export function BtwForm({
|
|
|
91
97
|
onSubmit={mode.submitAsk}
|
|
92
98
|
onNewQuestion={mode.resetQuestion}
|
|
93
99
|
onClose={mode.exit}
|
|
100
|
+
onCancel={mode.abort}
|
|
94
101
|
running={running}
|
|
95
102
|
topics={mode.topics}
|
|
96
103
|
activeTopicId={mode.activeTopic?.id ?? null}
|
|
@@ -107,7 +114,7 @@ export function BtwForm({
|
|
|
107
114
|
provider={mode.activeTopic?.model?.provider ?? provider}
|
|
108
115
|
providerNames={providerNames}
|
|
109
116
|
modelName={mode.activeTopic?.model?.name ?? modelName}
|
|
110
|
-
thinkingLevel={mode.thinkingLevel}
|
|
117
|
+
thinkingLevel={mode.activeTopic?.thinkingLevel}
|
|
111
118
|
isMobile={variant === 'mobile'}
|
|
112
119
|
/>
|
|
113
120
|
|
|
@@ -135,33 +142,19 @@ export function BtwForm({
|
|
|
135
142
|
setAttachments([]);
|
|
136
143
|
}}
|
|
137
144
|
isGenerating={running}
|
|
138
|
-
onStop={mode.abort}
|
|
145
|
+
onStop={() => void mode.abort()}
|
|
139
146
|
appSettings={appSettings}
|
|
140
147
|
attachments={attachments}
|
|
141
148
|
onAttachmentsChange={setAttachments}
|
|
142
149
|
rootPath={rootPath}
|
|
143
150
|
variant={variant}
|
|
144
151
|
enablePicker={false}
|
|
152
|
+
showModel={false}
|
|
153
|
+
showThinking={false}
|
|
154
|
+
showAccess={false}
|
|
145
155
|
placeholder="Ask in this btw session..."
|
|
146
|
-
accessMode={mode.accessMode}
|
|
147
|
-
onAccessModeChange={mode.setAccessMode}
|
|
148
|
-
onThinkingLevelChange={mode.setThinkingLevel}
|
|
149
|
-
onModelChange={mode.setModel}
|
|
150
|
-
sessionModel={mode.activeTopic?.model ? { provider: mode.activeTopic.model.provider, modelId: mode.activeTopic.model.id } : null}
|
|
151
|
-
sessionThinkingLevel={mode.thinkingLevel}
|
|
152
156
|
composerModelRef={composerModelRef}
|
|
153
157
|
/>
|
|
154
|
-
|
|
155
|
-
{/* A side child runs with tools, so a gated call parks its turn on an
|
|
156
|
-
approval dialog. There is no tool card here to host one inline (unlike
|
|
157
|
-
the chat's `ask`), so every answerable request is a modal — and the
|
|
158
|
-
dialog brings its own backdrop. */}
|
|
159
|
-
{dialog && (
|
|
160
|
-
<ExtensionDialog
|
|
161
|
-
request={dialog.request}
|
|
162
|
-
onRespond={(_, response) => mode.respondToDialog(dialog.request.id, response)}
|
|
163
|
-
/>
|
|
164
|
-
)}
|
|
165
158
|
</div>
|
|
166
159
|
);
|
|
167
160
|
}
|
|
@@ -3,7 +3,7 @@ import type { AIModelOption } from '@/shared/types';
|
|
|
3
3
|
import { ModelDropdown } from '@/client/components/workspace/model-dropdown/index';
|
|
4
4
|
import { ThinkingLevelDropdown } from '@/client/components/workspace/chat-timeline/chat-input/ThinkingLevelDropdown';
|
|
5
5
|
import { AccessDropdown } from '@/client/components/workspace/chat-timeline/chat-input/AccessDropdown';
|
|
6
|
-
import type
|
|
6
|
+
import { DEFAULT_APPROVAL_MODE, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
7
7
|
|
|
8
8
|
export interface ComposerToolbarProps {
|
|
9
9
|
isMobile: boolean;
|
|
@@ -21,8 +21,12 @@ export interface ComposerToolbarProps {
|
|
|
21
21
|
onSend: () => void;
|
|
22
22
|
/** Send is blocked while no workspace context is selected or nothing is typed. */
|
|
23
23
|
sendDisabled: boolean;
|
|
24
|
-
accessMode
|
|
25
|
-
onSelectAccess
|
|
24
|
+
accessMode?: ApprovalMode;
|
|
25
|
+
onSelectAccess?: (mode: ApprovalMode) => void;
|
|
26
|
+
/** Which selectors render; all true for the chat, all false for a side question. */
|
|
27
|
+
showModel?: boolean;
|
|
28
|
+
showThinking?: boolean;
|
|
29
|
+
showAccess?: boolean;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
/**
|
|
@@ -45,32 +49,39 @@ export function ComposerToolbar({
|
|
|
45
49
|
onStop,
|
|
46
50
|
onSend,
|
|
47
51
|
sendDisabled,
|
|
48
|
-
accessMode,
|
|
52
|
+
accessMode = DEFAULT_APPROVAL_MODE,
|
|
49
53
|
onSelectAccess,
|
|
54
|
+
showModel = true,
|
|
55
|
+
showThinking = true,
|
|
56
|
+
showAccess = true,
|
|
50
57
|
}: ComposerToolbarProps) {
|
|
51
58
|
const showStop = isGenerating && (isMobile ? Boolean(onStop) : true);
|
|
52
59
|
|
|
53
60
|
return (
|
|
54
61
|
<div className={`flex items-center justify-between border-t border-ink/5 bg-canvas/50 rounded-b-md ${isMobile ? 'px-2 py-2' : 'px-3 py-2'}`}>
|
|
55
62
|
<div className={`flex items-center min-w-0 ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
{showModel && (
|
|
64
|
+
<ModelDropdown
|
|
65
|
+
selectedModel={selectedModel ?? undefined}
|
|
66
|
+
onSelectModel={onSelectModel}
|
|
67
|
+
onThinkingLevelChange={onThinkingLevelChange}
|
|
68
|
+
className={isMobile ? 'min-w-0 max-w-[58%]' : undefined}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
62
71
|
|
|
63
|
-
<div className="w-[1px] h-3 bg-ink/10" />
|
|
72
|
+
{showModel && showThinking && <div className="w-[1px] h-3 bg-ink/10" />}
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
{showThinking && (
|
|
75
|
+
<ThinkingLevelDropdown
|
|
76
|
+
thinkingLevels={thinkingLevels}
|
|
77
|
+
currentThinking={currentThinking}
|
|
78
|
+
onSelect={onSelectThinking}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
70
81
|
|
|
71
|
-
<div className="w-[1px] h-3 bg-ink/10" />
|
|
82
|
+
{(showModel || showThinking) && showAccess && <div className="w-[1px] h-3 bg-ink/10" />}
|
|
72
83
|
|
|
73
|
-
<AccessDropdown value={accessMode} onSelect={onSelectAccess} />
|
|
84
|
+
{showAccess && onSelectAccess && <AccessDropdown value={accessMode} onSelect={onSelectAccess} />}
|
|
74
85
|
</div>
|
|
75
86
|
|
|
76
87
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
|
2
2
|
import type { ClipboardEvent, SetStateAction } from 'preact/compat';
|
|
3
3
|
import { AlertTriangle, X } from 'lucide-preact';
|
|
4
4
|
import type { AIModelOption, Attachment, ModelEntry } from '@/shared/types';
|
|
5
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
6
5
|
import { ComposerToolbar } from '@/client/components/workspace/chat-timeline/chat-input/Toolbar';
|
|
7
6
|
import { ComposerTextarea } from '@/client/components/common/ComposerTextarea';
|
|
8
7
|
import { AttachmentToolbar } from '@/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar';
|
|
@@ -13,6 +12,7 @@ import { NO_PENDING_PICK } from '@/client/hooks/chat/timeline/deferred-model';
|
|
|
13
12
|
import { primeFileReads } from '@/client/hooks/chat/composer/file-reads';
|
|
14
13
|
import { useComposerPipeline } from '@/client/hooks/chat/composer/pipeline';
|
|
15
14
|
import { DropOverlay } from '@/client/components/workspace/chat-timeline/chat-input/DropOverlay';
|
|
15
|
+
import type { ChatInputProps } from '@/client/components/workspace/chat-timeline/chat-input/props';
|
|
16
16
|
|
|
17
17
|
export function ChatInput({
|
|
18
18
|
value,
|
|
@@ -33,6 +33,9 @@ export function ChatInput({
|
|
|
33
33
|
variant = 'desktop',
|
|
34
34
|
accessMode,
|
|
35
35
|
onAccessModeChange,
|
|
36
|
+
showModel = true,
|
|
37
|
+
showThinking = true,
|
|
38
|
+
showAccess = true,
|
|
36
39
|
enablePicker = true,
|
|
37
40
|
placeholder,
|
|
38
41
|
/** Written on every model/thinking pick so the send path can snapshot the
|
|
@@ -44,43 +47,7 @@ export function ChatInput({
|
|
|
44
47
|
* by composers that never stream (the New Chat modal), where there is
|
|
45
48
|
* nothing to defer and adoption must behave as before. */
|
|
46
49
|
deferredComposerPickRef = NO_PENDING_PICK,
|
|
47
|
-
}: {
|
|
48
|
-
value: string;
|
|
49
|
-
onChange: (v: string) => void;
|
|
50
|
-
onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
|
|
51
|
-
isGenerating: boolean;
|
|
52
|
-
onStop?: () => void;
|
|
53
|
-
className?: string;
|
|
54
|
-
disabled?: boolean;
|
|
55
|
-
appSettings?: Record<string, any>;
|
|
56
|
-
attachments?: Attachment[];
|
|
57
|
-
onAttachmentsChange?: (attachments: SetStateAction<Attachment[]>) => void;
|
|
58
|
-
onThinkingLevelChange?: (level: string) => void;
|
|
59
|
-
onModelChange?: (provider: string, modelId: string) => void;
|
|
60
|
-
/** Model last used by the active session (omp `model_change` entry). */
|
|
61
|
-
sessionModel?: { provider: string; modelId: string } | null;
|
|
62
|
-
/** Thinking level last used by the active session (omp `thinking_level_change` entry). */
|
|
63
|
-
sessionThinkingLevel?: string | null;
|
|
64
|
-
rootPath?: string | null;
|
|
65
|
-
accessMode: ApprovalMode;
|
|
66
|
-
onAccessModeChange: (mode: ApprovalMode) => void;
|
|
67
|
-
/**
|
|
68
|
-
* Whether the `@` / `/` / `!` / `#` autocomplete runs. Off for the
|
|
69
|
-
* side-question form, which has no file tree, commands or skills to offer —
|
|
70
|
-
* and whose text is never run through the mention translator.
|
|
71
|
-
*/
|
|
72
|
-
enablePicker?: boolean;
|
|
73
|
-
/** Overrides the default hint text (the side-question form names its own). */
|
|
74
|
-
placeholder?: string;
|
|
75
|
-
/**
|
|
76
|
-
* `mobile` sizes the composer for a phone: 16px text (iOS Safari zooms the
|
|
77
|
-
* viewport when focusing an input below that), thumb-sized send/stop
|
|
78
|
-
* targets, and Enter-to-newline instead of Enter-to-send.
|
|
79
|
-
*/
|
|
80
|
-
variant?: 'desktop' | 'mobile';
|
|
81
|
-
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
82
|
-
deferredComposerPickRef?: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
|
|
83
|
-
}) {
|
|
50
|
+
}: ChatInputProps) {
|
|
84
51
|
const [internalAttachments, setInternalAttachments] = useState<Attachment[]>([]);
|
|
85
52
|
|
|
86
53
|
const attachments = externalAttachments !== undefined ? externalAttachments : internalAttachments;
|
|
@@ -303,7 +270,7 @@ export function ChatInput({
|
|
|
303
270
|
appSettings={appSettings}
|
|
304
271
|
rootPath={rootPath}
|
|
305
272
|
enablePicker={enablePicker}
|
|
306
|
-
placeholder={placeholder ?? (disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills
|
|
273
|
+
placeholder={placeholder ?? (disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills (Paste images/files here)")}
|
|
307
274
|
className={`w-full bg-transparent border-none focus:outline-none resize-none text-ink placeholder-ink/40 disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
308
275
|
isMobile ? 'px-3 py-3 text-base min-h-[68px] max-h-40' : 'px-3 py-3 text-sm min-h-[80px]'
|
|
309
276
|
}`}
|
|
@@ -329,6 +296,9 @@ export function ChatInput({
|
|
|
329
296
|
sendDisabled={disabled || (!value.trim() && attachments.length === 0)}
|
|
330
297
|
accessMode={accessMode}
|
|
331
298
|
onSelectAccess={onAccessModeChange}
|
|
299
|
+
showModel={showModel}
|
|
300
|
+
showThinking={showThinking}
|
|
301
|
+
showAccess={showAccess}
|
|
332
302
|
/>
|
|
333
303
|
</div>
|
|
334
304
|
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The composer's props, split from its implementation so the component file
|
|
8
|
+
* stays under the repo's per-file ceiling. Every field here is documented where
|
|
9
|
+
* it changes behaviour; the component reads them all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SetStateAction } from 'preact/compat';
|
|
13
|
+
import type { Attachment } from '@/shared/types';
|
|
14
|
+
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
15
|
+
|
|
16
|
+
export interface ChatInputProps {
|
|
17
|
+
value: string;
|
|
18
|
+
onChange: (v: string) => void;
|
|
19
|
+
onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
|
|
20
|
+
isGenerating: boolean;
|
|
21
|
+
onStop?: () => void;
|
|
22
|
+
className?: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
appSettings?: Record<string, any>;
|
|
25
|
+
attachments?: Attachment[];
|
|
26
|
+
onAttachmentsChange?: (attachments: SetStateAction<Attachment[]>) => void;
|
|
27
|
+
onThinkingLevelChange?: (level: string) => void;
|
|
28
|
+
onModelChange?: (provider: string, modelId: string) => void;
|
|
29
|
+
/** Model last used by the active session (omp `model_change` entry). */
|
|
30
|
+
sessionModel?: { provider: string; modelId: string } | null;
|
|
31
|
+
/** Thinking level last used by the active session (omp `thinking_level_change` entry). */
|
|
32
|
+
sessionThinkingLevel?: string | null;
|
|
33
|
+
rootPath?: string | null;
|
|
34
|
+
/** The chat session's access mode; omit it on a composer whose access
|
|
35
|
+
* selector is off (the side-question form). */
|
|
36
|
+
accessMode?: ApprovalMode;
|
|
37
|
+
onAccessModeChange?: (mode: ApprovalMode) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Which of the toolbar's selectors render. The side-question composer turns
|
|
40
|
+
* all three off: its child runs on the chat's model and thinking selector and
|
|
41
|
+
* without tools, so a control there would change nothing.
|
|
42
|
+
*/
|
|
43
|
+
showModel?: boolean;
|
|
44
|
+
showThinking?: boolean;
|
|
45
|
+
showAccess?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the `@` / `/` / `!` / `#` autocomplete runs. Off for the
|
|
48
|
+
* side-question form, which has no file tree, commands or skills to offer —
|
|
49
|
+
* and whose text is never run through the mention translator.
|
|
50
|
+
*/
|
|
51
|
+
enablePicker?: boolean;
|
|
52
|
+
/** Overrides the default hint text (the side-question form names its own). */
|
|
53
|
+
placeholder?: string;
|
|
54
|
+
/**
|
|
55
|
+
* `mobile` sizes the composer for a phone: 16px text (iOS Safari zooms the
|
|
56
|
+
* viewport when focusing an input below that), thumb-sized send/stop
|
|
57
|
+
* targets, and Enter-to-newline instead of Enter-to-send.
|
|
58
|
+
*/
|
|
59
|
+
variant?: 'desktop' | 'mobile';
|
|
60
|
+
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
61
|
+
deferredComposerPickRef?: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
|
|
62
|
+
}
|
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
* merge logic of its own and a reload can never disagree with the server.
|
|
13
13
|
* The stream is attached only while the panel is open: a side question is a
|
|
14
14
|
* deliberate action, not background traffic.
|
|
15
|
+
*
|
|
16
|
+
* There are no per-topic pickers here. A side question runs without tools and on
|
|
17
|
+
* the CHAT's model and thinking selector (the server re-reads them from the
|
|
18
|
+
* parent transcript at every ask), which is what omp's own `/btw` does.
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
18
22
|
import type { AgentImage, Attachment, BtwState, ChatMessageData, StreamTransport } from '@/shared/types';
|
|
19
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
20
23
|
import { attachmentImage, readTextAttachments } from '@/shared/lib/chat/attachments';
|
|
21
24
|
import {
|
|
22
25
|
BtwRequestError,
|
|
@@ -26,10 +29,6 @@ import {
|
|
|
26
29
|
deleteBtwTopic,
|
|
27
30
|
fetchBtwState,
|
|
28
31
|
promoteBtwTopic,
|
|
29
|
-
respondBtwDialog,
|
|
30
|
-
setBtwAccessMode,
|
|
31
|
-
setBtwModel,
|
|
32
|
-
setBtwThinkingLevel,
|
|
33
32
|
} from '@/shared/lib/chat/btw/client';
|
|
34
33
|
import type { StreamConnection } from '@/shared/lib/chat/omp/transport';
|
|
35
34
|
|
|
@@ -40,7 +39,7 @@ export type BtwImage = AgentImage;
|
|
|
40
39
|
* The turn streaming right now. `messages` is the turn's conversation in the
|
|
41
40
|
* chat's own shape, upserted by `message.id` exactly as the chat stream does —
|
|
42
41
|
* so the panel renders a live side answer through the same components the chat
|
|
43
|
-
* uses
|
|
42
|
+
* uses.
|
|
44
43
|
*/
|
|
45
44
|
export interface BtwLiveAnswer {
|
|
46
45
|
topicId: string;
|
|
@@ -50,17 +49,6 @@ export interface BtwLiveAnswer {
|
|
|
50
49
|
activity: string;
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
/**
|
|
54
|
-
* Composer picks for a topic's FIRST question. A topic that does not exist yet
|
|
55
|
-
* has no row to write them to, so they ride the ask and reach the spawn; later
|
|
56
|
-
* questions address an existing topic through the dedicated setters.
|
|
57
|
-
*/
|
|
58
|
-
export interface BtwFirstQuestionPicks {
|
|
59
|
-
model?: { provider: string; id: string };
|
|
60
|
-
thinkingLevel?: string;
|
|
61
|
-
approvalMode?: ApprovalMode;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
52
|
export interface BtwSessionHandle {
|
|
65
53
|
state: BtwState | null;
|
|
66
54
|
live: BtwLiveAnswer | null;
|
|
@@ -69,12 +57,8 @@ export interface BtwSessionHandle {
|
|
|
69
57
|
busy: boolean;
|
|
70
58
|
/** Resolves with the canonical state the command produced, or null when it
|
|
71
59
|
* was refused (the reason is then in `error`). */
|
|
72
|
-
ask: (question: string, attachments?: Attachment[], topicId?: string
|
|
60
|
+
ask: (question: string, attachments?: Attachment[], topicId?: string) => Promise<BtwState | null>;
|
|
73
61
|
abort: (topicId: string) => Promise<void>;
|
|
74
|
-
setModel: (topicId: string, provider: string, modelId: string) => Promise<void>;
|
|
75
|
-
setThinkingLevel: (topicId: string, level: string) => Promise<void>;
|
|
76
|
-
setAccessMode: (topicId: string, mode: ApprovalMode) => Promise<void>;
|
|
77
|
-
respondToDialog: (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => Promise<void>;
|
|
78
62
|
/** Returns the new session id to open, or null when it was refused. */
|
|
79
63
|
promote: (topicId: string) => Promise<string | null>;
|
|
80
64
|
remove: (topicId: string) => Promise<void>;
|
|
@@ -186,7 +170,6 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
186
170
|
question: string,
|
|
187
171
|
attachments: Attachment[] = [],
|
|
188
172
|
topicId?: string,
|
|
189
|
-
picks?: BtwFirstQuestionPicks,
|
|
190
173
|
): Promise<BtwState | null> => {
|
|
191
174
|
setError(null);
|
|
192
175
|
// Same split the chat send path makes: images travel as provider payloads,
|
|
@@ -201,9 +184,6 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
201
184
|
topicId,
|
|
202
185
|
...(images.length ? { images } : {}),
|
|
203
186
|
...(textFiles.length ? { textFiles } : {}),
|
|
204
|
-
// Only a first question carries picks; a follow-up addresses a topic
|
|
205
|
-
// that already owns them.
|
|
206
|
-
...(topicId ? {} : picks ?? {}),
|
|
207
187
|
}));
|
|
208
188
|
setLive(null);
|
|
209
189
|
if (next && !next.runningTopicId) {
|
|
@@ -222,38 +202,6 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
222
202
|
[run],
|
|
223
203
|
);
|
|
224
204
|
|
|
225
|
-
const setModel = useCallback(
|
|
226
|
-
async (topicId: string, provider: string, modelId: string) => {
|
|
227
|
-
setError(null);
|
|
228
|
-
await run((id) => setBtwModel(id, topicId, provider, modelId));
|
|
229
|
-
},
|
|
230
|
-
[run],
|
|
231
|
-
);
|
|
232
|
-
|
|
233
|
-
const setThinkingLevel = useCallback(
|
|
234
|
-
async (topicId: string, level: string) => {
|
|
235
|
-
setError(null);
|
|
236
|
-
await run((id) => setBtwThinkingLevel(id, topicId, level));
|
|
237
|
-
},
|
|
238
|
-
[run],
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
const setAccessMode = useCallback(
|
|
242
|
-
async (topicId: string, mode: ApprovalMode) => {
|
|
243
|
-
setError(null);
|
|
244
|
-
await run((id) => setBtwAccessMode(id, topicId, mode));
|
|
245
|
-
},
|
|
246
|
-
[run],
|
|
247
|
-
);
|
|
248
|
-
|
|
249
|
-
const respondToDialog = useCallback(
|
|
250
|
-
async (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => {
|
|
251
|
-
setError(null);
|
|
252
|
-
await run((requestId) => respondBtwDialog(requestId, topicId, id, response));
|
|
253
|
-
},
|
|
254
|
-
[run],
|
|
255
|
-
);
|
|
256
|
-
|
|
257
205
|
const remove = useCallback(
|
|
258
206
|
async (topicId: string) => {
|
|
259
207
|
setError(null);
|
|
@@ -283,19 +231,5 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
283
231
|
|
|
284
232
|
const clearError = useCallback(() => setError(null), []);
|
|
285
233
|
|
|
286
|
-
return {
|
|
287
|
-
state,
|
|
288
|
-
live,
|
|
289
|
-
error,
|
|
290
|
-
busy,
|
|
291
|
-
ask,
|
|
292
|
-
abort,
|
|
293
|
-
setModel,
|
|
294
|
-
setThinkingLevel,
|
|
295
|
-
setAccessMode,
|
|
296
|
-
respondToDialog,
|
|
297
|
-
promote,
|
|
298
|
-
remove,
|
|
299
|
-
clearError,
|
|
300
|
-
};
|
|
234
|
+
return { state, live, error, busy, ask, abort, promote, remove, clearError };
|
|
301
235
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/btw` interception.
|
|
8
|
+
*
|
|
9
|
+
* The token must be recognized the way omp parses a slash invocation — on the
|
|
10
|
+
* earliest whitespace OR `:` — because a form the chamber does not intercept is
|
|
11
|
+
* sent to the model as literal text (omp's `/btw` is TUI-only, so nothing
|
|
12
|
+
* downstream understands it).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `bun test` runs without a DOM; the interceptor only needs an event target.
|
|
20
|
+
*
|
|
21
|
+
* The stub is installed for this file alone and REMOVED afterwards: several
|
|
22
|
+
* other files in this suite install their own DOM by assigning `globalThis`,
|
|
23
|
+
* and a `window` left behind here replaced theirs (measured: `use-file-editor`
|
|
24
|
+
* failed with `form.get is not a function`). The descriptor is `writable` for
|
|
25
|
+
* the same reason — those files assign rather than define.
|
|
26
|
+
*/
|
|
27
|
+
const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true, writable: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(() => {
|
|
34
|
+
if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
|
|
35
|
+
else delete (globalThis as Record<string, unknown>).window;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function capture(text: string): { handled: boolean; detail: unknown } {
|
|
39
|
+
let detail: unknown;
|
|
40
|
+
const listener = (event: Event) => {
|
|
41
|
+
detail = (event as CustomEvent).detail;
|
|
42
|
+
};
|
|
43
|
+
window.addEventListener('omp:btw', listener);
|
|
44
|
+
try {
|
|
45
|
+
const handled = dispatchBtwCommand(text, []);
|
|
46
|
+
return { handled, detail };
|
|
47
|
+
} finally {
|
|
48
|
+
window.removeEventListener('omp:btw', listener);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('dispatchBtwCommand', () => {
|
|
53
|
+
test('a bare command opens the panel with no question', () => {
|
|
54
|
+
const { handled, detail } = capture('/btw');
|
|
55
|
+
expect(handled).toBe(true);
|
|
56
|
+
expect(detail).toEqual({ question: '' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a whitespace-separated question is carried', () => {
|
|
60
|
+
const { handled, detail } = capture('/btw what is this?');
|
|
61
|
+
expect(handled).toBe(true);
|
|
62
|
+
expect(detail).toEqual({ question: 'what is this?' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a colon-separated question is the same invocation', () => {
|
|
66
|
+
const { handled, detail } = capture('/btw:what is this?');
|
|
67
|
+
expect(handled).toBe(true);
|
|
68
|
+
expect(detail).toEqual({ question: 'what is this?' });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('case does not matter', () => {
|
|
72
|
+
expect(capture('/BTW hi').handled).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('anything else is left for the normal send path', () => {
|
|
76
|
+
expect(capture('hello /btw').handled).toBe(false);
|
|
77
|
+
expect(capture('/btwx').handled).toBe(false);
|
|
78
|
+
expect(capture('').handled).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -23,8 +23,11 @@
|
|
|
23
23
|
import type { Attachment } from '@/shared/types';
|
|
24
24
|
|
|
25
25
|
/** A bare `/btw` opens the form on the current history; anything after it is
|
|
26
|
-
* the first question. Case-insensitive, because omp's command tokens are
|
|
27
|
-
|
|
26
|
+
* the first question. Case-insensitive, because omp's command tokens are, and
|
|
27
|
+
* the separator is whitespace OR `:` — `parseSlashCommand` splits on whichever
|
|
28
|
+
* comes first, so `/btw:what is this` is the same invocation as `/btw what is
|
|
29
|
+
* this` and must not be sent to the model as text. */
|
|
30
|
+
const BTW_COMMAND_RE = /^\/btw(?:[\s:]+([\s\S]*))?$/i;
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Hand a `/btw` draft to the side-question panel. Returns false when the text
|