@kabmo-labs/kabmo 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -19,14 +19,15 @@ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
|
19
19
|
import type { SetSearchParams } from '@/client/lib/router/search-params';
|
|
20
20
|
import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
|
|
21
21
|
import { useNewChatSubmit } from '@/client/hooks/chat/timeline/new-chat';
|
|
22
|
-
import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
|
|
23
|
-
import { dispatchPluginCommand } from '@/client/hooks/chat/timeline/plugin-commands';
|
|
24
22
|
import { blockTuiOnlySend } from '@/client/hooks/chat/timeline/tui-only-guard';
|
|
23
|
+
import { dispatchLocalCommand } from '@/client/hooks/chat/timeline/local-commands';
|
|
25
24
|
import { createQueueActions } from '@/client/hooks/chat/timeline/queue-actions';
|
|
26
25
|
import { reportSteerOutcome } from '@/shared/lib/chat/timeline/steer-report';
|
|
27
26
|
import { createRewindActions } from '@/client/hooks/chat/timeline/rewind-actions';
|
|
28
|
-
import {
|
|
27
|
+
import { enqueuePreparedFollowUp } from '@/client/hooks/chat/timeline/enqueue-follow-up';
|
|
29
28
|
import { readKabmoSetting } from '@/shared/lib/settings/client';
|
|
29
|
+
import { holdFromComposer } from '@/client/hooks/chat/waiting/hold';
|
|
30
|
+
import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
30
31
|
|
|
31
32
|
export interface ChatTimelineActionsDeps {
|
|
32
33
|
inputValue: string;
|
|
@@ -80,6 +81,20 @@ export interface ChatTimelineActionsDeps {
|
|
|
80
81
|
deferredComposerPickRef: DeferredModelStore;
|
|
81
82
|
/** Global access-control mode, snapshotted onto queued items. */
|
|
82
83
|
accessModeRef: { current: ApprovalMode };
|
|
84
|
+
/**
|
|
85
|
+
* The composer's "wait for session" pick, mirrored in a ref: the send handler
|
|
86
|
+
* is created once per deps record, and reading the pick from render state
|
|
87
|
+
* would send with whatever it was when that closure was built.
|
|
88
|
+
*/
|
|
89
|
+
waitingDependencyRef?: { current: WaitingCandidate | null };
|
|
90
|
+
/** Clear the pick once the prompt is held (the wait now owns it). */
|
|
91
|
+
clearWaitingDependency?: () => void;
|
|
92
|
+
/** Server-tracked `stream` rows: re-checked at send, so a dependency that
|
|
93
|
+
* finished while the draft was being written sends normally. */
|
|
94
|
+
streamStatusMap?: Record<string, 'stream' | 'finish' | 'abort'>;
|
|
95
|
+
/** Plan/goal flags the held spawn must carry (read when the wait is created,
|
|
96
|
+
* because the dispatch happens in another process later). */
|
|
97
|
+
spawnSelectionRef?: { current: { plan: boolean; goal: boolean } | null };
|
|
83
98
|
setSearchParams: SetSearchParams;
|
|
84
99
|
/**
|
|
85
100
|
* Surface a footer action that failed (rewind refused, send rejected). Undo
|
|
@@ -137,17 +152,14 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
137
152
|
// they are built once per render from it rather than re-listing every field.
|
|
138
153
|
const queueActions = useMemo(() => createQueueActions(deps), [deps]);
|
|
139
154
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
enqueueMessage({ text, attachments: prepared, model });
|
|
149
|
-
});
|
|
150
|
-
}, [composerModelRef, accessModeRef, enqueueMessage]);
|
|
155
|
+
const enqueueFollowUp = useCallback(
|
|
156
|
+
(text: string, attachments: Attachment[]) => enqueuePreparedFollowUp(
|
|
157
|
+
{ composerModelRef, accessModeRef, enqueueMessage },
|
|
158
|
+
text,
|
|
159
|
+
attachments,
|
|
160
|
+
),
|
|
161
|
+
[composerModelRef, accessModeRef, enqueueMessage],
|
|
162
|
+
);
|
|
151
163
|
|
|
152
164
|
const handleSend = useCallback(async (attachments: Attachment[], options?: { steering?: boolean }) => {
|
|
153
165
|
const textToSend = inputValue.trim();
|
|
@@ -157,26 +169,8 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
157
169
|
// resume delivering after this run ends.
|
|
158
170
|
stopHoldRef.current = false;
|
|
159
171
|
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
// understand the token). A bare `/btw` only opens the panel's history.
|
|
163
|
-
//
|
|
164
|
-
// MUST run before the TUI-only guard below: `/btw` is in that table (omp
|
|
165
|
-
// implements it in the TUI only), so the guard would refuse the very
|
|
166
|
-
// command Kabmo answers with a panel.
|
|
167
|
-
if (dispatchBtwCommand(textToSend, attachments)) {
|
|
168
|
-
setInputValue('');
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// A command a PLUGIN contributed through the `commandPaletteAction` slot is
|
|
173
|
-
// answered by that plugin, exactly like a Kabmo command: it is local work
|
|
174
|
-
// the popup advertised, so it must never reach the model as literal text
|
|
175
|
-
// (RPC has no expansion for it, and omp would improvise a whole turn around
|
|
176
|
-
// the string). MUST run before the TUI-only guard below: a plugin may
|
|
177
|
-
// legitimately claim a name omp implements only in its TUI, and refusing
|
|
178
|
-
// the row the popup just offered would be the composer contradicting itself.
|
|
179
|
-
if (dispatchPluginCommand(textToSend)) {
|
|
172
|
+
// Commands Kabmo answers ITSELF, before anything can reach the model.
|
|
173
|
+
if (dispatchLocalCommand(textToSend, attachments)) {
|
|
180
174
|
setInputValue('');
|
|
181
175
|
return;
|
|
182
176
|
}
|
|
@@ -187,6 +181,11 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
187
181
|
// keep the draft so the user can edit or retype it.
|
|
188
182
|
if (blockTuiOnlySend(textToSend, setLocalMessages)) return;
|
|
189
183
|
|
|
184
|
+
// A NEW chat whose prompt is held for another session: nothing is sent now,
|
|
185
|
+
// and the pending row stays on screen saying what it waits for. Checked
|
|
186
|
+
// before the queue-vs-send split — a pending chat has no run to queue behind.
|
|
187
|
+
if (await holdFromComposer(deps, textToSend, attachments, isGenerating || chatRunning)) return;
|
|
188
|
+
|
|
190
189
|
// "A run is in flight" is the CHAT-level flag, not this client's own:
|
|
191
190
|
// `isGenerating` only knows the run THIS page started, so a second tab, a
|
|
192
191
|
// scheduled task, the goal driver's continuation or a page that never
|
|
@@ -234,7 +233,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
234
233
|
// branch would rather than losing the message. Every other failure may
|
|
235
234
|
// already have been accepted, and is reported by the send path instead.
|
|
236
235
|
if (result.busy) enqueueFollowUp(textToSend, attachments);
|
|
237
|
-
}, [inputValue, isGenerating, chatRunning, executeSend, enqueueMessage, enqueueFollowUp, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef, setLocalMessages]);
|
|
236
|
+
}, [deps, inputValue, isGenerating, chatRunning, executeSend, enqueueMessage, enqueueFollowUp, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef, setLocalMessages]);
|
|
238
237
|
|
|
239
238
|
const handleEditQueueItem = useCallback((item: QueuedMessage) => {
|
|
240
239
|
queueActions.handleEditQueueItem(item);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The composer's model/thinking refs, and the one rule that ties them to a
|
|
8
|
+
* session.
|
|
9
|
+
*
|
|
10
|
+
* Three of the four are mirrors of picks the user made, and they exist because
|
|
11
|
+
* the send path cannot read render state: it is created once per deps record,
|
|
12
|
+
* so a pick read from a prop there would dispatch with whatever it was when
|
|
13
|
+
* that closure was built. The fourth (`deferredComposerPickRef`) holds a pick
|
|
14
|
+
* made WHILE a turn streams, so it cannot re-target the answer in flight.
|
|
15
|
+
*
|
|
16
|
+
* Split out of `timeline/index.ts` for the per-file ceiling; the semantics are
|
|
17
|
+
* the hook's own and are documented at each field.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
21
|
+
import type { ComposerModelPick } from '@/client/hooks/chat/timeline/deferred-model';
|
|
22
|
+
|
|
23
|
+
export interface ComposerRefs {
|
|
24
|
+
/** Model picked on a pending `new-…` chat; applied to the spawn. */
|
|
25
|
+
pendingComposerModelRef: { current: { provider: string; modelId: string } | null };
|
|
26
|
+
/** Same, for the thinking level. */
|
|
27
|
+
pendingThinkingLevelRef: { current: string | null };
|
|
28
|
+
/** Live mirror of the composer's pick; snapshotted onto queued items. */
|
|
29
|
+
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
30
|
+
/** A pick made mid-run, pushed right before the next prompt this composer starts. */
|
|
31
|
+
deferredComposerPickRef: { current: ComposerModelPick | null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useComposerRefs(sessionId: string | null): ComposerRefs {
|
|
35
|
+
const pendingComposerModelRef = useRef<{ provider: string; modelId: string } | null>(null);
|
|
36
|
+
const pendingThinkingLevelRef = useRef<string | null>(null);
|
|
37
|
+
const composerModelRef = useRef<{ provider: string; modelId: string; thinkingLevel: string } | null>(null);
|
|
38
|
+
const deferredComposerPickRef = useRef<ComposerModelPick | null>(null);
|
|
39
|
+
|
|
40
|
+
// A stashed pick belongs to ONE session's next prompt: carrying it across a
|
|
41
|
+
// switch would silently re-target the session the user just opened.
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
deferredComposerPickRef.current = null;
|
|
44
|
+
}, [sessionId]);
|
|
45
|
+
|
|
46
|
+
return { pendingComposerModelRef, pendingThinkingLevelRef, composerModelRef, deferredComposerPickRef };
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Snapshot a follow-up onto the server-backed queue.
|
|
8
|
+
*
|
|
9
|
+
* The composer's model/thinking pick plus the live access mode ride along, so
|
|
10
|
+
* auto-delivery replays exactly these settings instead of whatever the session
|
|
11
|
+
* happens to run with at delivery time. The queue row is JSON — a live `File`
|
|
12
|
+
* does not survive it — so text contents and display fields are captured HERE,
|
|
13
|
+
* while the handle still exists.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Attachment, QueuedMessage, QueuedMessageModel } from '@/shared/types/chat';
|
|
17
|
+
import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
|
|
18
|
+
|
|
19
|
+
export interface FollowUpSnapshotDeps {
|
|
20
|
+
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
21
|
+
accessModeRef: { current: string };
|
|
22
|
+
enqueueMessage: (item: Omit<QueuedMessage, 'id'>) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function enqueuePreparedFollowUp(
|
|
26
|
+
deps: FollowUpSnapshotDeps,
|
|
27
|
+
text: string,
|
|
28
|
+
attachments: Attachment[],
|
|
29
|
+
): void {
|
|
30
|
+
const composerPick = deps.composerModelRef.current;
|
|
31
|
+
const model: QueuedMessageModel | null = composerPick
|
|
32
|
+
? { ...composerPick, accessMode: deps.accessModeRef.current as QueuedMessageModel['accessMode'] }
|
|
33
|
+
: null;
|
|
34
|
+
void prepareQueuedAttachments(attachments).then((prepared) => {
|
|
35
|
+
deps.enqueueMessage({ text, attachments: prepared, model });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback,
|
|
1
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { useTimelineScope } from '@/client/hooks/chat/timeline/scope';
|
|
3
3
|
import type { ChatMessageData } from '@/shared/types';
|
|
4
4
|
import { useOmpAgent } from '@/client/hooks/chat/omp';
|
|
@@ -9,7 +9,6 @@ import { useGeneratingState } from '@/client/hooks/chat/timeline/generating';
|
|
|
9
9
|
import { useTimelineAutoScroll } from '@/client/hooks/chat/timeline/auto-scroll';
|
|
10
10
|
import { useChatTimelineActions } from '@/client/hooks/chat/timeline/actions';
|
|
11
11
|
import { useChatTimelineSend } from '@/client/hooks/chat/timeline/send';
|
|
12
|
-
import type { ComposerModelPick } from '@/client/hooks/chat/timeline/deferred-model';
|
|
13
12
|
import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
|
|
14
13
|
import { useUserTurns } from '@/client/hooks/chat/timeline/user-turns';
|
|
15
14
|
import { useRailJump } from '@/client/hooks/chat/timeline/rail-jump';
|
|
@@ -19,6 +18,8 @@ import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-m
|
|
|
19
18
|
import { useComposerModes } from '@/client/hooks/chat/timeline/composer-modes';
|
|
20
19
|
import { useComposerDraft } from '@/client/hooks/chat/composer/use-draft';
|
|
21
20
|
import { isChatRunning } from '@/client/hooks/chat/omp/session-statuses';
|
|
21
|
+
import { useWaitingDependencyChoice } from '@/client/hooks/chat/waiting/choice';
|
|
22
|
+
import { useComposerRefs } from '@/client/hooks/chat/timeline/composer-refs';
|
|
22
23
|
|
|
23
24
|
interface UseChatTimelineOptions {
|
|
24
25
|
folders?: any[];
|
|
@@ -157,28 +158,18 @@ export function useChatTimeline({
|
|
|
157
158
|
});
|
|
158
159
|
const jumpToUserTurn = useRailJump(pagedJumpToTurn, { followRef, scrollRef });
|
|
159
160
|
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const pendingComposerModelRef = useRef<{ provider: string; modelId: string } | null>(null);
|
|
164
|
-
const pendingThinkingLevelRef = useRef<string | null>(null);
|
|
165
|
-
// Live mirror of the composer's model/thinking pick: the enqueue path
|
|
166
|
-
// snapshots it onto queued items so auto-delivery replays those settings.
|
|
167
|
-
const composerModelRef = useRef<{ provider: string; modelId: string; thinkingLevel: string } | null>(null);
|
|
168
|
-
// Picks made WHILE a turn streams: held here so they cannot re-target the
|
|
169
|
-
// answer in flight (omp applies set_model to the running turn), and pushed
|
|
170
|
-
// onto the session right before the next prompt.
|
|
171
|
-
const deferredComposerPickRef = useRef<ComposerModelPick | null>(null);
|
|
172
|
-
// A stashed pick belongs to ONE session's next prompt: carrying it across a
|
|
173
|
-
// switch would silently re-target the session the user just opened.
|
|
174
|
-
useEffect(() => {
|
|
175
|
-
deferredComposerPickRef.current = null;
|
|
176
|
-
}, [sessionId]);
|
|
161
|
+
// The composer's model/thinking mirrors and the mid-run stash (see the hook).
|
|
162
|
+
const { pendingComposerModelRef, pendingThinkingLevelRef, composerModelRef, deferredComposerPickRef } =
|
|
163
|
+
useComposerRefs(sessionId);
|
|
177
164
|
|
|
178
165
|
// Access control is a GLOBAL, persisted user preference (unlike the
|
|
179
166
|
// per-session model/thinking picks).
|
|
180
167
|
const { accessMode, accessModeRef, setAccessMode: handleAccessModeChange } = useChatTimelineAccessMode(appSettings);
|
|
181
168
|
|
|
169
|
+
// "Wait for session" on a NEW chat: the pick, and the ref the send handler
|
|
170
|
+
// reads (see `choice.ts` for why both).
|
|
171
|
+
const waitingDependency = useWaitingDependencyChoice(sessionId);
|
|
172
|
+
|
|
182
173
|
const persistMessages = useCallback((messagesToSave: any[]) => {
|
|
183
174
|
if (!sessionId) return;
|
|
184
175
|
fetch(`/api/chat/${sessionId}`, {
|
|
@@ -291,6 +282,10 @@ export function useChatTimeline({
|
|
|
291
282
|
composerModelRef,
|
|
292
283
|
deferredComposerPickRef,
|
|
293
284
|
accessModeRef,
|
|
285
|
+
waitingDependencyRef: waitingDependency.ref,
|
|
286
|
+
clearWaitingDependency: waitingDependency.clear,
|
|
287
|
+
streamStatusMap,
|
|
288
|
+
spawnSelectionRef: modes.spawnSelectionRef,
|
|
294
289
|
setSearchParams,
|
|
295
290
|
reportActionError,
|
|
296
291
|
});
|
|
@@ -343,6 +338,7 @@ export function useChatTimeline({
|
|
|
343
338
|
planReview,
|
|
344
339
|
composerModelRef,
|
|
345
340
|
deferredComposerPickRef,
|
|
341
|
+
waitingDependency,
|
|
346
342
|
extensionDialogs,
|
|
347
343
|
resolveExtensionDialog,
|
|
348
344
|
respondToExtensionUi: ompAgent.respondToExtensionUi,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Commands Kabmo answers itself, checked before anything can reach the model.
|
|
8
|
+
*
|
|
9
|
+
* Two of them, and both MUST run ahead of the TUI-only guard:
|
|
10
|
+
*
|
|
11
|
+
* - `/btw [question]` is the side-question panel's entry point. omp implements
|
|
12
|
+
* `/btw` in its TUI only, so the guard would refuse the very command Kabmo
|
|
13
|
+
* answers — a bare `/btw` just opens the panel's history.
|
|
14
|
+
* - a command a PLUGIN contributed through `commandPaletteAction` is local
|
|
15
|
+
* work the popup advertised, so it must never arrive as literal text (RPC
|
|
16
|
+
* has no expansion for it, and omp would improvise a turn around the
|
|
17
|
+
* string). A plugin may legitimately claim a name omp implements only in its
|
|
18
|
+
* TUI, and refusing the row the popup just offered would be the composer
|
|
19
|
+
* contradicting itself.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Attachment } from '@/shared/types/chat';
|
|
23
|
+
import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
|
|
24
|
+
import { dispatchPluginCommand } from '@/client/hooks/chat/timeline/plugin-commands';
|
|
25
|
+
|
|
26
|
+
/** True when the text was answered locally — the caller clears the composer and returns. */
|
|
27
|
+
export function dispatchLocalCommand(text: string, attachments: Attachment[]): boolean {
|
|
28
|
+
return dispatchBtwCommand(text, attachments) || dispatchPluginCommand(text);
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The provider/model the generating indicator names a run by.
|
|
8
|
+
*
|
|
9
|
+
* The run's model is persisted BESIDE the stream status and rides the sidebar
|
|
10
|
+
* payload, so the indicator names the real provider/model even for a run this
|
|
11
|
+
* page never started. The transcript is the fallback, not the source: a freshly
|
|
12
|
+
* spawned session has no readable JSONL yet, and one loaded from history may
|
|
13
|
+
* carry a model the run has since switched away from.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useMemo } from 'preact/hooks';
|
|
17
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
18
|
+
import type { SessionDataShape } from '@/client/hooks/chat/timeline/session-load';
|
|
19
|
+
|
|
20
|
+
export interface RunIdentity {
|
|
21
|
+
sessionProvider?: string;
|
|
22
|
+
sessionModelName?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useRunIdentity(input: {
|
|
26
|
+
folders: WorkspaceFolderData[];
|
|
27
|
+
sessionId: string | null;
|
|
28
|
+
sessionData: SessionDataShape | null | undefined;
|
|
29
|
+
modelNames: Record<string, string>;
|
|
30
|
+
}): RunIdentity {
|
|
31
|
+
const { folders, sessionId, sessionData, modelNames } = input;
|
|
32
|
+
const runModel = useMemo(
|
|
33
|
+
() => folders.flatMap((f) => f.sessions ?? []).find((s) => String(s.id) === String(sessionId))?.runModel ?? null,
|
|
34
|
+
[folders, sessionId],
|
|
35
|
+
);
|
|
36
|
+
const fallback = sessionData?.model;
|
|
37
|
+
const fallbackModel = typeof fallback === 'object' ? fallback : null;
|
|
38
|
+
const sessionModelId = runModel?.modelId ?? fallbackModel?.modelId;
|
|
39
|
+
return {
|
|
40
|
+
sessionProvider: runModel?.provider ?? fallbackModel?.provider,
|
|
41
|
+
sessionModelName: sessionModelId
|
|
42
|
+
? (modelNames[sessionModelId] ?? sessionModelId)
|
|
43
|
+
: (typeof fallback === 'string' ? fallback : undefined),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dropping a held prompt from the row that shows it.
|
|
8
|
+
*
|
|
9
|
+
* A refusal is REPORTED: the only two refusals are "already sent" (the prompt
|
|
10
|
+
* is running now, so Stop the session instead) and "no longer there", and both
|
|
11
|
+
* leave the row on screen. Swallowing them would leave a row that looks stuck
|
|
12
|
+
* with no way to learn why.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback } from 'preact/hooks';
|
|
16
|
+
import { cancelWaitingPromptRequest } from '@/client/hooks/chat/waiting/store';
|
|
17
|
+
import { releasePendingSessionByWait } from '@/client/hooks/chat/omp/pending-waiting';
|
|
18
|
+
|
|
19
|
+
export type CancelWait = (waitId: string) => void;
|
|
20
|
+
|
|
21
|
+
export function useCancelWait(onNotice?: (message: string, type?: 'success' | 'error') => void): CancelWait {
|
|
22
|
+
return useCallback((waitId: string) => {
|
|
23
|
+
void cancelWaitingPromptRequest(waitId).then((result) => {
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
onNotice?.(result.error ?? 'Could not cancel the waiting prompt.', 'error');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// The row existed only for this wait, so it goes with it. The topic
|
|
29
|
+
// republish also removes the cue; doing it here keeps the click instant.
|
|
30
|
+
releasePendingSessionByWait(waitId);
|
|
31
|
+
onNotice?.('Waiting prompt cancelled.', 'success');
|
|
32
|
+
});
|
|
33
|
+
}, [onNotice]);
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The composer's "wait for session" pick, per chat.
|
|
8
|
+
*
|
|
9
|
+
* Held in the session's own UI state (`chat.waitingFor`) rather than in
|
|
10
|
+
* component state: the composer is remounted by a stretch, a tab switch and a
|
|
11
|
+
* reload, and a pick that evaporated with it would silently send the prompt
|
|
12
|
+
* immediately — the one outcome the user explicitly deferred.
|
|
13
|
+
*
|
|
14
|
+
* The ref is the other half, and it is not redundant: the send handler is
|
|
15
|
+
* created once per deps record, so reading the pick from render state there
|
|
16
|
+
* would dispatch with whatever it was when that closure was built.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useCallback, useEffect, useRef } from 'preact/hooks';
|
|
20
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
21
|
+
import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
22
|
+
|
|
23
|
+
export interface WaitingDependencyChoice {
|
|
24
|
+
value: WaitingCandidate | null;
|
|
25
|
+
ref: { current: WaitingCandidate | null };
|
|
26
|
+
set: (candidate: WaitingCandidate | null) => void;
|
|
27
|
+
clear: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useWaitingDependencyChoice(sessionId: string | null): WaitingDependencyChoice {
|
|
31
|
+
const [stored, setStored] = useSessionState<WaitingCandidate | null>('chat.waitingFor', null);
|
|
32
|
+
const ref = useRef<WaitingCandidate | null>(stored);
|
|
33
|
+
ref.current = stored;
|
|
34
|
+
|
|
35
|
+
// The pick belongs to ONE chat's next prompt. A pending chat is replaced by
|
|
36
|
+
// the id a spawn adopts, which is a different slot — so nothing has to be
|
|
37
|
+
// cleared for the normal path. This covers the other direction: a session that
|
|
38
|
+
// is not pending at all has no use for a pick, and a stale value there would
|
|
39
|
+
// be applied by the next New Session the user opens in that slot.
|
|
40
|
+
const pending = Boolean(sessionId?.startsWith('new-'));
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!pending && stored !== null) setStored(null);
|
|
43
|
+
}, [pending, stored, setStored]);
|
|
44
|
+
|
|
45
|
+
const set = useCallback((candidate: WaitingCandidate | null) => {
|
|
46
|
+
ref.current = candidate;
|
|
47
|
+
setStored(candidate);
|
|
48
|
+
}, [setStored]);
|
|
49
|
+
|
|
50
|
+
const clear = useCallback(() => set(null), [set]);
|
|
51
|
+
|
|
52
|
+
return { value: stored, ref, set, clear };
|
|
53
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Following a held prompt to its outcome.
|
|
8
|
+
*
|
|
9
|
+
* The dispatch happens on the SERVER, in a process that may be a different
|
|
10
|
+
* instance, a different tab's, or one that started after this page was closed.
|
|
11
|
+
* Nothing on this side sends it, so the only thing that can report it is the
|
|
12
|
+
* `waiting` topic — and what a dispatched row carries is exactly what the local
|
|
13
|
+
* send path produces for itself: the omp session id the spawn answered with.
|
|
14
|
+
*
|
|
15
|
+
* That is why the adoption is here rather than in the composer: the pending
|
|
16
|
+
* row's `new-…` id has to move onto the real one whether or not the composer is
|
|
17
|
+
* still mounted, and whether or not this tab is the one that created it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
21
|
+
import { useWaitingPrompts } from '@/client/hooks/chat/waiting/store';
|
|
22
|
+
import {
|
|
23
|
+
adoptPendingSession,
|
|
24
|
+
releasePendingSession,
|
|
25
|
+
} from '@/client/hooks/chat/omp/pending-sessions';
|
|
26
|
+
import { clearPendingSessionWaiting, pendingSessionWait } from '@/client/hooks/chat/omp/pending-waiting';
|
|
27
|
+
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
28
|
+
|
|
29
|
+
export interface WaitingDispatchOptions {
|
|
30
|
+
/** Surfaces an outcome the user must know about (a failed or cancelled wait). */
|
|
31
|
+
onNotice?: (message: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Adopt the session a held prompt became, and report the outcomes that are not
|
|
36
|
+
* a dispatch.
|
|
37
|
+
*
|
|
38
|
+
* Mounted once, beside the sidebar's own data provider: every layout needs it,
|
|
39
|
+
* and the pending row it adopts lives in the same client store both sidebars
|
|
40
|
+
* read.
|
|
41
|
+
*/
|
|
42
|
+
export function useWaitingDispatch({ onNotice }: WaitingDispatchOptions = {}): void {
|
|
43
|
+
const { prompts } = useWaitingPrompts();
|
|
44
|
+
const [, setSearchParams] = useSearchParams();
|
|
45
|
+
// The toast stack's `pushToast` is a fresh closure per render, so it must not
|
|
46
|
+
// be an effect dependency: every unrelated render would re-run this walk.
|
|
47
|
+
const noticeRef = useRef(onNotice);
|
|
48
|
+
noticeRef.current = onNotice;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
for (const prompt of prompts) {
|
|
52
|
+
const pendingId = prompt.pendingId;
|
|
53
|
+
if (!pendingId) continue;
|
|
54
|
+
const held = pendingSessionWait(pendingId);
|
|
55
|
+
|
|
56
|
+
if (prompt.status === 'dispatched' && prompt.sessionId) {
|
|
57
|
+
// Adopt when this tab OWNS the chat: it still holds the cue, or its URL
|
|
58
|
+
// names the pending id (a reload, a second tab on the same chat). A row
|
|
59
|
+
// from someone else's chat is left alone — adopting it would mint a
|
|
60
|
+
// sidebar entry for a session this tab never opened.
|
|
61
|
+
const openId = new URLSearchParams(window.location.search).get('sessionId');
|
|
62
|
+
if (!held && openId !== pendingId) continue;
|
|
63
|
+
adoptPendingSession(pendingId, prompt.sessionId, prompt.folderId);
|
|
64
|
+
// The URL may still name the pending id — this tab was left on the chat
|
|
65
|
+
// while the dispatch happened elsewhere.
|
|
66
|
+
setSearchParams((prev) => {
|
|
67
|
+
if (prev.get('sessionId') !== pendingId) return prev;
|
|
68
|
+
const next = new URLSearchParams(prev);
|
|
69
|
+
next.set('sessionId', prompt.sessionId as string);
|
|
70
|
+
return next;
|
|
71
|
+
}, { replace: true });
|
|
72
|
+
noticeRef.current?.(`Waiting finished — your prompt is now running in a new session.`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (prompt.status === 'error') {
|
|
77
|
+
const openId = new URLSearchParams(window.location.search).get('sessionId');
|
|
78
|
+
if (!held && openId !== pendingId) continue;
|
|
79
|
+
clearPendingSessionWaiting(pendingId);
|
|
80
|
+
releasePendingSession(pendingId);
|
|
81
|
+
noticeRef.current?.(`The prompt you held was not sent: ${prompt.error ?? 'the dispatch failed.'}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (prompt.status === 'cancelled' && held) {
|
|
86
|
+
clearPendingSessionWaiting(pendingId);
|
|
87
|
+
noticeRef.current?.('The held prompt was cancelled.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}, [prompts, setSearchParams]);
|
|
91
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The held prompt of the chat on screen, and how to drop it.
|
|
8
|
+
*
|
|
9
|
+
* A chat whose prompt is deferred has no session and no transcript, so the
|
|
10
|
+
* composer is the only place the wait is visible — and it must be DISABLED
|
|
11
|
+
* while the wait lasts: a second prompt would have nowhere to go.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useMemo } from 'preact/hooks';
|
|
15
|
+
import { useWaitingPrompts } from '@/client/hooks/chat/waiting/store';
|
|
16
|
+
import { useCancelWait } from '@/client/hooks/chat/waiting/cancel';
|
|
17
|
+
import type { WaitingPrompt } from '@/shared/types/waiting';
|
|
18
|
+
|
|
19
|
+
export interface HeldWait {
|
|
20
|
+
/** The waiting row for the open chat, or null. */
|
|
21
|
+
heldWait: WaitingPrompt | null;
|
|
22
|
+
cancelHeldWait: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useHeldWait(
|
|
26
|
+
sessionId: string | null,
|
|
27
|
+
isPendingSession: boolean,
|
|
28
|
+
onNotice?: (message: string, type?: 'success' | 'error') => void,
|
|
29
|
+
): HeldWait {
|
|
30
|
+
const { prompts } = useWaitingPrompts();
|
|
31
|
+
const cancelWait = useCancelWait(onNotice);
|
|
32
|
+
const heldWait = useMemo(
|
|
33
|
+
() => (isPendingSession
|
|
34
|
+
? prompts.find((prompt) => prompt.pendingId === sessionId && prompt.status === 'waiting') ?? null
|
|
35
|
+
: null),
|
|
36
|
+
[isPendingSession, sessionId, prompts],
|
|
37
|
+
);
|
|
38
|
+
const cancelHeldWait = useCallback(() => {
|
|
39
|
+
if (heldWait) cancelWait(heldWait.id);
|
|
40
|
+
}, [heldWait, cancelWait]);
|
|
41
|
+
return { heldWait, cancelHeldWait };
|
|
42
|
+
}
|