@kabmo-labs/kabmo 0.3.0 → 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-8x5j8zrr.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/git-update.server.test.ts +18 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The left dock's state, as the desktop layout and its bar read it.
|
|
8
|
+
*
|
|
9
|
+
* Backed by the GLOBAL `app_settings.omp_left_panels` (see
|
|
10
|
+
* `shared/lib/workspace/left-dock.ts`), not by `layout.*` session state: the
|
|
11
|
+
* dock lists the same views in every chat, so a placement stored per session
|
|
12
|
+
* would move the user's arrangement when they picked another session.
|
|
13
|
+
*
|
|
14
|
+
* The read is synchronous from the primed settings snapshot, so the first render
|
|
15
|
+
* already knows whether the bar is drawn. A write notifies every reader on
|
|
16
|
+
* screen, because they all resolve against this one module-level cache.
|
|
17
|
+
*
|
|
18
|
+
* The snapshot is compared by IDENTITY of the stored value: `writeSetting`
|
|
19
|
+
* replaces it with the exact object a write handed it, so a reference comparison
|
|
20
|
+
* is both correct and free — a rebuilt object would make
|
|
21
|
+
* `useSyncExternalStore` see a change on every render and loop.
|
|
22
|
+
*/
|
|
23
|
+
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
24
|
+
import { useSyncExternalStore } from 'preact/compat';
|
|
25
|
+
import { readKabmoSetting } from '@/shared/lib/settings/client';
|
|
26
|
+
import {
|
|
27
|
+
LEFT_DOCK_SETTING_KEY,
|
|
28
|
+
readLeftDock,
|
|
29
|
+
setPanelSide as persistPanelSide,
|
|
30
|
+
writeLeftDock,
|
|
31
|
+
type LeftDockState,
|
|
32
|
+
type PanelSide,
|
|
33
|
+
} from '@/shared/lib/workspace/left-dock';
|
|
34
|
+
import { resolveLeftDockWidth, saveLeftDockWidth } from '@/shared/lib/workspace/left-dock-width';
|
|
35
|
+
|
|
36
|
+
const listeners = new Set<() => void>();
|
|
37
|
+
|
|
38
|
+
/** The stored value the cache was built from, and the state it produced. */
|
|
39
|
+
let lastSource: unknown;
|
|
40
|
+
let cached: LeftDockState = { enabled: false, panels: [] };
|
|
41
|
+
|
|
42
|
+
function subscribe(listener: () => void): () => void {
|
|
43
|
+
listeners.add(listener);
|
|
44
|
+
return () => listeners.delete(listener);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function snapshot(): LeftDockState {
|
|
48
|
+
const source = readKabmoSetting<unknown>(LEFT_DOCK_SETTING_KEY);
|
|
49
|
+
if (source === lastSource) return cached;
|
|
50
|
+
lastSource = source;
|
|
51
|
+
cached = readLeftDock();
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function publish(): void {
|
|
56
|
+
lastSource = undefined;
|
|
57
|
+
for (const listener of listeners) listener();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LeftDockHandle extends LeftDockState {
|
|
61
|
+
/** Draw the dock's activity bar. Off by default. */
|
|
62
|
+
setEnabled: (enabled: boolean) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Place a plugin panel on the left, or send it back to the right.
|
|
65
|
+
*
|
|
66
|
+
* Placing a panel TURNS THE RAIL ON. The rail exists to reach the panels on
|
|
67
|
+
* it, so leaving it off after a successful placement would be a move with no
|
|
68
|
+
* button, no view and no way to undo it from the bar — the settings row would
|
|
69
|
+
* be the only control, which is exactly the state a user reads as broken.
|
|
70
|
+
* Moving a panel back to the right does NOT turn the rail off: that is the
|
|
71
|
+
* user's switch, and the dock may still be worth showing.
|
|
72
|
+
*/
|
|
73
|
+
setSide: (panelId: string, side: PanelSide) => void;
|
|
74
|
+
/** The width this view opens at, and the writer a drag commits through. */
|
|
75
|
+
widthOf: (viewId: string) => number;
|
|
76
|
+
commitWidth: (viewId: string, px: number) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useLeftDock(): LeftDockHandle {
|
|
80
|
+
const state = useSyncExternalStore(subscribe, snapshot);
|
|
81
|
+
|
|
82
|
+
const setEnabled = useCallback((enabled: boolean) => {
|
|
83
|
+
writeLeftDock({ enabled });
|
|
84
|
+
publish();
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const setSide = useCallback((panelId: string, side: PanelSide) => {
|
|
88
|
+
// The coupling — placing a panel also shows the rail — lives in the store,
|
|
89
|
+
// so a second caller cannot produce a placement nobody can reach.
|
|
90
|
+
persistPanelSide(panelId, side);
|
|
91
|
+
publish();
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const commitWidth = useCallback((viewId: string, px: number) => {
|
|
95
|
+
saveLeftDockWidth(viewId, px);
|
|
96
|
+
publish();
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
...state,
|
|
101
|
+
setEnabled,
|
|
102
|
+
setSide,
|
|
103
|
+
widthOf: resolveLeftDockWidth,
|
|
104
|
+
commitWidth,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The dock's width for ONE view.
|
|
110
|
+
*
|
|
111
|
+
* Per view because a session list reads at 280px and a plugin's table does not:
|
|
112
|
+
* a column that snapped to the list's width the moment the user switched views
|
|
113
|
+
* would be a column with one width that is wrong for every second view.
|
|
114
|
+
*/
|
|
115
|
+
export function useLeftDockWidth(viewId: string): [number, (px: number) => void] {
|
|
116
|
+
const dock = useLeftDock();
|
|
117
|
+
const [width, setWidth] = useState(() => dock.widthOf(viewId));
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
setWidth(dock.widthOf(viewId));
|
|
121
|
+
}, [viewId, dock]);
|
|
122
|
+
|
|
123
|
+
const commitWidth = useCallback(
|
|
124
|
+
(px: number) => {
|
|
125
|
+
dock.commitWidth(viewId, px);
|
|
126
|
+
setWidth(dock.widthOf(viewId));
|
|
127
|
+
},
|
|
128
|
+
[dock, viewId],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return [width, commitWidth];
|
|
132
|
+
}
|
|
@@ -123,6 +123,19 @@ export function seedPanelRegistry(payload: PanelRegistryPayload): void {
|
|
|
123
123
|
adoptPanelRegistry(payload);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Forget the adopted payload — a test seam, and nothing else.
|
|
128
|
+
*
|
|
129
|
+
* The store is anchored on `globalThis` (a `bun --hot` reload must not lose it),
|
|
130
|
+
* which is also why it survives between test files in one `bun test` process: a
|
|
131
|
+
* suite that adopts a payload with a plugin in it leaves every later suite
|
|
132
|
+
* drawing that plugin's button. A file that adopts one has to put the store
|
|
133
|
+
* back.
|
|
134
|
+
*/
|
|
135
|
+
export function resetPanelRegistry(): void {
|
|
136
|
+
globalThis.__kabmoPanelRegistry = undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
/**
|
|
127
140
|
* Re-read the marketplace from disk, bypassing the server's scan cache.
|
|
128
141
|
*
|
package/src/client/tailwind.css
CHANGED
|
@@ -182,6 +182,28 @@
|
|
|
182
182
|
overflow-y: auto;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/* The composer reads at the transcript's own size — 13px — EXCEPT on iOS.
|
|
186
|
+
*
|
|
187
|
+
* Every message row in the timeline is `text-[13px] leading-relaxed`, and a
|
|
188
|
+
* draft written at 16px looked like a different document pasted under the
|
|
189
|
+
* conversation. The size itself is a utility on the textarea; this rule exists
|
|
190
|
+
* for the one platform where 13px is not allowed to apply.
|
|
191
|
+
*
|
|
192
|
+
* iOS Safari ZOOMS the viewport when a text control below 16px takes focus,
|
|
193
|
+
* and it does not zoom back out — the page is left wider than the screen with
|
|
194
|
+
* the composer half off it. So iOS keeps 16px and every other platform reads
|
|
195
|
+
* at the transcript's size. `-webkit-touch-callout` is the standard
|
|
196
|
+
* iOS-only probe (it is a Safari-only property), and it cannot be expressed as
|
|
197
|
+
* a Tailwind variant, which is why this is a class rather than a utility.
|
|
198
|
+
*
|
|
199
|
+
* The PLACEHOLDER needs no rule of its own: `::placeholder` inherits
|
|
200
|
+
* `font-size` from its textarea, so both move together. */
|
|
201
|
+
@supports (-webkit-touch-callout: none) {
|
|
202
|
+
textarea.composer-type {
|
|
203
|
+
font-size: 16px;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
185
207
|
/* Reveal-on-hover controls are written `opacity-0 group-hover:opacity-100`
|
|
186
208
|
* (or `hidden group-hover:flex`), and Tailwind v4 emits every one of those
|
|
187
209
|
* variants inside `@media (hover: hover)` — verified in the emitted
|
|
@@ -33,7 +33,7 @@ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
|
33
33
|
import { homedir, tmpdir } from 'node:os';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
35
|
|
|
36
|
-
import { getDatabasePath, getDb } from '@/server/db.server';
|
|
36
|
+
import { closeDb, getDatabasePath, getDb } from '@/server/db.server';
|
|
37
37
|
import { removeDir } from '@/test-support/remove-dir';
|
|
38
38
|
|
|
39
39
|
const saved = {
|
|
@@ -151,3 +151,54 @@ describe('a failed open is not cached', () => {
|
|
|
151
151
|
}
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
describe('closeDb', () => {
|
|
156
|
+
test('retires an open that is still in flight, and closes its handle', async () => {
|
|
157
|
+
// The bug this pins, measured on the Release gate: `releaseDb` cleared the
|
|
158
|
+
// slot while `getDb()` was still opening, so the continuation assigned
|
|
159
|
+
// `resolved` on the DROPPED slot object. Nothing could reach that handle
|
|
160
|
+
// again, and on Windows its temp directory stayed locked — `EBUSY … a
|
|
161
|
+
// handle outlived its test`, reported against the two PRECEDING cases.
|
|
162
|
+
const savedSlot = globalThis.__kabmoDb;
|
|
163
|
+
try {
|
|
164
|
+
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'in-flight', 'db.sqlite');
|
|
165
|
+
globalThis.__kabmoDb = undefined;
|
|
166
|
+
|
|
167
|
+
// Start the open and retire the slot in the SAME tick, so the async
|
|
168
|
+
// continuation is still pending when `closeDb` runs.
|
|
169
|
+
const pending = getDb();
|
|
170
|
+
const retired = globalThis.__kabmoDb as { disposed?: boolean } | undefined;
|
|
171
|
+
closeDb();
|
|
172
|
+
expect(retired?.disposed).toBe(true);
|
|
173
|
+
|
|
174
|
+
// The open settles as a refusal rather than handing back a handle whose
|
|
175
|
+
// owner is gone, and the slot stays clear for the next caller.
|
|
176
|
+
await expect(pending).rejects.toThrow(/retired/);
|
|
177
|
+
expect(globalThis.__kabmoDb ?? null).toBeNull();
|
|
178
|
+
|
|
179
|
+
// A later open under a NEW path works, which is what a test isolating
|
|
180
|
+
// itself between cases needs.
|
|
181
|
+
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'after', 'db.sqlite');
|
|
182
|
+
const db = await getDb();
|
|
183
|
+
expect(db).toBeDefined();
|
|
184
|
+
closeDb();
|
|
185
|
+
} finally {
|
|
186
|
+
globalThis.__kabmoDb = savedSlot;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('closes a resolved handle and clears the slot', async () => {
|
|
191
|
+
const savedSlot = globalThis.__kabmoDb;
|
|
192
|
+
try {
|
|
193
|
+
Bun.env.KABMO_DB_PATH = path.join(tempRoot(), 'resolved', 'db.sqlite');
|
|
194
|
+
globalThis.__kabmoDb = undefined;
|
|
195
|
+
const db = await getDb();
|
|
196
|
+
closeDb();
|
|
197
|
+
expect(globalThis.__kabmoDb ?? null).toBeNull();
|
|
198
|
+
// A closed handle refuses further work rather than silently succeeding.
|
|
199
|
+
expect(() => db.raw.exec('SELECT 1')).toThrow();
|
|
200
|
+
} finally {
|
|
201
|
+
globalThis.__kabmoDb = savedSlot;
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|