ompchamber 1.2.0 → 2.0.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/README.md +131 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.33d6779f98.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +129 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/editor/index.tsx +11 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.57b39ffa7f.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -15,18 +15,50 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
18
|
-
import type { AgentImage, BtwState, StreamTransport } from '@/shared/types';
|
|
19
|
-
import {
|
|
18
|
+
import type { AgentImage, Attachment, BtwState, ChatMessageData, StreamTransport } from '@/shared/types';
|
|
19
|
+
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
20
|
+
import { attachmentImage, readTextAttachments } from '@/shared/lib/chat/attachments';
|
|
21
|
+
import {
|
|
22
|
+
BtwRequestError,
|
|
23
|
+
abortBtwQuestion,
|
|
24
|
+
askBtwQuestion,
|
|
25
|
+
connectBtwStream,
|
|
26
|
+
deleteBtwTopic,
|
|
27
|
+
fetchBtwState,
|
|
28
|
+
promoteBtwTopic,
|
|
29
|
+
respondBtwDialog,
|
|
30
|
+
setBtwAccessMode,
|
|
31
|
+
setBtwModel,
|
|
32
|
+
setBtwThinkingLevel,
|
|
33
|
+
} from '@/shared/lib/chat/btw/client';
|
|
20
34
|
import type { StreamConnection } from '@/shared/lib/chat/omp/transport';
|
|
21
35
|
|
|
22
36
|
/** Images the side session accepts, already read as base64. */
|
|
23
37
|
export type BtwImage = AgentImage;
|
|
24
38
|
|
|
25
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* The turn streaming right now. `messages` is the turn's conversation in the
|
|
41
|
+
* chat's own shape, upserted by `message.id` exactly as the chat stream does —
|
|
42
|
+
* so the panel renders a live side answer through the same components the chat
|
|
43
|
+
* uses, tool calls included.
|
|
44
|
+
*/
|
|
26
45
|
export interface BtwLiveAnswer {
|
|
27
46
|
topicId: string;
|
|
28
47
|
turnIndex: number;
|
|
29
|
-
|
|
48
|
+
messages: ChatMessageData[];
|
|
49
|
+
/** Activity phrase for the panel's indicator, from the side child's frames. */
|
|
50
|
+
activity: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
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;
|
|
30
62
|
}
|
|
31
63
|
|
|
32
64
|
export interface BtwSessionHandle {
|
|
@@ -37,8 +69,12 @@ export interface BtwSessionHandle {
|
|
|
37
69
|
busy: boolean;
|
|
38
70
|
/** Resolves with the canonical state the command produced, or null when it
|
|
39
71
|
* was refused (the reason is then in `error`). */
|
|
40
|
-
ask: (question: string,
|
|
72
|
+
ask: (question: string, attachments?: Attachment[], topicId?: string, picks?: BtwFirstQuestionPicks) => Promise<BtwState | null>;
|
|
41
73
|
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>;
|
|
42
78
|
/** Returns the new session id to open, or null when it was refused. */
|
|
43
79
|
promote: (topicId: string) => Promise<string | null>;
|
|
44
80
|
remove: (topicId: string) => Promise<void>;
|
|
@@ -74,20 +110,39 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
74
110
|
switch (frame.type) {
|
|
75
111
|
case 'btw_state':
|
|
76
112
|
setState(frame.state);
|
|
77
|
-
//
|
|
78
|
-
//
|
|
113
|
+
// The snapshot carries the running turn's conversation, so a client
|
|
114
|
+
// attaching mid-turn (a reload, a second tab) shows the answer the
|
|
115
|
+
// child already produced instead of an empty bubble. A snapshot with
|
|
116
|
+
// no live turn ends the local buffer — the persisted rows own it now.
|
|
117
|
+
setLive(frame.state.live
|
|
118
|
+
? {
|
|
119
|
+
topicId: frame.state.live.topicId,
|
|
120
|
+
turnIndex: frame.state.live.turnIndex,
|
|
121
|
+
messages: frame.state.live.messages,
|
|
122
|
+
activity: frame.state.live.activity,
|
|
123
|
+
}
|
|
124
|
+
: null);
|
|
125
|
+
break;
|
|
126
|
+
case 'btw_message':
|
|
127
|
+
// Same upsert the chat stream performs: omp emits one frame per
|
|
128
|
+
// segment carrying that segment's full accumulated content, so a
|
|
129
|
+
// same-id frame replaces in place and a new id appends.
|
|
79
130
|
setLive((current) => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
const base =
|
|
132
|
+
current && current.topicId === frame.topicId && current.turnIndex === frame.turnIndex
|
|
133
|
+
? current
|
|
134
|
+
: { topicId: frame.topicId, turnIndex: frame.turnIndex, messages: [], activity: '' };
|
|
135
|
+
const existing = base.messages.findIndex((message) => message.id === frame.message.id);
|
|
136
|
+
const messages =
|
|
137
|
+
existing === -1
|
|
138
|
+
? [...base.messages, frame.message]
|
|
139
|
+
: [...base.messages.slice(0, existing), frame.message, ...base.messages.slice(existing + 1)];
|
|
140
|
+
return { ...base, messages };
|
|
84
141
|
});
|
|
85
142
|
break;
|
|
86
|
-
case '
|
|
143
|
+
case 'btw_activity':
|
|
87
144
|
setLive((current) =>
|
|
88
|
-
current && current.topicId === frame.topicId
|
|
89
|
-
? { ...current, text: current.text + frame.text }
|
|
90
|
-
: { topicId: frame.topicId, turnIndex: frame.turnIndex, text: frame.text },
|
|
145
|
+
current && current.topicId === frame.topicId ? { ...current, activity: frame.verb } : current,
|
|
91
146
|
);
|
|
92
147
|
break;
|
|
93
148
|
case 'btw_error':
|
|
@@ -127,9 +182,29 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
127
182
|
);
|
|
128
183
|
|
|
129
184
|
const ask = useCallback(
|
|
130
|
-
async (
|
|
185
|
+
async (
|
|
186
|
+
question: string,
|
|
187
|
+
attachments: Attachment[] = [],
|
|
188
|
+
topicId?: string,
|
|
189
|
+
picks?: BtwFirstQuestionPicks,
|
|
190
|
+
): Promise<BtwState | null> => {
|
|
131
191
|
setError(null);
|
|
132
|
-
|
|
192
|
+
// Same split the chat send path makes: images travel as provider payloads,
|
|
193
|
+
// text files are inlined into the prompt. Both are read here, from the
|
|
194
|
+
// attachments the composer already filled in at attach time.
|
|
195
|
+
const images = attachments
|
|
196
|
+
.map((attachment) => attachmentImage(attachment))
|
|
197
|
+
.filter((image): image is NonNullable<typeof image> => image !== null);
|
|
198
|
+
const textFiles = (await readTextAttachments(attachments)).filter((file) => !file.missing);
|
|
199
|
+
const next = await run((id) => askBtwQuestion(id, {
|
|
200
|
+
question,
|
|
201
|
+
topicId,
|
|
202
|
+
...(images.length ? { images } : {}),
|
|
203
|
+
...(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
|
+
}));
|
|
133
208
|
setLive(null);
|
|
134
209
|
if (next && !next.runningTopicId) {
|
|
135
210
|
setError('The side question was accepted but did not start running.');
|
|
@@ -147,6 +222,38 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
147
222
|
[run],
|
|
148
223
|
);
|
|
149
224
|
|
|
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
|
+
|
|
150
257
|
const remove = useCallback(
|
|
151
258
|
async (topicId: string) => {
|
|
152
259
|
setError(null);
|
|
@@ -176,5 +283,19 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
|
|
|
176
283
|
|
|
177
284
|
const clearError = useCallback(() => setError(null), []);
|
|
178
285
|
|
|
179
|
-
return {
|
|
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
|
+
};
|
|
180
301
|
}
|
|
@@ -7,8 +7,13 @@
|
|
|
7
7
|
* `/btw [question]` typed into the main composer is the side-question entry
|
|
8
8
|
* point, not chat text: the panel owns the token (omp's `/btw` is TUI-only, so
|
|
9
9
|
* nothing downstream would understand it). This module is the whole
|
|
10
|
-
* interception — recognize the command,
|
|
11
|
-
*
|
|
10
|
+
* interception — recognize the command and hand it, with the composer's
|
|
11
|
+
* attachments, to the panel over `omp:btw`.
|
|
12
|
+
*
|
|
13
|
+
* The attachments cross as the composer's own `Attachment` objects, not as
|
|
14
|
+
* pre-built provider payloads: the side session accepts the same shapes the chat
|
|
15
|
+
* does (images as payloads, text files inlined into the prompt), and it makes
|
|
16
|
+
* that split itself — from the fields the attach step already filled in.
|
|
12
17
|
*
|
|
13
18
|
* It lives beside the rest of the client-side btw state (`hooks/chat/btw/`)
|
|
14
19
|
* rather than in the send handler: the attachment read goes through the shared
|
|
@@ -16,7 +21,6 @@
|
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import type { Attachment } from '@/shared/types';
|
|
19
|
-
import { attachmentImage } from '@/shared/lib/chat/attachments';
|
|
20
24
|
|
|
21
25
|
/** A bare `/btw` opens the form on the current history; anything after it is
|
|
22
26
|
* the first question. Case-insensitive, because omp's command tokens are. */
|
|
@@ -30,13 +34,9 @@ export function dispatchBtwCommand(text: string, attachments: Attachment[]): boo
|
|
|
30
34
|
const match = BTW_COMMAND_RE.exec(text);
|
|
31
35
|
if (!match) return false;
|
|
32
36
|
|
|
33
|
-
const images = attachments
|
|
34
|
-
.map((attachment) => attachmentImage(attachment))
|
|
35
|
-
.filter((image): image is NonNullable<typeof image> => image !== null);
|
|
36
|
-
|
|
37
37
|
window.dispatchEvent(
|
|
38
38
|
new CustomEvent('omp:btw', {
|
|
39
|
-
detail: { question: match[1]?.trim() ?? '', ...(
|
|
39
|
+
detail: { question: match[1]?.trim() ?? '', ...(attachments.length ? { attachments } : {}) },
|
|
40
40
|
}),
|
|
41
41
|
);
|
|
42
42
|
return true;
|
|
@@ -16,17 +16,26 @@
|
|
|
16
16
|
* arrived with the command is asked immediately, which is what the TUI does.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { useCallback } from 'preact/hooks';
|
|
20
|
-
import type { BtwState, BtwTopic } from '@/shared/types';
|
|
21
|
-
import
|
|
19
|
+
import { useCallback, useState } from 'preact/hooks';
|
|
20
|
+
import type { Attachment, BtwDialog, BtwState, BtwTopic, ChatMessageData } from '@/shared/types';
|
|
21
|
+
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
22
|
+
import { useBtwSession, type BtwFirstQuestionPicks, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
|
|
22
23
|
import { useChamberEvent } from '@/client/hooks/ui/window-event';
|
|
23
24
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
24
25
|
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
25
26
|
import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
|
|
27
|
+
import { normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
28
|
+
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
29
|
+
import { readChamberSetting } from '@/shared/lib/settings/client';
|
|
26
30
|
|
|
27
31
|
/** Question sent when a question arrived carrying only images. */
|
|
28
32
|
const ATTACHMENT_ONLY_QUESTION = 'Describe the attached image.';
|
|
29
33
|
|
|
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
|
+
|
|
30
39
|
export interface BtwMode {
|
|
31
40
|
open: boolean;
|
|
32
41
|
topics: BtwTopic[];
|
|
@@ -35,9 +44,16 @@ export interface BtwMode {
|
|
|
35
44
|
runningTopicId: string | null;
|
|
36
45
|
/** A command is still in flight — the panel holds its actions meanwhile. */
|
|
37
46
|
busy: boolean;
|
|
38
|
-
/**
|
|
39
|
-
|
|
47
|
+
/** The running turn's conversation, chat-shaped; empty when none. */
|
|
48
|
+
liveMessages: ChatMessageData[];
|
|
49
|
+
/** What that turn is doing, for the panel's indicator (side stream's own). */
|
|
50
|
+
liveVerb?: string;
|
|
40
51
|
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;
|
|
41
57
|
askDraft: string;
|
|
42
58
|
followUpDraft: string;
|
|
43
59
|
setAskDraft: (value: string) => void;
|
|
@@ -51,8 +67,13 @@ export interface BtwMode {
|
|
|
51
67
|
/** Submit the ask card: a question of its own. */
|
|
52
68
|
submitAsk: () => void;
|
|
53
69
|
/** Submit the side session's composer: a follow-up to the active topic. */
|
|
54
|
-
submitFollowUp: (question: string,
|
|
70
|
+
submitFollowUp: (question: string, attachments?: Attachment[]) => void;
|
|
55
71
|
abort: () => void;
|
|
72
|
+
/** Re-target the side child's model (applies to the running turn). */
|
|
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;
|
|
56
77
|
/** Returns the session id to open, or null when promotion was refused. */
|
|
57
78
|
promote: () => Promise<string | null>;
|
|
58
79
|
removeTopic: (topicId: string) => void;
|
|
@@ -61,7 +82,18 @@ export interface BtwMode {
|
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
export function useBtwMode(sessionId: string | null, appSettings: Record<string, unknown>): BtwMode {
|
|
64
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Whether the panel is on screen. Deliberately NOT restored from session
|
|
87
|
+
* state: a panel that reopens itself on every load leaves the user with no
|
|
88
|
+
* chat composer and a field they did not ask for, and a `/btw` typed into
|
|
89
|
+
* that field becomes a real question to the model. The panel is a mode the
|
|
90
|
+
* user enters, so every load starts in the chat.
|
|
91
|
+
*
|
|
92
|
+
* The drafts and the selected topic DO persist (`chat.btwAsk` and friends):
|
|
93
|
+
* leaving and re-entering the panel loses nothing, which is what those slots
|
|
94
|
+
* are for.
|
|
95
|
+
*/
|
|
96
|
+
const [open, setOpen] = useState(false);
|
|
65
97
|
const [askDraft, setAskDraft] = useSessionState<string>('chat.btwAsk', '');
|
|
66
98
|
const [followUpDraft, setFollowUpDraft] = useSessionState<string>('chat.btwFollowUp', '');
|
|
67
99
|
const [selectedTopicId, setSelectedTopicId] = useSessionState<string | null>('chat.btwTopicId', null);
|
|
@@ -79,6 +111,26 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
79
111
|
const runningTopicId = session.state?.runningTopicId ?? null;
|
|
80
112
|
const live: BtwLiveAnswer | null = session.live && session.live.topicId === activeTopic?.id ? session.live : null;
|
|
81
113
|
|
|
114
|
+
/**
|
|
115
|
+
* What the running turn is doing, for the panel's indicator.
|
|
116
|
+
*
|
|
117
|
+
* The verb is the SERVER's, derived from the side child's own frames
|
|
118
|
+
* (`describeAssistantPhase` / `describeToolActivity` over `btw_activity`) —
|
|
119
|
+
* never the chat's, whose run is a different process. Until the child reports
|
|
120
|
+
* one the turn is still working, which is all the panel can honestly say.
|
|
121
|
+
*/
|
|
122
|
+
const liveVerb = live?.activity || (runningTopicId ? PHASE_VERBS.sideQuestion : undefined);
|
|
123
|
+
|
|
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
|
+
|
|
82
134
|
const selectNewest = useCallback(
|
|
83
135
|
(next: BtwState | null) => {
|
|
84
136
|
if (!next) return;
|
|
@@ -90,30 +142,45 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
90
142
|
// `/btw [question]` hands the question over; a bare `/btw` only opens the
|
|
91
143
|
// form, which is the history view.
|
|
92
144
|
useChamberEvent('omp:btw', (event) => {
|
|
93
|
-
const detail = (event as CustomEvent<{ question?: string;
|
|
145
|
+
const detail = (event as CustomEvent<{ question?: string; attachments?: Attachment[] }>).detail;
|
|
94
146
|
const question = detail?.question?.trim();
|
|
95
147
|
setOpen(true);
|
|
96
|
-
if (question || detail?.
|
|
148
|
+
if (question || detail?.attachments?.length) {
|
|
97
149
|
setAskDraft('');
|
|
98
|
-
void session.ask(question || ATTACHMENT_ONLY_QUESTION, detail?.
|
|
150
|
+
void session.ask(question || ATTACHMENT_ONLY_QUESTION, detail?.attachments).then(selectNewest);
|
|
99
151
|
}
|
|
100
152
|
});
|
|
101
153
|
|
|
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
|
+
|
|
102
169
|
const submitAsk = useCallback(() => {
|
|
103
170
|
const question = askDraft.trim();
|
|
104
171
|
if (!question) return;
|
|
105
172
|
setAskDraft('');
|
|
106
|
-
void session.ask(question).then(selectNewest);
|
|
107
|
-
}, [askDraft, session, setAskDraft, selectNewest]);
|
|
173
|
+
void session.ask(question, undefined, undefined, firstQuestionPicks()).then(selectNewest);
|
|
174
|
+
}, [askDraft, session, setAskDraft, selectNewest, firstQuestionPicks]);
|
|
108
175
|
|
|
109
176
|
const submitFollowUp = useCallback(
|
|
110
|
-
(question: string,
|
|
177
|
+
(question: string, attachments?: Attachment[]) => {
|
|
111
178
|
const text = question.trim();
|
|
112
|
-
if (!text && !
|
|
179
|
+
if (!text && !attachments?.length) return;
|
|
113
180
|
setFollowUpDraft('');
|
|
114
181
|
// With no topic in view yet, the side session's first question is the
|
|
115
182
|
// topic — there is nothing to follow up on.
|
|
116
|
-
void session.ask(text || ATTACHMENT_ONLY_QUESTION,
|
|
183
|
+
void session.ask(text || ATTACHMENT_ONLY_QUESTION, attachments, activeTopic?.id).then((next) => {
|
|
117
184
|
if (!activeTopic) selectNewest(next);
|
|
118
185
|
});
|
|
119
186
|
},
|
|
@@ -150,8 +217,15 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
150
217
|
activeTopic,
|
|
151
218
|
runningTopicId,
|
|
152
219
|
busy: session.busy,
|
|
153
|
-
|
|
220
|
+
liveMessages: live ? live.messages : [],
|
|
221
|
+
liveVerb,
|
|
154
222
|
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,
|
|
155
229
|
askDraft,
|
|
156
230
|
followUpDraft,
|
|
157
231
|
setAskDraft,
|
|
@@ -168,6 +242,27 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
|
|
|
168
242
|
abort: () => {
|
|
169
243
|
if (runningTopicId) void session.abort(runningTopicId);
|
|
170
244
|
},
|
|
245
|
+
setModel: (provider: string, modelId: string) => {
|
|
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);
|
|
265
|
+
},
|
|
171
266
|
promote,
|
|
172
267
|
removeTopic,
|
|
173
268
|
selectTopic: (topicId: string) => {
|
|
@@ -13,6 +13,12 @@ export interface UseComposerTriggerOptions {
|
|
|
13
13
|
disabled?: boolean;
|
|
14
14
|
/** Workspace root to scope `@` file mentions to (null → app root). */
|
|
15
15
|
rootPath?: string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the popup may open at all. A composer with no completable surface
|
|
18
|
+
* (the side-question form) turns it off so a stray `@` or `/` cannot open an
|
|
19
|
+
* empty list over its text.
|
|
20
|
+
*/
|
|
21
|
+
enabled?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export interface ComposerTriggerController {
|
|
@@ -55,7 +61,7 @@ function shouldOpen(trigger: ComposerTrigger | null, matches: ComposerMatchItem[
|
|
|
55
61
|
|
|
56
62
|
/** Orchestrates the composer `@`-mention / `/`-command autocomplete flow. */
|
|
57
63
|
export function useComposerTrigger(options: UseComposerTriggerOptions): ComposerTriggerController {
|
|
58
|
-
const { value, setValue, textareaRef, disabled = false, rootPath } = options;
|
|
64
|
+
const { value, setValue, textareaRef, disabled = false, rootPath, enabled = true } = options;
|
|
59
65
|
|
|
60
66
|
const [trigger, setTrigger] = useState<ComposerTrigger | null>(null);
|
|
61
67
|
const [activeIndex, setActiveIndexState] = useState(0);
|
|
@@ -77,7 +83,7 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
77
83
|
|
|
78
84
|
const optionId = useCallback((index: number) => `${listboxId}-option-${index}`, [listboxId]);
|
|
79
85
|
|
|
80
|
-
const open = shouldOpen(trigger, matches);
|
|
86
|
+
const open = enabled && shouldOpen(trigger, matches);
|
|
81
87
|
|
|
82
88
|
const close = useCallback(() => {
|
|
83
89
|
setTrigger(null);
|
|
@@ -109,14 +115,14 @@ export function useComposerTrigger(options: UseComposerTriggerOptions): Composer
|
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
setValue(text);
|
|
112
|
-
if (disabled) {
|
|
118
|
+
if (disabled || !enabled) {
|
|
113
119
|
close();
|
|
114
120
|
return;
|
|
115
121
|
}
|
|
116
122
|
setTrigger(detectComposerTrigger(text, caret));
|
|
117
123
|
setActiveIndexState(0);
|
|
118
124
|
},
|
|
119
|
-
[setValue, close, disabled],
|
|
125
|
+
[setValue, close, disabled, enabled],
|
|
120
126
|
);
|
|
121
127
|
|
|
122
128
|
const selectItem = useCallback(
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Access-control (approval) mode for the composer: a global, persisted user
|
|
8
|
+
* preference, unlike the per-session model/thinking picks. It hydrates from
|
|
9
|
+
* appSettings at first paint and is mirrored into a ref so the send path reads
|
|
10
|
+
* the latest value without re-creating `executeSend`. Extracted from
|
|
11
|
+
* useChatTimeline so that hook stays under the repo's per-file size ceiling.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
15
|
+
import { ACCESS_MODE_SETTING_KEY, normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
16
|
+
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
17
|
+
import { writeSetting } from '@/shared/lib/settings/client';
|
|
18
|
+
|
|
19
|
+
export interface ChatTimelineAccessMode {
|
|
20
|
+
accessMode: ApprovalMode;
|
|
21
|
+
/** Live value for callbacks created once (send, queue delivery). */
|
|
22
|
+
accessModeRef: { current: ApprovalMode };
|
|
23
|
+
setAccessMode: (mode: ApprovalMode) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useChatTimelineAccessMode(appSettings: Record<string, any>): ChatTimelineAccessMode {
|
|
27
|
+
const [accessMode, setAccessModeState] = useState<ApprovalMode>(() => normalizeApprovalMode(appSettings.omp_access_mode));
|
|
28
|
+
const accessModeRef = useRef<ApprovalMode>(accessMode);
|
|
29
|
+
accessModeRef.current = accessMode;
|
|
30
|
+
|
|
31
|
+
const setAccessMode = useCallback((mode: ApprovalMode) => {
|
|
32
|
+
setAccessModeState(mode);
|
|
33
|
+
accessModeRef.current = mode;
|
|
34
|
+
// Persist the last selection; the server reads this key as the spawn-time
|
|
35
|
+
// default. Fire-and-forget: the in-memory value is already authoritative
|
|
36
|
+
// for this session's requests, which carry it explicitly.
|
|
37
|
+
writeSetting(ACCESS_MODE_SETTING_KEY, mode);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return { accessMode, accessModeRef, setAccessMode };
|
|
41
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The ask/approval routing split, and the one thing it must never do: paint a
|
|
8
|
+
* dialog as a modal and then take it back.
|
|
9
|
+
*
|
|
10
|
+
* On session open the pending dialogs are replayed from the agent-state probe
|
|
11
|
+
* (which answers out of the wrapper's local flags) long before the session
|
|
12
|
+
* JSONL lands, so every replayed frame looks unowned while `messages` is still
|
|
13
|
+
* empty — while the ask card that owns it is one fetch away. Deciding "no card
|
|
14
|
+
* owns this" during that window flashed the ask modal on every switch to a
|
|
15
|
+
* session parked on a question. These pin the hold, and that it never turns
|
|
16
|
+
* into a silently dropped question.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, test } from 'bun:test';
|
|
20
|
+
|
|
21
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
22
|
+
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
23
|
+
import { splitAskFrames } from '@/client/hooks/chat/timeline/ask-frames';
|
|
24
|
+
|
|
25
|
+
/** An assistant turn whose `ask` tool call asked `questions`, as the JSONL and
|
|
26
|
+
* the live stream both deliver it. */
|
|
27
|
+
function askHistory(questions: string[]): ChatMessageData[] {
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
id: 'msg-ask',
|
|
31
|
+
role: 'ai',
|
|
32
|
+
content: '',
|
|
33
|
+
toolCalls: [
|
|
34
|
+
{
|
|
35
|
+
id: 'call_ask',
|
|
36
|
+
type: 'ask',
|
|
37
|
+
name: 'ask',
|
|
38
|
+
title: 'ask',
|
|
39
|
+
input: { questions: questions.map((question) => ({ question, options: [{ label: 'A' }, { label: 'B' }] })) },
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function selectFrame(id: string, title: string): ExtensionUiDialogRequest {
|
|
47
|
+
return { type: 'extension_ui_request', id, method: 'select', title, options: ['A (Recommended)', 'B', 'Other (type your own)'] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function confirmFrame(id: string, title: string): ExtensionUiDialogRequest {
|
|
51
|
+
return { type: 'extension_ui_request', id, method: 'confirm', title };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('splitAskFrames', () => {
|
|
55
|
+
test('a question dialog is claimed by the ask card, not handed to the modal', () => {
|
|
56
|
+
const frame = selectFrame('f1', 'Which language?');
|
|
57
|
+
const split = splitAskFrames(askHistory(['Which language?']), [frame], true);
|
|
58
|
+
|
|
59
|
+
expect(split.framesByTool.get('call_ask')?.[0]?.map((held) => held.id)).toEqual(['f1']);
|
|
60
|
+
expect(split.modalRequest).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the modal is held while the session history is still loading', () => {
|
|
64
|
+
const question = selectFrame('f1', 'Which language?');
|
|
65
|
+
const gate = confirmFrame('g1', 'Run bash?');
|
|
66
|
+
|
|
67
|
+
// The regression: the frame has been replayed, its tool call has not.
|
|
68
|
+
expect(splitAskFrames([], [question], false)).toEqual({ framesByTool: new Map(), modalRequest: null });
|
|
69
|
+
// The hold is uniform — a gate cannot be told from an ask before history
|
|
70
|
+
// either, and it is re-decided the moment the history settles.
|
|
71
|
+
expect(splitAskFrames([], [gate], false).modalRequest).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('once the history has settled an unclaimed dialog stays answerable', () => {
|
|
75
|
+
const frame = selectFrame('f1', 'Which language?');
|
|
76
|
+
expect(splitAskFrames([], [frame], true).modalRequest).toBe(frame);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('an approval gate keeps the modal beside a claimed question', () => {
|
|
80
|
+
const question = selectFrame('f1', 'Which language?');
|
|
81
|
+
const gate = confirmFrame('g1', 'Run bash?');
|
|
82
|
+
const split = splitAskFrames(askHistory(['Which language?']), [question, gate], true);
|
|
83
|
+
|
|
84
|
+
expect(split.framesByTool.get('call_ask')?.[0]?.map((held) => held.id)).toEqual(['f1']);
|
|
85
|
+
expect(split.modalRequest).toBe(gate);
|
|
86
|
+
});
|
|
87
|
+
});
|