@kabmo-labs/kabmo 0.3.1 → 0.5.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-cs3b557t.js} +792 -792
- 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/TopNavbar.tsx +5 -3
- 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/Header.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
- 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/LayoutWrapper.tsx +4 -0
- package/src/client/components/mobile/RightSidebar.tsx +30 -36
- package/src/client/components/mobile/SessionSidebar.tsx +124 -101
- package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
- 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 +47 -14
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
- package/src/client/components/mobile/panel-chips.test.tsx +155 -0
- package/src/client/components/mobile/panel-chips.tsx +74 -0
- 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 +15 -10
- 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 +162 -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/index.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
- 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/queue-rewind.test.ts +6 -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/ui/toasts.ts +12 -1
- 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/ui/titlebar.test.ts +124 -0
- package/src/shared/lib/ui/titlebar.ts +54 -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/mobile-dock.test.ts +65 -0
- package/src/shared/lib/workspace/mobile-dock.ts +55 -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/src/test-support/silent-socket.ts +69 -0
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The pending store's WAITING rules.
|
|
8
|
+
*
|
|
9
|
+
* A held prompt's row is the only thing on screen for a chat that has no
|
|
10
|
+
* transcript and no omp id, so the store has to keep it for as long as the wait
|
|
11
|
+
* lasts — and drop the cue the moment the wait ends, or the row keeps claiming
|
|
12
|
+
* something the server no longer holds.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
16
|
+
import {
|
|
17
|
+
adoptPendingSession,
|
|
18
|
+
listPendingSessions,
|
|
19
|
+
markPendingSessionSent,
|
|
20
|
+
reconcilePendingSessions,
|
|
21
|
+
releasePendingSession,
|
|
22
|
+
startPendingSession,
|
|
23
|
+
subscribePendingSessions,
|
|
24
|
+
} from '@/client/hooks/chat/omp/pending-sessions';
|
|
25
|
+
import {
|
|
26
|
+
clearPendingSessionWaiting,
|
|
27
|
+
markPendingSessionWaiting,
|
|
28
|
+
pendingSessionWait,
|
|
29
|
+
releasePendingSessionByWait,
|
|
30
|
+
} from '@/client/hooks/chat/omp/pending-waiting';
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
for (const entry of listPendingSessions()) releasePendingSession(entry.id);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('pending session waiting state', () => {
|
|
37
|
+
test('a held prompt keeps its row past the settle window', () => {
|
|
38
|
+
const id = startPendingSession(1);
|
|
39
|
+
markPendingSessionWaiting(id, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'Session A' });
|
|
40
|
+
|
|
41
|
+
// Far past PENDING_SETTLE_MS, which is the bound a SENT prompt gets.
|
|
42
|
+
reconcilePendingSessions({
|
|
43
|
+
listed: new Set(),
|
|
44
|
+
running: new Set(),
|
|
45
|
+
activeId: null,
|
|
46
|
+
now: Date.now() + 10 * 60_000,
|
|
47
|
+
});
|
|
48
|
+
expect(listPendingSessions().map((entry) => entry.id)).toContain(id);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The contrast case: a SENT prompt with no wait behind it is released on the
|
|
52
|
+
// settle window, which is what makes the waiting rule a deliberate exception
|
|
53
|
+
// rather than a disabled timer.
|
|
54
|
+
test('an ordinary sent prompt still expires on the settle window', () => {
|
|
55
|
+
const id = startPendingSession(1);
|
|
56
|
+
markPendingSessionSent(id);
|
|
57
|
+
reconcilePendingSessions({ listed: new Set(), running: new Set(), activeId: null, now: Date.now() + 10 * 60_000 });
|
|
58
|
+
expect(listPendingSessions().map((entry) => entry.id)).not.toContain(id);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('clearing the wait keeps the row but drops the cue', () => {
|
|
62
|
+
const id = startPendingSession(1);
|
|
63
|
+
markPendingSessionWaiting(id, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'Session A' });
|
|
64
|
+
clearPendingSessionWaiting(id);
|
|
65
|
+
expect(pendingSessionWait(id)).toBeNull();
|
|
66
|
+
expect(listPendingSessions().map((entry) => entry.id)).toContain(id);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a cancelled wait drops its row by the wait id', () => {
|
|
70
|
+
const first = startPendingSession(1);
|
|
71
|
+
const second = startPendingSession(1);
|
|
72
|
+
markPendingSessionWaiting(first, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'A' });
|
|
73
|
+
markPendingSessionWaiting(second, { waitId: 'wait-2', dependsOn: 'sess-b', dependsOnTitle: 'B' });
|
|
74
|
+
|
|
75
|
+
releasePendingSessionByWait('wait-1');
|
|
76
|
+
const ids = listPendingSessions().map((entry) => entry.id);
|
|
77
|
+
expect(ids).not.toContain(first);
|
|
78
|
+
expect(ids).toContain(second);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// The wait ends where the spawn answers: the new row is a real session from
|
|
82
|
+
// that moment, so a cue carried across would say it is still held.
|
|
83
|
+
test('adoption drops the waiting cue', () => {
|
|
84
|
+
const id = startPendingSession(1);
|
|
85
|
+
markPendingSessionWaiting(id, { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'A' });
|
|
86
|
+
adoptPendingSession(id, 'omp-session-9', 1);
|
|
87
|
+
expect(pendingSessionWait('omp-session-9')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// The cue is re-stamped from the server list on every publish, so an identical
|
|
91
|
+
// write must be a no-op — otherwise every sidebar re-renders on every frame.
|
|
92
|
+
test('re-stamping the same wait does not notify', () => {
|
|
93
|
+
const id = startPendingSession(1);
|
|
94
|
+
const wait = { waitId: 'wait-1', dependsOn: 'sess-a', dependsOnTitle: 'A' };
|
|
95
|
+
markPendingSessionWaiting(id, wait);
|
|
96
|
+
let notifications = 0;
|
|
97
|
+
const stop = subscribePendingSessions(() => { notifications += 1; });
|
|
98
|
+
markPendingSessionWaiting(id, { ...wait });
|
|
99
|
+
stop();
|
|
100
|
+
expect(notifications).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The WAITING half of the pending store.
|
|
8
|
+
*
|
|
9
|
+
* Split from `pending-sessions.ts` for the repo's per-file ceiling, and it is a
|
|
10
|
+
* clean seam: that file owns an entry's LIFETIME (mint, adopt, retain, drop),
|
|
11
|
+
* this one owns the single field that says the chat's prompt is deferred. The
|
|
12
|
+
* store exposes one write hook (`patchPendingEntry`) so the field is still
|
|
13
|
+
* written through the same map, the same emit and the same no-op check.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PendingSessionEntry } from '@/client/hooks/chat/omp/pending-sessions';
|
|
17
|
+
import { listPendingSessions, patchPendingEntry } from '@/client/hooks/chat/omp/pending-sessions';
|
|
18
|
+
|
|
19
|
+
export type PendingWait = NonNullable<PendingSessionEntry['waiting']>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The chat's prompt is held until another session finishes.
|
|
23
|
+
*
|
|
24
|
+
* `sentAt` is set as well, deliberately: the row now stands for a real prompt
|
|
25
|
+
* the user has committed, so the "an unsent draft with nothing in it may go"
|
|
26
|
+
* rule must not reclaim it. The waiting half is what survives the switch —
|
|
27
|
+
* `reconcilePendingSessions` keeps it while the server still reports the row.
|
|
28
|
+
*/
|
|
29
|
+
export function markPendingSessionWaiting(id: string, waiting: PendingWait): void {
|
|
30
|
+
patchPendingEntry(id, (entry) => ({ ...entry, sentAt: entry.sentAt ?? Date.now(), waiting }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The wait ended (dispatched, cancelled, failed): keep the row, drop the cue. */
|
|
34
|
+
export function clearPendingSessionWaiting(id: string): void {
|
|
35
|
+
patchPendingEntry(id, (entry) => (entry.waiting ? { ...entry, waiting: undefined } : entry));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The waiting half of one entry, or null when the row is not waiting. */
|
|
39
|
+
export function pendingSessionWait(id: string): PendingWait | null {
|
|
40
|
+
return listPendingSessions().find((entry) => entry.id === id)?.waiting ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Drop the entry whose prompt was held by `waitId` (the wait was cancelled). */
|
|
44
|
+
export function releasePendingSessionByWait(waitId: string): void {
|
|
45
|
+
for (const entry of listPendingSessions()) {
|
|
46
|
+
if (entry.waiting?.waitId === waitId) patchPendingEntry(entry.id, () => null);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
reconcilePendingSessions,
|
|
31
31
|
usePendingSessions,
|
|
32
32
|
} from '@/client/hooks/chat/omp/pending-sessions';
|
|
33
|
+
import { markPendingSessionWaiting } from '@/client/hooks/chat/omp/pending-waiting';
|
|
34
|
+
import { useWaitingPrompts } from '@/client/hooks/chat/waiting/store';
|
|
33
35
|
import { withPendingRows } from '@/client/hooks/chat/omp/pending-rows';
|
|
34
36
|
import { writeSetting } from '@/shared/lib/settings/client';
|
|
35
37
|
import { subscribeSessionKey } from '@/shared/lib/workspace/session-state/listeners';
|
|
@@ -150,6 +152,11 @@ export function useSessionSidebarController(
|
|
|
150
152
|
// A chat omp has not written a transcript for yet. The store owns the row's
|
|
151
153
|
// whole life — see `pending-sessions.ts` — so the sidebar only reads it here.
|
|
152
154
|
const pendingEntries = usePendingSessions();
|
|
155
|
+
// A held prompt is a property of the SERVER row, not of this tab: a reload (or
|
|
156
|
+
// a second tab) re-enters a pending chat whose wait it never created, and the
|
|
157
|
+
// row has to keep saying what it waits for. The cue is therefore re-stamped
|
|
158
|
+
// from the list on every publish, and cleared when the row is gone.
|
|
159
|
+
const { prompts: waitingPrompts, loaded: waitingLoaded } = useWaitingPrompts();
|
|
153
160
|
const runningIds = useMemo(
|
|
154
161
|
() => new Set(Object.entries(sessionStatus).filter(([, status]) => status === 'stream').map(([id]) => id)),
|
|
155
162
|
[sessionStatus],
|
|
@@ -165,6 +172,28 @@ export function useSessionSidebarController(
|
|
|
165
172
|
hydratePendingSession(sessionParam, folderIdParam, sessionStatus[sessionParam] === 'stream');
|
|
166
173
|
}, [sessionParam, folders, initializing, folderIdParam, sessionStatus]);
|
|
167
174
|
|
|
175
|
+
// The cue is STAMPED from the server list, never cleared from an absence.
|
|
176
|
+
//
|
|
177
|
+
// An absence is not evidence: the effect runs on the render a hold triggers,
|
|
178
|
+
// one frame before the topic's first snapshot, and a snapshot resolved before
|
|
179
|
+
// the create does not carry the row either. Clearing on that deleted the cue
|
|
180
|
+
// the user had just made — and, with it, the id the dispatch watcher needs to
|
|
181
|
+
// adopt. A wait ENDS through a terminal row, which the watcher
|
|
182
|
+
// (`waiting/dispatch.ts`) reads: dispatched, cancelled or failed.
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (initializing || !waitingLoaded) return;
|
|
185
|
+
for (const entry of pendingEntries) {
|
|
186
|
+
const id = String(entry.id);
|
|
187
|
+
const held = waitingPrompts.find((prompt) => prompt.pendingId === id && prompt.status === 'waiting');
|
|
188
|
+
if (!held || entry.waiting?.waitId === held.id) continue;
|
|
189
|
+
markPendingSessionWaiting(id, {
|
|
190
|
+
waitId: held.id,
|
|
191
|
+
dependsOn: held.dependsOn,
|
|
192
|
+
dependsOnTitle: held.dependsOnTitle,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}, [pendingEntries, waitingPrompts, waitingLoaded, initializing]);
|
|
196
|
+
|
|
168
197
|
// A pending chat's row is kept only while its draft still holds something,
|
|
169
198
|
// so the reconcile below has to re-run when a draft SLOT moves — the blob
|
|
170
199
|
// arriving is the common case (a chat opened and left empty has its entry
|
|
@@ -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
|
+
}
|
|
@@ -28,6 +28,7 @@ import type { QueuedMessage } from '@/shared/types';
|
|
|
28
28
|
import { SessionStateContext } from '@/client/hooks/workspace/session-state/context';
|
|
29
29
|
import { useChatTimelineQueue, type ChatTimelineQueueResult } from '@/client/hooks/chat/timeline/queue';
|
|
30
30
|
import { requestRewind } from '@/client/hooks/chat/timeline/rewind';
|
|
31
|
+
import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
|
|
31
32
|
|
|
32
33
|
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'CustomEvent'] as const;
|
|
33
34
|
/** The runner's own globals, restored on teardown so later files still have them. */
|
|
@@ -80,12 +81,17 @@ afterAll(() => {
|
|
|
80
81
|
}
|
|
81
82
|
});
|
|
82
83
|
beforeEach(() => {
|
|
84
|
+
// The queue hook subscribes to its session topic on mount, and this origin has
|
|
85
|
+
// no listener: a real dial would fail asynchronously, after the globals are
|
|
86
|
+
// restored. Per case, because `restoreRealtime` clears the constructor.
|
|
87
|
+
silenceRealtime();
|
|
83
88
|
calls.length = 0;
|
|
84
89
|
canonical = [];
|
|
85
90
|
gate = null;
|
|
86
91
|
stubFetch();
|
|
87
92
|
});
|
|
88
93
|
afterEach(() => {
|
|
94
|
+
restoreRealtime();
|
|
89
95
|
if (container) render(null, container);
|
|
90
96
|
container?.remove();
|
|
91
97
|
latest = null;
|
|
@@ -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
|
+
}
|