ompchamber 1.2.1 → 2.0.1
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 +143 -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.cb81980498.js +2 -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.22588845a9.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/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
- package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
- 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.1fd9e1c543.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 +124 -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/diff-panel/SplitView.tsx +4 -1
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
- package/src/client/components/workspace/diff-panel/index.tsx +8 -1
- package/src/client/components/workspace/editor/index.tsx +18 -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/editor/save-file.ts +62 -0
- package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
- package/src/client/hooks/editor/use-file-editor.ts +38 -29
- package/src/client/hooks/editor/use-file-transfer.ts +5 -2
- 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/action.test.ts +64 -0
- package/src/server/routes/fs/action.ts +10 -1
- 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/line-endings.test.ts +38 -0
- package/src/shared/lib/code/line-endings.ts +56 -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/m.2e1ea3db9e.js.LICENSE.txt +0 -6
- 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.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- 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/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.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.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
|
@@ -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
|
+
});
|
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
* Any frame left over is modal material — including an ask-shaped frame whose
|
|
19
19
|
* tool call the timeline has not seen, which must stay answerable rather than
|
|
20
20
|
* silently vanish.
|
|
21
|
+
*
|
|
22
|
+
* That fallback must wait for the session's own history, though. Ownership is
|
|
23
|
+
* decided against the ask tool calls in `messages`, and on session open the
|
|
24
|
+
* pending dialogs are replayed from the agent-state probe — which answers out
|
|
25
|
+
* of the wrapper's local flags — long before the JSONL fetch lands. Every
|
|
26
|
+
* replayed frame therefore looks unowned for that window, and rendering one as
|
|
27
|
+
* a modal painted the question on screen and yanked it away the instant the
|
|
28
|
+
* timeline arrived and its card claimed it: the flash seen when switching to a
|
|
29
|
+
* session parked on an ask. `historyLoaded` holds the modal — never the card
|
|
30
|
+
* claim — until there is a timeline to decide against; a frame no card claims
|
|
31
|
+
* after that still gets its modal.
|
|
21
32
|
*/
|
|
22
33
|
|
|
23
34
|
import { createContext, useContext } from 'preact/compat';
|
|
@@ -51,6 +62,9 @@ export function useAskFrames(): AskFramesHandle {
|
|
|
51
62
|
export function splitAskFrames(
|
|
52
63
|
messages: ChatMessageData[],
|
|
53
64
|
pending: ExtensionUiDialogRequest[],
|
|
65
|
+
/** Whether `messages` is the session's committed history yet (see the module
|
|
66
|
+
* note). False holds the modal back; it never blocks a card from claiming. */
|
|
67
|
+
historyLoaded: boolean,
|
|
54
68
|
): AskFramesSplit {
|
|
55
69
|
const framesByTool = new Map<string, ExtensionUiDialogRequest[][]>();
|
|
56
70
|
if (pending.length === 0) return { framesByTool, modalRequest: null };
|
|
@@ -98,5 +112,10 @@ export function splitAskFrames(
|
|
|
98
112
|
framesByTool.set(toolId, groupAskFrames(openAsks.get(toolId) ?? [], frames));
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
// The frame stays queued either way — only the modal waits for the history,
|
|
116
|
+
// so nothing is dropped and nothing flashes.
|
|
117
|
+
return {
|
|
118
|
+
framesByTool,
|
|
119
|
+
modalRequest: historyLoaded ? pending.find((request) => !claimed.has(request)) ?? null : null,
|
|
120
|
+
};
|
|
102
121
|
}
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* because every other trigger lives in the streaming callbacks. This jumps to
|
|
11
11
|
* the tail exactly once per session, on the commit where that session's
|
|
12
12
|
* messages appear.
|
|
13
|
+
*
|
|
14
|
+
* The jump is the UNCONDITIONAL one (`jumpToBottom`), not the follow-gated
|
|
15
|
+
* `scrollToBottom`. Follow mode is a property of this mounted view, not of the
|
|
16
|
+
* session being opened: a user who scrolled up before switching left it
|
|
17
|
+
* disengaged, and the open then painted the top of the new session with no
|
|
18
|
+
* scroll event to re-engage it — the session stayed at the head until the
|
|
19
|
+
* button was clicked (reproduced: scroll up in session A, open session B,
|
|
20
|
+
* `scrollTop` pinned at 0 with the jump button showing).
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import type { RefObject } from 'preact/compat';
|
|
@@ -24,14 +32,15 @@ export interface UseTimelineAutoScrollDeps {
|
|
|
24
32
|
messages: ChatMessageData[];
|
|
25
33
|
/** Scroll container owned by useChatTimelineScroll. */
|
|
26
34
|
scrollRef: RefObject<HTMLDivElement>;
|
|
27
|
-
|
|
35
|
+
/** Unconditional tail jump (bypasses follow mode, re-engages it). */
|
|
36
|
+
jumpToBottom: (behavior?: ScrollBehavior) => void;
|
|
28
37
|
/** Pending "new-…" sessions render the workspace picker, which owns its own
|
|
29
38
|
* timeline and scroll handling. */
|
|
30
39
|
enabled: boolean;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
export function useTimelineAutoScroll(deps: UseTimelineAutoScrollDeps): void {
|
|
34
|
-
const { sessionId, messages, scrollRef,
|
|
43
|
+
const { sessionId, messages, scrollRef, jumpToBottom, enabled } = deps;
|
|
35
44
|
// Session the container was last rendering. On the commit where this flips
|
|
36
45
|
// the DOM still holds the previous session's rows, so scrolling then would
|
|
37
46
|
// aim at content that is about to be replaced.
|
|
@@ -53,6 +62,6 @@ export function useTimelineAutoScroll(deps: UseTimelineAutoScrollDeps): void {
|
|
|
53
62
|
// scrollHeight forces layout, so the jump lands before the browser paints.
|
|
54
63
|
// 'instant' is required because the container carries Tailwind's
|
|
55
64
|
// `scroll-smooth`, which 'auto' would resolve to.
|
|
56
|
-
|
|
57
|
-
}, [sessionId, messages, enabled, scrollRef,
|
|
65
|
+
jumpToBottom('instant');
|
|
66
|
+
}, [sessionId, messages, enabled, scrollRef, jumpToBottom]);
|
|
58
67
|
}
|
|
@@ -15,10 +15,8 @@ import { useBrowserPageContextInsert } from '@/client/hooks/chat/timeline/browse
|
|
|
15
15
|
import { createOmpAgentCallbacks } from '@/shared/lib/chat/timeline/omp-callbacks';
|
|
16
16
|
import { cancelStreamingCoalescer } from '@/shared/lib/chat/timeline/stream-coalescer';
|
|
17
17
|
import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
|
|
18
|
-
import {
|
|
19
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
18
|
+
import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-mode';
|
|
20
19
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
21
|
-
import { writeSetting } from '@/shared/lib/settings/client';
|
|
22
20
|
|
|
23
21
|
interface UseChatTimelineOptions {
|
|
24
22
|
folders?: any[];
|
|
@@ -51,7 +49,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
51
49
|
// without re-creating it (useSessionLoad is defined below the scroll hook).
|
|
52
50
|
const loadOlderRef = useRef<() => void>(() => {});
|
|
53
51
|
|
|
54
|
-
const { scrollRef, contentRef, showScrollBottom, isScrolling, handleScroll, scrollToBottom, jumpToBottom } = useChatTimelineScroll({
|
|
52
|
+
const { scrollRef, contentRef, showScrollBottom, isScrolling, handleScroll, scrollToBottom, jumpToBottom, jumpActiveRef, jumpCountRef } = useChatTimelineScroll({
|
|
55
53
|
onScrollTop: () => loadOlderRef.current(),
|
|
56
54
|
});
|
|
57
55
|
|
|
@@ -65,11 +63,12 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
65
63
|
const isPendingSession = Boolean(sessionId?.startsWith('new-'));
|
|
66
64
|
// Committed history lands asynchronously (session-load.ts), so the container
|
|
67
65
|
// paints at the top first; jump to the tail once per session when it does.
|
|
66
|
+
// Unconditional: follow mode is this view's, not the opened session's.
|
|
68
67
|
useTimelineAutoScroll({
|
|
69
68
|
sessionId,
|
|
70
69
|
messages: localMessages,
|
|
71
70
|
scrollRef,
|
|
72
|
-
|
|
71
|
+
jumpToBottom,
|
|
73
72
|
enabled: !isPendingSession,
|
|
74
73
|
});
|
|
75
74
|
|
|
@@ -135,6 +134,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
135
134
|
cancelStreamingCoalescer,
|
|
136
135
|
metaRefreshedRef,
|
|
137
136
|
scrollRef,
|
|
137
|
+
jumpActiveRef,
|
|
138
|
+
jumpCountRef,
|
|
138
139
|
});
|
|
139
140
|
loadOlderRef.current = loadOlder;
|
|
140
141
|
|
|
@@ -165,21 +166,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
165
166
|
}, [sessionId]);
|
|
166
167
|
|
|
167
168
|
// Access-control mode is a global, persisted user preference (unlike the
|
|
168
|
-
// per-session model/thinking picks)
|
|
169
|
-
|
|
170
|
-
// without re-creating executeSend.
|
|
171
|
-
const [accessMode, setAccessMode] = useState<ApprovalMode>(() => normalizeApprovalMode(appSettings.omp_access_mode));
|
|
172
|
-
const accessModeRef = useRef<ApprovalMode>(accessMode);
|
|
173
|
-
accessModeRef.current = accessMode;
|
|
174
|
-
|
|
175
|
-
const handleAccessModeChange = useCallback((mode: ApprovalMode) => {
|
|
176
|
-
setAccessMode(mode);
|
|
177
|
-
accessModeRef.current = mode;
|
|
178
|
-
// Persist the last selection; the server reads this key as the spawn-time
|
|
179
|
-
// default. Fire-and-forget: the in-memory value is already authoritative
|
|
180
|
-
// for this session's requests, which carry it explicitly.
|
|
181
|
-
writeSetting(ACCESS_MODE_SETTING_KEY, mode);
|
|
182
|
-
}, []);
|
|
169
|
+
// per-session model/thinking picks).
|
|
170
|
+
const { accessMode, accessModeRef, setAccessMode: handleAccessModeChange } = useChatTimelineAccessMode(appSettings);
|
|
183
171
|
|
|
184
172
|
const persistMessages = useCallback((messagesToSave: any[]) => {
|
|
185
173
|
if (!sessionId) return;
|