@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,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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The hold path: what makes a prompt deferred instead of sent.
|
|
8
|
+
*
|
|
9
|
+
* Three behaviours are pinned, and each is a decision rather than an
|
|
10
|
+
* implementation detail:
|
|
11
|
+
*
|
|
12
|
+
* - a stale pick (the session finished while the draft was written) SENDS,
|
|
13
|
+
* and says so — a wait created on it would be released by the next sweep;
|
|
14
|
+
* - a refusal KEEPS the draft, because the user's text is the one thing that
|
|
15
|
+
* cannot be recovered from anywhere else;
|
|
16
|
+
* - a success writes the pending row's cue, which is what keeps the sidebar
|
|
17
|
+
* row on screen until the dispatch adopts it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
21
|
+
import { holdPromptForDependency, type HoldDeps } from '@/client/hooks/chat/waiting/hold';
|
|
22
|
+
import {
|
|
23
|
+
listPendingSessions,
|
|
24
|
+
releasePendingSession,
|
|
25
|
+
startPendingSession,
|
|
26
|
+
} from '@/client/hooks/chat/omp/pending-sessions';
|
|
27
|
+
import { pendingSessionWait } from '@/client/hooks/chat/omp/pending-waiting';
|
|
28
|
+
import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
29
|
+
import type { Attachment } from '@/shared/types/chat';
|
|
30
|
+
|
|
31
|
+
const DEPENDENCY: WaitingCandidate = { id: 'sess-a', title: 'Refactor parser', folderName: 'alpha' };
|
|
32
|
+
|
|
33
|
+
interface Recorded {
|
|
34
|
+
drafts: string[];
|
|
35
|
+
attachments: Attachment[][];
|
|
36
|
+
errors: string[];
|
|
37
|
+
cleared: number;
|
|
38
|
+
posted: Record<string, unknown>[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const nativeFetch = globalThis.fetch;
|
|
42
|
+
/** The id `startPendingSession` minted for this case; the store owns the clock. */
|
|
43
|
+
let pendingId = '';
|
|
44
|
+
|
|
45
|
+
function harness(overrides: Partial<HoldDeps> = {}): { deps: HoldDeps; recorded: Recorded } {
|
|
46
|
+
const recorded: Recorded = { drafts: [], attachments: [], errors: [], cleared: 0, posted: [] };
|
|
47
|
+
const deps: HoldDeps = {
|
|
48
|
+
sessionId: pendingId,
|
|
49
|
+
folders: [{ id: 1, name: 'alpha', project_path: '/tmp/alpha', isExpanded: true, hasMore: false, totalSessions: 0, sessions: [] }],
|
|
50
|
+
selectedFolderId: 1,
|
|
51
|
+
composerModelRef: { current: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high' } },
|
|
52
|
+
accessModeRef: { current: 'always-ask' },
|
|
53
|
+
spawnModesRef: { current: { plan: false, goal: false } },
|
|
54
|
+
streamStatusMap: { 'sess-a': 'stream' },
|
|
55
|
+
setInputValue: (value) => recorded.drafts.push(value),
|
|
56
|
+
setAttachments: (value) => recorded.attachments.push(value),
|
|
57
|
+
reportActionError: (message) => recorded.errors.push(message),
|
|
58
|
+
clearChoice: () => { recorded.cleared += 1; },
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
return { deps, recorded };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
pendingId = startPendingSession(1);
|
|
66
|
+
(globalThis as Record<string, unknown>).fetch = async (_input: unknown, init?: { body?: string }) => {
|
|
67
|
+
const body = init?.body ? JSON.parse(init.body) : {};
|
|
68
|
+
createdBody = body;
|
|
69
|
+
return new Response(JSON.stringify({ prompt: { id: 'wait-1', ...body, status: 'waiting' } }), {
|
|
70
|
+
status: 200,
|
|
71
|
+
headers: { 'content-type': 'application/json' },
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let createdBody: Record<string, unknown> = {};
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
globalThis.fetch = nativeFetch;
|
|
80
|
+
for (const entry of listPendingSessions()) releasePendingSession(entry.id);
|
|
81
|
+
createdBody = {};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('holdPromptForDependency', () => {
|
|
85
|
+
test('writes the row and marks the pending chat as waiting', async () => {
|
|
86
|
+
const { deps, recorded } = harness();
|
|
87
|
+
const held = await holdPromptForDependency(deps, DEPENDENCY, 'summarise it', []);
|
|
88
|
+
|
|
89
|
+
expect(held).toBe(true);
|
|
90
|
+
expect(recorded.drafts).toEqual(['']);
|
|
91
|
+
expect(recorded.attachments).toEqual([[]]);
|
|
92
|
+
expect(recorded.cleared).toBe(1);
|
|
93
|
+
expect(createdBody).toMatchObject({
|
|
94
|
+
dependsOn: 'sess-a',
|
|
95
|
+
dependsOnTitle: 'Refactor parser',
|
|
96
|
+
prompt: 'summarise it',
|
|
97
|
+
cwd: '/tmp/alpha',
|
|
98
|
+
folderId: 1,
|
|
99
|
+
model: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'high', accessMode: 'always-ask' },
|
|
100
|
+
});
|
|
101
|
+
expect(pendingSessionWait(pendingId)).toMatchObject({ waitId: 'wait-1', dependsOn: 'sess-a' });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('no pick means the caller sends normally', async () => {
|
|
105
|
+
const { deps, recorded } = harness();
|
|
106
|
+
expect(await holdPromptForDependency(deps, null, 'hello', [])).toBe(false);
|
|
107
|
+
expect(recorded.drafts).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The dropdown was opened while the dependency was running; by the time the
|
|
111
|
+
// user hit send it had finished. Holding would be a wait of zero length.
|
|
112
|
+
test('a dependency that finished meanwhile sends instead, and says so', async () => {
|
|
113
|
+
const { deps, recorded } = harness({ streamStatusMap: { 'sess-a': 'finish' } });
|
|
114
|
+
expect(await holdPromptForDependency(deps, DEPENDENCY, 'hello', [])).toBe(false);
|
|
115
|
+
expect(recorded.errors).toEqual(['"Refactor parser" has already finished — sending now instead of waiting.']);
|
|
116
|
+
expect(recorded.cleared).toBe(1);
|
|
117
|
+
expect(recorded.drafts).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a refused request keeps the draft and reports the server reason', async () => {
|
|
121
|
+
(globalThis as Record<string, unknown>).fetch = async () =>
|
|
122
|
+
new Response(JSON.stringify({ error: 'That session has already finished' }), {
|
|
123
|
+
status: 409,
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
});
|
|
126
|
+
const { deps, recorded } = harness();
|
|
127
|
+
expect(await holdPromptForDependency(deps, DEPENDENCY, 'hello', [])).toBe(false);
|
|
128
|
+
expect(recorded.errors).toEqual(['That session has already finished']);
|
|
129
|
+
expect(recorded.drafts).toEqual([]);
|
|
130
|
+
expect(pendingSessionWait(pendingId)).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a chat with no workspace is refused before anything is written', async () => {
|
|
134
|
+
const { deps, recorded } = harness({ folders: [], selectedFolderId: null });
|
|
135
|
+
expect(await holdPromptForDependency(deps, DEPENDENCY, 'hello', [])).toBe(false);
|
|
136
|
+
expect(recorded.errors).toEqual(['Pick a workspace before holding this prompt.']);
|
|
137
|
+
expect(createdBody).toEqual({});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sending a NEW chat's prompt into a wait instead of dispatching it now.
|
|
8
|
+
*
|
|
9
|
+
* The prompt never reaches omp from here: the row is written server-side and a
|
|
10
|
+
* sweep spawns the session when the dependency finishes. That is why this path
|
|
11
|
+
* clears the composer and marks the pending row, and why it does NOT arm the
|
|
12
|
+
* generating state — nothing is running yet, and a spinner would say otherwise.
|
|
13
|
+
*
|
|
14
|
+
* The dependency is re-checked against the live status map at send time. A
|
|
15
|
+
* dropdown opened a minute ago can name a session that has since finished, and
|
|
16
|
+
* a prompt held on it would be released by the very next sweep: the user asked
|
|
17
|
+
* to wait, so the honest outcome is to send it now and say so.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Attachment } from '@/shared/types/chat';
|
|
21
|
+
import type { WorkspaceFolderData } from '@/shared/types/workspace';
|
|
22
|
+
import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
|
|
23
|
+
import { createWaitingPromptRequest } from '@/client/hooks/chat/waiting/store';
|
|
24
|
+
import { markPendingSessionWaiting } from '@/client/hooks/chat/omp/pending-waiting';
|
|
25
|
+
import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The slice of the composer's deps record a hold needs.
|
|
29
|
+
*
|
|
30
|
+
* Structural, not an import of the actions record: the caller passes that whole
|
|
31
|
+
* record and TypeScript checks it against this, so the two stay decoupled and
|
|
32
|
+
* this module never depends on the send hook's shape.
|
|
33
|
+
*/
|
|
34
|
+
export interface ComposerHoldDeps {
|
|
35
|
+
sessionId: string | null;
|
|
36
|
+
folders: WorkspaceFolderData[];
|
|
37
|
+
selectedFolderId: number | null;
|
|
38
|
+
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
39
|
+
accessModeRef: { current: string };
|
|
40
|
+
spawnSelectionRef?: { current: { plan: boolean; goal: boolean } | null };
|
|
41
|
+
streamStatusMap?: Record<string, string | undefined>;
|
|
42
|
+
setInputValue: (value: string) => void;
|
|
43
|
+
setInputAttachments: (attachments: Attachment[] | ((prev: Attachment[]) => Attachment[])) => void;
|
|
44
|
+
reportActionError: (message: string) => void;
|
|
45
|
+
clearWaitingDependency?: () => void;
|
|
46
|
+
waitingDependencyRef?: { current: WaitingCandidate | null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The composer's own entry point: hold the draft if a dependency is picked and
|
|
51
|
+
* the chat is idle, else answer false and let the normal send proceed.
|
|
52
|
+
*
|
|
53
|
+
* Idle is part of the condition rather than the caller's business: a chat with
|
|
54
|
+
* a run in flight queues its follow-up (or steers), which is a different
|
|
55
|
+
* decision made further down the send path.
|
|
56
|
+
*/
|
|
57
|
+
export async function holdFromComposer(
|
|
58
|
+
deps: ComposerHoldDeps,
|
|
59
|
+
text: string,
|
|
60
|
+
attachments: Attachment[],
|
|
61
|
+
busy: boolean,
|
|
62
|
+
): Promise<boolean> {
|
|
63
|
+
const choice = deps.waitingDependencyRef?.current ?? null;
|
|
64
|
+
if (!choice || busy) return false;
|
|
65
|
+
return holdPromptForDependency({
|
|
66
|
+
sessionId: deps.sessionId,
|
|
67
|
+
folders: deps.folders,
|
|
68
|
+
selectedFolderId: deps.selectedFolderId,
|
|
69
|
+
composerModelRef: deps.composerModelRef,
|
|
70
|
+
accessModeRef: deps.accessModeRef,
|
|
71
|
+
spawnModesRef: deps.spawnSelectionRef ?? { current: null },
|
|
72
|
+
streamStatusMap: deps.streamStatusMap ?? {},
|
|
73
|
+
setInputValue: deps.setInputValue,
|
|
74
|
+
setAttachments: (next) => deps.setInputAttachments(next),
|
|
75
|
+
reportActionError: deps.reportActionError,
|
|
76
|
+
clearChoice: () => deps.clearWaitingDependency?.(),
|
|
77
|
+
}, choice, text, attachments);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface HoldDeps {
|
|
81
|
+
sessionId: string | null;
|
|
82
|
+
folders: WorkspaceFolderData[];
|
|
83
|
+
selectedFolderId: number | null;
|
|
84
|
+
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
85
|
+
accessModeRef: { current: string };
|
|
86
|
+
spawnModesRef: { current: { plan: boolean; goal: boolean } | null };
|
|
87
|
+
streamStatusMap: Record<string, string | undefined>;
|
|
88
|
+
setInputValue: (value: string) => void;
|
|
89
|
+
setAttachments: (attachments: Attachment[]) => void;
|
|
90
|
+
reportActionError: (message: string) => void;
|
|
91
|
+
/** The dependency pick, cleared once the prompt is held. */
|
|
92
|
+
clearChoice: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Hold `text` until `dependency` finishes.
|
|
97
|
+
*
|
|
98
|
+
* Returns true when the prompt was held — the caller must then do nothing else.
|
|
99
|
+
* A false return means the caller should fall through to a normal send: either
|
|
100
|
+
* there is no dependency, or it is no longer running.
|
|
101
|
+
*/
|
|
102
|
+
export async function holdPromptForDependency(
|
|
103
|
+
deps: HoldDeps,
|
|
104
|
+
dependency: WaitingCandidate | null,
|
|
105
|
+
text: string,
|
|
106
|
+
attachments: Attachment[],
|
|
107
|
+
): Promise<boolean> {
|
|
108
|
+
if (!dependency) return false;
|
|
109
|
+
if (deps.streamStatusMap[dependency.id] !== 'stream') {
|
|
110
|
+
// Stale pick: the session finished while the draft was being written. Send
|
|
111
|
+
// normally rather than creating a wait that releases on the next sweep.
|
|
112
|
+
deps.reportActionError(`"${dependency.title}" has already finished — sending now instead of waiting.`);
|
|
113
|
+
deps.clearChoice();
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const folder = deps.folders.find((entry) => String(entry.id) === String(deps.selectedFolderId));
|
|
117
|
+
const cwd = folder?.project_path || folder?.name;
|
|
118
|
+
if (!cwd) {
|
|
119
|
+
deps.reportActionError('Pick a workspace before holding this prompt.');
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Attachments become the persisted shape here, while the live `File` handles
|
|
124
|
+
// still exist: the server sends the prompt hours later, from the row.
|
|
125
|
+
const prepared = await prepareQueuedAttachments(attachments);
|
|
126
|
+
const composerPick = deps.composerModelRef.current;
|
|
127
|
+
try {
|
|
128
|
+
const created = await createWaitingPromptRequest({
|
|
129
|
+
pendingId: deps.sessionId,
|
|
130
|
+
dependsOn: dependency.id,
|
|
131
|
+
dependsOnTitle: dependency.title,
|
|
132
|
+
prompt: text,
|
|
133
|
+
attachments: prepared,
|
|
134
|
+
model: composerPick ? { ...composerPick, accessMode: deps.accessModeRef.current } : null,
|
|
135
|
+
modes: deps.spawnModesRef.current,
|
|
136
|
+
cwd,
|
|
137
|
+
folderId: folder ? Number(folder.id) : null,
|
|
138
|
+
});
|
|
139
|
+
if (deps.sessionId) {
|
|
140
|
+
markPendingSessionWaiting(deps.sessionId, {
|
|
141
|
+
waitId: created.id,
|
|
142
|
+
dependsOn: dependency.id,
|
|
143
|
+
dependsOnTitle: dependency.title,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
deps.setInputValue('');
|
|
147
|
+
deps.setAttachments([]);
|
|
148
|
+
deps.clearChoice();
|
|
149
|
+
return true;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
// The draft is KEPT: a refusal that also threw away the prompt would be the
|
|
152
|
+
// worst of both outcomes, and the composer still holds what the user wrote.
|
|
153
|
+
deps.reportActionError(error instanceof Error ? error.message : String(error));
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client view of the waiting prompts (`waiting_prompts`, owned by the server).
|
|
8
|
+
*
|
|
9
|
+
* The panel is a VIEW, exactly like the follow-up queue: the server holds the
|
|
10
|
+
* clock, the row is the state, and this hook reads it off the `waiting` topic.
|
|
11
|
+
* The HTTP route is the fallback for a socket that is down.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
15
|
+
import { useRealtimeTopic } from '@/client/hooks/ui/realtime';
|
|
16
|
+
import { TOPIC_WAITING } from '@/shared/lib/realtime/protocol';
|
|
17
|
+
import type { WaitingPrompt } from '@/shared/types/waiting';
|
|
18
|
+
|
|
19
|
+
export interface WaitingPromptsState {
|
|
20
|
+
prompts: WaitingPrompt[];
|
|
21
|
+
/**
|
|
22
|
+
* Whether the list has been READ from the server.
|
|
23
|
+
*
|
|
24
|
+
* Load-bearing for anything that CLEARS state from an absence: before the
|
|
25
|
+
* first payload, "no row names this wait" is not a fact, it is a page that
|
|
26
|
+
* has not loaded yet. Clearing on it deleted a cue the user had just created —
|
|
27
|
+
* the controller's effect runs on the render the hold triggers, one frame
|
|
28
|
+
* before the topic's first snapshot.
|
|
29
|
+
*/
|
|
30
|
+
loaded: boolean;
|
|
31
|
+
/** Ask the server for a fresh snapshot (a manual refresh). */
|
|
32
|
+
refresh: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useWaitingPrompts(): WaitingPromptsState {
|
|
36
|
+
const topic = useRealtimeTopic<WaitingPrompt[]>(TOPIC_WAITING);
|
|
37
|
+
const [fallback, setFallback] = useState<WaitingPrompt[]>([]);
|
|
38
|
+
const [loadedFallback, setLoadedFallback] = useState(false);
|
|
39
|
+
|
|
40
|
+
// The topic is the source; the HTTP read only fills in before the first frame
|
|
41
|
+
// lands, or when the socket is down (a phone that just woke).
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (Array.isArray(topic.data)) return;
|
|
44
|
+
let active = true;
|
|
45
|
+
fetch('/api/waiting')
|
|
46
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
47
|
+
.then((data) => {
|
|
48
|
+
if (!active || !Array.isArray(data?.prompts)) return;
|
|
49
|
+
setFallback(data.prompts as WaitingPrompt[]);
|
|
50
|
+
setLoadedFallback(true);
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {});
|
|
53
|
+
return () => {
|
|
54
|
+
active = false;
|
|
55
|
+
};
|
|
56
|
+
}, [topic.data]);
|
|
57
|
+
|
|
58
|
+
const fromTopic = Array.isArray(topic.data);
|
|
59
|
+
const prompts = fromTopic ? (topic.data as WaitingPrompt[]) : fallback;
|
|
60
|
+
const refresh = useCallback(() => topic.refresh(), [topic]);
|
|
61
|
+
return {
|
|
62
|
+
prompts,
|
|
63
|
+
// The HTTP read counts too: it is the same list, and a socket that is down
|
|
64
|
+
// must not leave every consumer believing the server holds nothing.
|
|
65
|
+
loaded: fromTopic || loadedFallback,
|
|
66
|
+
refresh,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Create a held prompt. Throws the server's own reason on a refusal. */
|
|
71
|
+
export async function createWaitingPromptRequest(input: {
|
|
72
|
+
pendingId: string | null;
|
|
73
|
+
dependsOn: string;
|
|
74
|
+
dependsOnTitle: string | null;
|
|
75
|
+
prompt: string;
|
|
76
|
+
attachments: unknown[];
|
|
77
|
+
model: unknown;
|
|
78
|
+
modes: { plan: boolean; goal: boolean } | null;
|
|
79
|
+
cwd: string;
|
|
80
|
+
folderId: number | null;
|
|
81
|
+
}): Promise<WaitingPrompt> {
|
|
82
|
+
const response = await fetch('/api/waiting', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify(input),
|
|
86
|
+
});
|
|
87
|
+
const data = await response.json().catch(() => null);
|
|
88
|
+
if (!response.ok || !data?.prompt) {
|
|
89
|
+
throw new Error(typeof data?.error === 'string' ? data.error : `HTTP ${response.status}`);
|
|
90
|
+
}
|
|
91
|
+
return data.prompt as WaitingPrompt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Cancel a held prompt. Returns the server's refusal rather than swallowing it. */
|
|
95
|
+
export async function cancelWaitingPromptRequest(id: string): Promise<{ ok: boolean; error?: string }> {
|
|
96
|
+
const response = await fetch(`/api/waiting?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
97
|
+
if (response.ok) return { ok: true };
|
|
98
|
+
const data = await response.json().catch(() => null);
|
|
99
|
+
return { ok: false, error: typeof data?.error === 'string' ? data.error : `HTTP ${response.status}` };
|
|
100
|
+
}
|
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { useRef, useState } from 'preact/hooks';
|
|
2
2
|
import type { ToastData } from '@/client/components/common/Toast';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** What `useToasts` hands back — named, so a component takes the contract rather than `ReturnType`. */
|
|
5
|
+
export interface ToastsHandle {
|
|
6
|
+
toasts: ToastData[];
|
|
7
|
+
pushToast: (
|
|
8
|
+
message: string,
|
|
9
|
+
type?: 'success' | 'error',
|
|
10
|
+
options?: { action?: ToastData['action']; duration?: number },
|
|
11
|
+
) => void;
|
|
12
|
+
dismissToast: (id: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useToasts(): ToastsHandle {
|
|
5
16
|
const [toasts, setToasts] = useState<ToastData[]>([]);
|
|
6
17
|
const toastIdRef = useRef(0);
|
|
7
18
|
|