ompchamber 3.3.2 → 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-e3axkbk1.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/assets/font-css.server.test.ts +87 -0
- package/src/server/lib/assets/font-css.server.ts +46 -20
- package/src/server/lib/assets/font-faces.ts +69 -0
- package/src/server/lib/assets/fonts.server.ts +27 -4
- 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/bundler/build-errors.server.ts +8 -3
- package/src/server/lib/bundler/css.ts +12 -61
- 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/plugins/ssr.ts +4 -4
- package/src/server/plugins/static.ts +14 -10
- 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
|
@@ -14,28 +14,24 @@
|
|
|
14
14
|
*
|
|
15
15
|
* `/btw [question]` in the main composer is the entry point; a question that
|
|
16
16
|
* arrived with the command is asked immediately, which is what the TUI does.
|
|
17
|
+
*
|
|
18
|
+
* The panel has no model / thinking / access pickers: a side question runs on
|
|
19
|
+
* the chat's own model and thinking selector and without tools, so a control
|
|
20
|
+
* here would promise something the child does not read.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
import { useCallback, useState } from 'preact/hooks';
|
|
20
|
-
import type { Attachment,
|
|
21
|
-
import type
|
|
22
|
-
import { useBtwSession, type BtwFirstQuestionPicks, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
|
|
24
|
+
import type { Attachment, BtwState, BtwTopic, ChatMessageData } from '@/shared/types';
|
|
25
|
+
import { useBtwSession, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
|
|
23
26
|
import { useChamberEvent } from '@/client/hooks/ui/window-event';
|
|
24
27
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
25
28
|
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
26
29
|
import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
|
|
27
|
-
import { normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
28
30
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
29
|
-
import { readChamberSetting } from '@/shared/lib/settings/client';
|
|
30
31
|
|
|
31
32
|
/** Question sent when a question arrived carrying only images. */
|
|
32
33
|
const ATTACHMENT_ONLY_QUESTION = 'Describe the attached image.';
|
|
33
34
|
|
|
34
|
-
/** The composer's persisted access pick, for a topic that has none yet. */
|
|
35
|
-
function readAccessMode(appSettings: Record<string, unknown>): ApprovalMode {
|
|
36
|
-
return normalizeApprovalMode(readChamberSetting<unknown>('omp_access_mode', appSettings));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
35
|
export interface BtwMode {
|
|
40
36
|
open: boolean;
|
|
41
37
|
topics: BtwTopic[];
|
|
@@ -49,11 +45,6 @@ export interface BtwMode {
|
|
|
49
45
|
/** What that turn is doing, for the panel's indicator (side stream's own). */
|
|
50
46
|
liveVerb?: string;
|
|
51
47
|
error: string | null;
|
|
52
|
-
/** Ask/approval dialogs the side child is blocked on, oldest first. */
|
|
53
|
-
dialogs: BtwDialog[];
|
|
54
|
-
/** Access mode the active topic's child runs under. */
|
|
55
|
-
accessMode: ApprovalMode;
|
|
56
|
-
thinkingLevel: string;
|
|
57
48
|
askDraft: string;
|
|
58
49
|
followUpDraft: string;
|
|
59
50
|
setAskDraft: (value: string) => void;
|
|
@@ -68,12 +59,8 @@ export interface BtwMode {
|
|
|
68
59
|
submitAsk: () => void;
|
|
69
60
|
/** Submit the side session's composer: a follow-up to the active topic. */
|
|
70
61
|
submitFollowUp: (question: string, attachments?: Attachment[]) => void;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
setModel: (provider: string, modelId: string) => void;
|
|
74
|
-
setThinkingLevel: (level: string) => void;
|
|
75
|
-
setAccessMode: (mode: ApprovalMode) => void;
|
|
76
|
-
respondToDialog: (id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => void;
|
|
62
|
+
/** Cancel the running turn, if any. Returns whether one was running. */
|
|
63
|
+
abort: () => boolean;
|
|
77
64
|
/** Returns the session id to open, or null when promotion was refused. */
|
|
78
65
|
promote: () => Promise<string | null>;
|
|
79
66
|
removeTopic: (topicId: string) => void;
|
|
@@ -121,16 +108,6 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
121
108
|
*/
|
|
122
109
|
const liveVerb = live?.activity || (runningTopicId ? PHASE_VERBS.sideQuestion : undefined);
|
|
123
110
|
|
|
124
|
-
/**
|
|
125
|
-
* Composer picks made before a topic exists. They are per-session UI state
|
|
126
|
-
* rather than props, because the form must show what the FIRST question will
|
|
127
|
-
* actually run with — the topic that would otherwise own these values has not
|
|
128
|
-
* been created yet.
|
|
129
|
-
*/
|
|
130
|
-
const [pendingModel, setPendingModel] = useSessionState<BtwFirstQuestionPicks['model'] | null>('chat.btwModel', null);
|
|
131
|
-
const [pendingThinking, setPendingThinking] = useSessionState<string>('chat.btwThinking', 'auto');
|
|
132
|
-
const [pendingAccess, setPendingAccess] = useSessionState<ApprovalMode | null>('chat.btwAccess', null);
|
|
133
|
-
|
|
134
111
|
const selectNewest = useCallback(
|
|
135
112
|
(next: BtwState | null) => {
|
|
136
113
|
if (!next) return;
|
|
@@ -151,27 +128,12 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
151
128
|
}
|
|
152
129
|
});
|
|
153
130
|
|
|
154
|
-
/**
|
|
155
|
-
* What the next FIRST question will run with: the composer's picks until a
|
|
156
|
-
* topic exists, then that topic's own settings. Every setter below writes to
|
|
157
|
-
* the topic when there is one and to this pending slot when there is not —
|
|
158
|
-
* a pick that only reached the UI would silently not apply.
|
|
159
|
-
*/
|
|
160
|
-
const firstQuestionPicks = useCallback(
|
|
161
|
-
(): BtwFirstQuestionPicks => ({
|
|
162
|
-
...(pendingModel ? { model: pendingModel } : {}),
|
|
163
|
-
...(pendingThinking && pendingThinking !== 'auto' ? { thinkingLevel: pendingThinking } : {}),
|
|
164
|
-
...(pendingAccess ? { approvalMode: pendingAccess } : {}),
|
|
165
|
-
}),
|
|
166
|
-
[pendingModel, pendingThinking, pendingAccess],
|
|
167
|
-
);
|
|
168
|
-
|
|
169
131
|
const submitAsk = useCallback(() => {
|
|
170
132
|
const question = askDraft.trim();
|
|
171
133
|
if (!question) return;
|
|
172
134
|
setAskDraft('');
|
|
173
|
-
void session.ask(question
|
|
174
|
-
}, [askDraft, session, setAskDraft, selectNewest
|
|
135
|
+
void session.ask(question).then(selectNewest);
|
|
136
|
+
}, [askDraft, session, setAskDraft, selectNewest]);
|
|
175
137
|
|
|
176
138
|
const submitFollowUp = useCallback(
|
|
177
139
|
(question: string, attachments?: Attachment[]) => {
|
|
@@ -220,12 +182,6 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
220
182
|
liveMessages: live ? live.messages : [],
|
|
221
183
|
liveVerb,
|
|
222
184
|
error: session.error,
|
|
223
|
-
dialogs: session.state?.dialogs ?? [],
|
|
224
|
-
// Before a topic exists the composer's own picks are what the next question
|
|
225
|
-
// will run with, so they are what the controls must show — and after it,
|
|
226
|
-
// the topic's own settings (which the pick was written to).
|
|
227
|
-
accessMode: activeTopic?.approvalMode ?? pendingAccess ?? readAccessMode(appSettings),
|
|
228
|
-
thinkingLevel: activeTopic?.thinkingLevel ?? pendingThinking,
|
|
229
185
|
askDraft,
|
|
230
186
|
followUpDraft,
|
|
231
187
|
setAskDraft,
|
|
@@ -239,29 +195,13 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
239
195
|
},
|
|
240
196
|
submitAsk,
|
|
241
197
|
submitFollowUp,
|
|
198
|
+
// Escape (and the composer's Stop) cancel the turn BEFORE leaving: omp's
|
|
199
|
+
// Escape handler cancels a running answer and only then closes the panel, so
|
|
200
|
+
// a stray keypress cannot abandon a question mid-flight.
|
|
242
201
|
abort: () => {
|
|
243
|
-
if (runningTopicId)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!activeTopic) {
|
|
247
|
-
setPendingModel({ provider, id: modelId });
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
void session.setModel(activeTopic.id, provider, modelId);
|
|
251
|
-
},
|
|
252
|
-
setThinkingLevel: (level: string) => {
|
|
253
|
-
setPendingThinking(level);
|
|
254
|
-
if (!activeTopic) return;
|
|
255
|
-
void session.setThinkingLevel(activeTopic.id, level);
|
|
256
|
-
},
|
|
257
|
-
setAccessMode: (mode: ApprovalMode) => {
|
|
258
|
-
setPendingAccess(mode);
|
|
259
|
-
if (!activeTopic) return;
|
|
260
|
-
void session.setAccessMode(activeTopic.id, mode);
|
|
261
|
-
},
|
|
262
|
-
respondToDialog: (id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => {
|
|
263
|
-
if (!activeTopic) return;
|
|
264
|
-
void session.respondToDialog(activeTopic.id, id, response);
|
|
202
|
+
if (!runningTopicId) return false;
|
|
203
|
+
void session.abort(runningTopicId);
|
|
204
|
+
return true;
|
|
265
205
|
},
|
|
266
206
|
promote,
|
|
267
207
|
removeTopic,
|
|
@@ -11,13 +11,15 @@
|
|
|
11
11
|
* record — captured semantics are unchanged.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { useCallback } from 'preact/hooks';
|
|
14
|
+
import { useCallback, useMemo } from 'preact/hooks';
|
|
15
15
|
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
19
|
import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
|
|
20
20
|
import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
|
|
21
|
+
import { blockTuiOnlySend } from '@/client/hooks/chat/timeline/tui-only-guard';
|
|
22
|
+
import { createQueueActions } from '@/client/hooks/chat/timeline/queue-actions';
|
|
21
23
|
import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
|
|
22
24
|
|
|
23
25
|
export interface ChatTimelineActionsDeps {
|
|
@@ -76,14 +78,12 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
76
78
|
const {
|
|
77
79
|
inputValue,
|
|
78
80
|
setInputValue,
|
|
79
|
-
setInputAttachments,
|
|
80
81
|
isGenerating,
|
|
81
82
|
isOmpSession,
|
|
82
83
|
sessionId,
|
|
83
84
|
appSettings,
|
|
84
85
|
messageQueue,
|
|
85
86
|
enqueueMessage,
|
|
86
|
-
removeMessage,
|
|
87
87
|
executeSend,
|
|
88
88
|
steerOmpAgent,
|
|
89
89
|
ompAgent,
|
|
@@ -100,6 +100,10 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
100
100
|
setSearchParams,
|
|
101
101
|
} = deps;
|
|
102
102
|
|
|
103
|
+
// The queue's two row actions take the same slice of this deps record, so
|
|
104
|
+
// they are built once per render from it rather than re-listing every field.
|
|
105
|
+
const queueActions = useMemo(() => createQueueActions(deps), [deps]);
|
|
106
|
+
|
|
103
107
|
const handleSend = useCallback(async (attachments: Attachment[], options?: { steering?: boolean }) => {
|
|
104
108
|
const textToSend = inputValue.trim();
|
|
105
109
|
if (!textToSend && attachments.length === 0) return;
|
|
@@ -111,11 +115,21 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
111
115
|
// `/btw [question]` is the side-question entry point, not chat text: the
|
|
112
116
|
// panel owns it (omp's `/btw` is TUI-only, so nothing downstream would
|
|
113
117
|
// understand the token). A bare `/btw` only opens the panel's history.
|
|
118
|
+
//
|
|
119
|
+
// MUST run before the TUI-only guard below: `/btw` is in that table (omp
|
|
120
|
+
// implements it in the TUI only), so the guard would refuse the very
|
|
121
|
+
// command the chamber answers with a panel.
|
|
114
122
|
if (dispatchBtwCommand(textToSend, attachments)) {
|
|
115
123
|
setInputValue('');
|
|
116
124
|
return;
|
|
117
125
|
}
|
|
118
126
|
|
|
127
|
+
// A command omp only implements in its TUI (`/plan`, `/clear`, `/login`, …)
|
|
128
|
+
// would reach the model as literal text over RPC and burn a whole turn on
|
|
129
|
+
// an improvisation. Answer it here, before it is queued or steered, and
|
|
130
|
+
// keep the draft so the user can edit or retype it.
|
|
131
|
+
if (blockTuiOnlySend(textToSend, setLocalMessages)) return;
|
|
132
|
+
|
|
119
133
|
if (isGenerating) {
|
|
120
134
|
if (options?.steering) {
|
|
121
135
|
// Explicit steering while a run is active.
|
|
@@ -161,37 +175,15 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
161
175
|
|
|
162
176
|
setInputValue('');
|
|
163
177
|
executeSend(textToSend, attachments);
|
|
164
|
-
}, [inputValue, isGenerating, executeSend, enqueueMessage, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef]);
|
|
178
|
+
}, [inputValue, isGenerating, executeSend, enqueueMessage, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef, setLocalMessages]);
|
|
165
179
|
|
|
166
180
|
const handleEditQueueItem = useCallback((item: QueuedMessage) => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
// dead window here where removing from the local list was the only edit.
|
|
170
|
-
setInputValue(item.text);
|
|
171
|
-
setInputAttachments(item.attachments);
|
|
172
|
-
removeMessage(item.id);
|
|
173
|
-
}, [removeMessage, setInputValue, setInputAttachments]);
|
|
181
|
+
queueActions.handleEditQueueItem(item);
|
|
182
|
+
}, [queueActions]);
|
|
174
183
|
|
|
175
184
|
const handleSendNowQueueItem = useCallback(async (item: QueuedMessage) => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
removeMessage(item.id);
|
|
179
|
-
|
|
180
|
-
if (isGenerating) {
|
|
181
|
-
if (isOmpSession) {
|
|
182
|
-
void steerOmpAgent(item.text, item.attachments);
|
|
183
|
-
} else {
|
|
184
|
-
if (abortControllerRef.current) {
|
|
185
|
-
abortControllerRef.current.abort();
|
|
186
|
-
abortControllerRef.current = null;
|
|
187
|
-
}
|
|
188
|
-
setGenerating(false);
|
|
189
|
-
setTimeout(() => executeSend(item.text, item.attachments, { model: item.model }), 0);
|
|
190
|
-
}
|
|
191
|
-
} else {
|
|
192
|
-
executeSend(item.text, item.attachments, { model: item.model });
|
|
193
|
-
}
|
|
194
|
-
}, [isGenerating, executeSend, removeMessage, isOmpSession, steerOmpAgent, abortControllerRef, setGenerating, stopHoldRef]);
|
|
185
|
+
await queueActions.handleSendNowQueueItem(item);
|
|
186
|
+
}, [queueActions]);
|
|
195
187
|
|
|
196
188
|
const handleUndo = useCallback(async (msgId: string, content?: string): Promise<boolean> => {
|
|
197
189
|
if (isGenerating) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Queue-row actions for the chat timeline: editing a queued item back into the
|
|
8
|
+
* composer, and delivering one immediately.
|
|
9
|
+
*
|
|
10
|
+
* Split out of `actions.ts` so that hook keeps to the repo's per-file ceiling.
|
|
11
|
+
* Both handlers take the same deps record, and both are about ONE row of the
|
|
12
|
+
* follow-up queue rather than about the composer's own send path — which is the
|
|
13
|
+
* seam this file draws.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
17
|
+
import type { Attachment, QueuedMessageModel } from '@/shared/types';
|
|
18
|
+
import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
|
|
19
|
+
|
|
20
|
+
export interface QueueActionsDeps {
|
|
21
|
+
setInputValue: (v: string) => void;
|
|
22
|
+
setInputAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
|
23
|
+
removeMessage: (id: string) => void;
|
|
24
|
+
/** Armed by Stop; the queue auto-process holds off while it is set. */
|
|
25
|
+
stopHoldRef: { current: boolean };
|
|
26
|
+
isGenerating: boolean;
|
|
27
|
+
isOmpSession: boolean;
|
|
28
|
+
steerOmpAgent: (text: string, attachments: Attachment[]) => Promise<void>;
|
|
29
|
+
abortControllerRef: { current: AbortController | null };
|
|
30
|
+
setGenerating: (v: boolean) => void;
|
|
31
|
+
executeSend: (
|
|
32
|
+
text: string,
|
|
33
|
+
attachments: Attachment[],
|
|
34
|
+
options?: { model?: QueuedMessageModel | null },
|
|
35
|
+
) => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface QueueActions {
|
|
39
|
+
handleEditQueueItem: (item: QueuedMessage) => void;
|
|
40
|
+
handleSendNowQueueItem: (item: QueuedMessage) => Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createQueueActions(deps: QueueActionsDeps): QueueActions {
|
|
44
|
+
const {
|
|
45
|
+
setInputValue,
|
|
46
|
+
setInputAttachments,
|
|
47
|
+
removeMessage,
|
|
48
|
+
stopHoldRef,
|
|
49
|
+
isGenerating,
|
|
50
|
+
isOmpSession,
|
|
51
|
+
steerOmpAgent,
|
|
52
|
+
abortControllerRef,
|
|
53
|
+
setGenerating,
|
|
54
|
+
executeSend,
|
|
55
|
+
} = deps;
|
|
56
|
+
return {
|
|
57
|
+
/** Lift the row's text into the composer and drop the row. If the user
|
|
58
|
+
* never re-submits, the delete already removed it — the old flow had a
|
|
59
|
+
* dead window here where removing from the local list was the only edit. */
|
|
60
|
+
handleEditQueueItem: (item) => {
|
|
61
|
+
setInputValue(item.text);
|
|
62
|
+
setInputAttachments(item.attachments);
|
|
63
|
+
removeMessage(item.id);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** Deliver one row now, ahead of the queue. Explicit delivery also lifts
|
|
67
|
+
* the Stop hold, so the auto-process may resume after this run ends. */
|
|
68
|
+
handleSendNowQueueItem: async (item) => {
|
|
69
|
+
stopHoldRef.current = false;
|
|
70
|
+
removeMessage(item.id);
|
|
71
|
+
|
|
72
|
+
if (isGenerating) {
|
|
73
|
+
if (isOmpSession) {
|
|
74
|
+
void steerOmpAgent(item.text, item.attachments);
|
|
75
|
+
} else {
|
|
76
|
+
if (abortControllerRef.current) {
|
|
77
|
+
abortControllerRef.current.abort();
|
|
78
|
+
abortControllerRef.current = null;
|
|
79
|
+
}
|
|
80
|
+
setGenerating(false);
|
|
81
|
+
setTimeout(() => executeSend(item.text, item.attachments, { model: item.model }), 0);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
executeSend(item.text, item.attachments, { model: item.model });
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
readTextAttachments,
|
|
27
27
|
} from '@/shared/lib/chat/attachments';
|
|
28
28
|
import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
|
|
29
|
+
import { blockTuiOnlySend } from '@/client/hooks/chat/timeline/tui-only-guard';
|
|
29
30
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
30
31
|
import { formatClock } from '@/shared/lib/format/time';
|
|
31
32
|
|
|
@@ -135,6 +136,12 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
135
136
|
attachments: Attachment[],
|
|
136
137
|
options?: { model?: QueuedMessageModel | null },
|
|
137
138
|
) => {
|
|
139
|
+
// Last gate before a prompt leaves the client. `handleSend` already refuses
|
|
140
|
+
// these, but `executeSend` is also reached by a retry of a stored turn, a
|
|
141
|
+
// queued item's "Send now", and `submitNewChat` — all of which can carry a
|
|
142
|
+
// command that was typed before the guard existed, or that arrived from the
|
|
143
|
+
// queue. Nothing has been rendered yet, so no rollback is needed.
|
|
144
|
+
if (blockTuiOnlySend(text, setLocalMessages)) return;
|
|
138
145
|
// A queued delivery replays the snapshot the item was queued with; a plain
|
|
139
146
|
// send keeps the session's live picks. 'auto' thinking leaves omp alone.
|
|
140
147
|
const modelOverride = options?.model ?? null;
|
|
@@ -304,6 +311,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
304
311
|
scrollToBottom,
|
|
305
312
|
})
|
|
306
313
|
);
|
|
307
|
-
}, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, deferredComposerPickRef, accessModeRef]);
|
|
314
|
+
}, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, deferredComposerPickRef, accessModeRef, setLocalMessages]);
|
|
308
315
|
return { steerOmpAgent, executeSend };
|
|
309
316
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Refuse a slash command omp only implements in its own TUI, in the composer,
|
|
8
|
+
* before it can cost a turn.
|
|
9
|
+
*
|
|
10
|
+
* The guard exists because the RPC prompt path has no slash handling for these
|
|
11
|
+
* names: `session.prompt()` only expands file commands and prompt templates, so
|
|
12
|
+
* `/plan` arrives at the model as literal text and it improvises a whole turn
|
|
13
|
+
* around the string (measured: `/hotkeys` sent the model to read omp's own docs
|
|
14
|
+
* and run bash). The composer already knows the token, so it answers locally
|
|
15
|
+
* with the same notice row shape a real `command_output` frame produces — the
|
|
16
|
+
* user sees why nothing ran, and the model never sees the command.
|
|
17
|
+
*
|
|
18
|
+
* Every send path funnels through here: a fresh send, a steer, a retry of a
|
|
19
|
+
* stored turn, and a queued item's "send now". Queued auto-delivery is covered
|
|
20
|
+
* server-side (`blockedSlashPrompt` in the RPC dispatcher), because it never
|
|
21
|
+
* passes through this module.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
25
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
26
|
+
import { isTuiOnlySlashCommand, tuiOnlyCommandNotice } from '@/shared/lib/chat/composer/tui-only';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Append the refusal as a notice row, the same shape `onCommandOutput` builds.
|
|
30
|
+
* Placed at the tail: no AI placeholder exists yet, so there is nothing to
|
|
31
|
+
* insert before.
|
|
32
|
+
*/
|
|
33
|
+
export function appendTuiOnlyNotice(
|
|
34
|
+
text: string,
|
|
35
|
+
setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>,
|
|
36
|
+
): void {
|
|
37
|
+
const row: ChatMessageData = {
|
|
38
|
+
id: `tui-only-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
39
|
+
role: 'ai',
|
|
40
|
+
content: '',
|
|
41
|
+
notice: tuiOnlyCommandNotice(text),
|
|
42
|
+
};
|
|
43
|
+
setLocalMessages(prev => (prev.some(m => m.notice === row.notice) ? prev : [...prev, row]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the draft is a TUI-only command and was answered locally. A true
|
|
48
|
+
* result means the caller must stop — nothing was sent, and the draft is left
|
|
49
|
+
* in the composer so the user can edit or retype it.
|
|
50
|
+
*/
|
|
51
|
+
export function blockTuiOnlySend(
|
|
52
|
+
text: string,
|
|
53
|
+
setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>,
|
|
54
|
+
): boolean {
|
|
55
|
+
if (!isTuiOnlySlashCommand(text)) return false;
|
|
56
|
+
appendTuiOnlyNotice(text, setLocalMessages);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/fonts.css` is derived from the SOURCE stylesheets on every request, and this
|
|
8
|
+
* is the gate that keeps it that way.
|
|
9
|
+
*
|
|
10
|
+
* The sheet used to be assembled from a directory the bundler wrote as a side
|
|
11
|
+
* effect of bundling a CSS module — so it was produced once per process and
|
|
12
|
+
* could never be repaired. An `rm -rf` while the server ran, or a fresh clone
|
|
13
|
+
* that never built, left the route answering 0 bytes for the rest of that
|
|
14
|
+
* process and the terminal's Nerd symbols face vanished: private-use glyphs
|
|
15
|
+
* became tofu boxes with no error anywhere. These assertions fail if the sheet
|
|
16
|
+
* ever depends on a build artifact again.
|
|
17
|
+
*
|
|
18
|
+
* The last case is the end-to-end one: every font basename the sheet names must
|
|
19
|
+
* resolve to a file the server actually serves. A face pointing at a basename
|
|
20
|
+
* no package ships is a 404 the browser swallows, which looks identical to the
|
|
21
|
+
* font never having been declared.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, expect, test } from 'bun:test';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { fontStylesheet, serveFontFile } from '@/server/lib/assets/font-css.server';
|
|
29
|
+
import { packageDir } from '@/server/lib/assets/fonts.server';
|
|
30
|
+
|
|
31
|
+
const sheet = await fontStylesheet();
|
|
32
|
+
|
|
33
|
+
/** Every `url(...)` target in the sheet. */
|
|
34
|
+
function urls(css: string): string[] {
|
|
35
|
+
return [...css.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)].map(([, , target]) => target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('/fonts.css is assembled from source, not from a build artifact', () => {
|
|
39
|
+
test('it declares the bundled Nerd symbols face', () => {
|
|
40
|
+
expect(sheet).toContain('OMPChamber Nerd Symbols');
|
|
41
|
+
expect(urls(sheet)).toContain('/fonts/symbols-nerd-font-mono.woff2');
|
|
42
|
+
// The range is what keeps the 1.1 MB face off every machine that has a
|
|
43
|
+
// Nerd Font of its own, so a sheet without it is a different contract.
|
|
44
|
+
expect(sheet).toContain('unicode-range');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('it declares the text faces the bundle cannot carry', () => {
|
|
48
|
+
expect(sheet).toContain('Fira Code');
|
|
49
|
+
expect(sheet).toContain('KaTeX_Main');
|
|
50
|
+
expect(urls(sheet)).toContain('/fonts/fira-code-latin-400-normal.woff2');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('every url is rewritten to the served route — no relative or node_modules path survives', () => {
|
|
54
|
+
const targets = urls(sheet);
|
|
55
|
+
expect(targets.length).toBeGreaterThan(20);
|
|
56
|
+
for (const target of targets) {
|
|
57
|
+
expect(target.startsWith('/fonts/')).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('every basename the sheet names resolves to a served file', async () => {
|
|
62
|
+
for (const target of urls(sheet)) {
|
|
63
|
+
const response = await serveFontFile(target.slice('/fonts/'.length));
|
|
64
|
+
expect(response).not.toBeNull();
|
|
65
|
+
expect(response?.headers.get('content-type')).toMatch(/^font\//);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('the source tree is resolved by the package, not by the dependencies', () => {
|
|
71
|
+
// In a hoisted install the package's own files live under
|
|
72
|
+
// `node_modules/ompchamber/` while its dependencies sit in the parent
|
|
73
|
+
// `node_modules/` — so `packageRoot()` (the first root holding
|
|
74
|
+
// `node_modules`) is the PARENT, and looking for `src/` beside the hoisted
|
|
75
|
+
// packages finds nothing. That served an empty sheet and silently dropped
|
|
76
|
+
// every `@font-face`; the stylesheet has to resolve through `packageDir()`.
|
|
77
|
+
test('the stylesheets resolve through packageDir, and it finds the source tree', () => {
|
|
78
|
+
// This repo is a source checkout, where `packageDir()` and `packageRoot()`
|
|
79
|
+
// coincide. The assertion that matters is that the source tree is found AT
|
|
80
|
+
// ALL — the hoisted layout above is the case that breaks, and it cannot be
|
|
81
|
+
// staged from inside the repo without rewriting the module's own search
|
|
82
|
+
// roots, so the end-to-end proof for it lives in the manual run against a
|
|
83
|
+
// simulated published install.
|
|
84
|
+
expect(existsSync(join(packageDir(), 'src'))).toBe(true);
|
|
85
|
+
expect(existsSync(join(packageDir(), 'src', 'shared', 'lib', 'fonts', 'nerd-symbols.css'))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -10,32 +10,44 @@
|
|
|
10
10
|
* local `url()`, and a font is always a local file — so a bundled face is either
|
|
11
11
|
* a base64 blob or an absolute filesystem path. `lib/bundler/css.ts` lifts each
|
|
12
12
|
* block out of its stylesheet and rewrites the urls to `<FONT_ROUTE_PREFIX><basename>`;
|
|
13
|
-
* this module
|
|
13
|
+
* this module re-declares them on `/fonts.css`.
|
|
14
14
|
*
|
|
15
15
|
* Two jobs, one route each:
|
|
16
16
|
*
|
|
17
|
-
* - `FONT_STYLESHEET_ROUTE` (`/fonts.css`) concatenates the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* - `FONT_STYLESHEET_ROUTE` (`/fonts.css`) concatenates the faces, read from the
|
|
18
|
+
* source stylesheets under `src/` on every request. It is a handful of
|
|
19
|
+
* kilobytes and four small files, and reading them is what keeps the sheet
|
|
20
|
+
* correct for the process's whole life — the alternative, a directory the
|
|
21
|
+
* bundler wrote, is only ever produced as a side effect of a CSS module being
|
|
22
|
+
* bundled, so it is written once per process and can never be repaired: an
|
|
23
|
+
* `rm -rf` while the server runs (or a fresh clone that never ran a build)
|
|
24
|
+
* leaves `/fonts.css` empty for the rest of that process, and the terminal's
|
|
25
|
+
* Nerd symbols face silently disappears — the glyphs become tofu boxes with no
|
|
26
|
+
* error anywhere.
|
|
27
|
+
*
|
|
28
|
+
* Only a face whose `src` names a local font is re-declared, so a stylesheet
|
|
29
|
+
* carrying a remote face contributes nothing (see `font-faces.ts`). The faces
|
|
30
|
+
* the client bundle imports all live under `src/`, which is what makes the
|
|
31
|
+
* source tree the honest place to read them from: the same files the bundler
|
|
32
|
+
* strips are the files the browser is served.
|
|
22
33
|
*
|
|
23
34
|
* - `FONT_ROUTE_PREFIX` (`/fonts/<basename>`) serves the file itself from the
|
|
24
35
|
* installed packages. The basename is the whole key, so the lookup is an index
|
|
25
36
|
* rather than a path built from the request; see `fonts.server.ts`.
|
|
26
37
|
*/
|
|
27
38
|
|
|
28
|
-
import { join } from 'path';
|
|
39
|
+
import { dirname, join } from 'path';
|
|
29
40
|
import { Glob } from 'bun';
|
|
30
41
|
|
|
31
|
-
import {
|
|
32
|
-
|
|
33
|
-
/** Directory holding one extracted face file per source stylesheet. */
|
|
34
|
-
export const FONT_FACE_DIR = join('.ompchamber-build', 'font-faces');
|
|
42
|
+
import { splitFontFaces } from '@/server/lib/assets/font-faces';
|
|
43
|
+
import { packageDir, resolveFontFile } from '@/server/lib/assets/fonts.server';
|
|
35
44
|
|
|
36
45
|
/** The route the assembled stylesheet is served on. */
|
|
37
46
|
export const FONT_STYLESHEET_ROUTE = '/fonts.css';
|
|
38
47
|
|
|
48
|
+
/** Where the stylesheets that carry faces live, relative to the package directory. */
|
|
49
|
+
const STYLE_SOURCE_ROOT = 'src';
|
|
50
|
+
|
|
39
51
|
const FONT_MIME: Record<string, string> = {
|
|
40
52
|
'.woff2': 'font/woff2',
|
|
41
53
|
'.woff': 'font/woff',
|
|
@@ -48,20 +60,34 @@ const STYLESHEET_HEADERS = {
|
|
|
48
60
|
'cache-control': 'no-store',
|
|
49
61
|
} as const;
|
|
50
62
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const glob = new Glob('
|
|
63
|
+
/** The absolute path of every stylesheet under the package's `src/`, sorted. */
|
|
64
|
+
function sourceStylesheets(): string[] {
|
|
65
|
+
const root = join(packageDir(), STYLE_SOURCE_ROOT);
|
|
66
|
+
const glob = new Glob('**/*.css');
|
|
55
67
|
const files: string[] = [];
|
|
56
68
|
try {
|
|
57
|
-
for
|
|
69
|
+
for (const file of glob.scanSync({ cwd: root, onlyFiles: true })) files.push(join(root, file));
|
|
58
70
|
} catch {
|
|
59
|
-
return
|
|
71
|
+
return [];
|
|
60
72
|
}
|
|
61
|
-
// Sorted so the stylesheet is byte-stable between
|
|
62
|
-
// on the ETag, and an unstable order would invalidate it for no change.
|
|
73
|
+
// Sorted so the stylesheet is byte-stable between requests: the browser
|
|
74
|
+
// caches on the ETag, and an unstable order would invalidate it for no change.
|
|
63
75
|
files.sort();
|
|
64
|
-
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every `@font-face` block the source stylesheets declare, concatenated. */
|
|
80
|
+
export async function fontStylesheet(): Promise<string> {
|
|
81
|
+
const parts: string[] = [];
|
|
82
|
+
for (const path of sourceStylesheets()) {
|
|
83
|
+
// A read that fails contributes nothing rather than failing the sheet: the
|
|
84
|
+
// shell links this unconditionally, and one unreadable file must not take
|
|
85
|
+
// the faces beside it down with it.
|
|
86
|
+
const css = await Bun.file(path).text().catch(() => '');
|
|
87
|
+
if (!css) continue;
|
|
88
|
+
const { faces } = splitFontFaces(css, dirname(path));
|
|
89
|
+
if (faces.length > 0) parts.push(...faces);
|
|
90
|
+
}
|
|
65
91
|
return parts.join('\n');
|
|
66
92
|
}
|
|
67
93
|
|