@kabmo-labs/kabmo 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/dist/client/static/js/index-pdp5dwc8.css +0 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The LEFT dock: which view fills it, and which panels may live there.
|
|
8
|
+
*
|
|
9
|
+
* The left column used to be the session sidebar and nothing else. It is now a
|
|
10
|
+
* DOCK with a view on it, exactly as the right column is — the sessions list is
|
|
11
|
+
* the built-in view, and a plugin panel can be placed beside it by the user.
|
|
12
|
+
*
|
|
13
|
+
* Three decisions are encoded here rather than at a call site:
|
|
14
|
+
*
|
|
15
|
+
* - **The dock is OPTIONAL and OFF by default.** Its activity bar is a setting,
|
|
16
|
+
* and while it is off the dock shows only its active view with no rail and no
|
|
17
|
+
* second width to reserve. That is what makes the feature free for the many
|
|
18
|
+
* users who never place a panel on the left: nothing renders, nothing is
|
|
19
|
+
* measured, and no extra panel competes with the chat for the group's budget.
|
|
20
|
+
* - **Placement is the USER's, never the plugin's.** A plugin keeps registering
|
|
21
|
+
* `app.rightPanel(...)`; where it is drawn is a per-install preference. That
|
|
22
|
+
* is what keeps ONE id (`plugin:<id>`) meaning one panel — the id the server
|
|
23
|
+
* gates the bundle with, the enablement set stores, the width map keys and the
|
|
24
|
+
* stored order ranks. A second slot name would have split all four.
|
|
25
|
+
* - **The sessions view cannot be moved.** It is the reason the left column
|
|
26
|
+
* exists, and moving it would leave a chat with no way to reach another one.
|
|
27
|
+
*
|
|
28
|
+
* Stored GLOBAL (`app_settings.omp_left_panels`), like the right panel's order:
|
|
29
|
+
* the dock lists the same panels in every session, so a per-session placement
|
|
30
|
+
* would move the user's own arrangement when they picked another chat.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readKabmoSetting, writeSetting } from '@/shared/lib/settings/client';
|
|
34
|
+
import { pluginKeyOf, pluginSlotOf, type PluginPanelSlot } from '@/shared/lib/workspace/panel-ids';
|
|
35
|
+
|
|
36
|
+
/** `app_settings` key holding the dock's own state. */
|
|
37
|
+
export const LEFT_DOCK_SETTING_KEY = 'omp_left_panels';
|
|
38
|
+
|
|
39
|
+
/** The built-in view that owns the left column unless the user places another. */
|
|
40
|
+
export const SESSIONS_VIEW_ID = 'sessions';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Where a panel is drawn.
|
|
44
|
+
*
|
|
45
|
+
* `editor` is the third destination and it is the one that makes a two-panel
|
|
46
|
+
* plugin coherent: `session-info` registers a `rightPanel` AND a `panel`, and
|
|
47
|
+
* with per-instance placement they are independent — either may sit on the left,
|
|
48
|
+
* the other may stay right or take the editor column. Placement is per PANEL, so
|
|
49
|
+
* moving one never drags its sibling along.
|
|
50
|
+
*
|
|
51
|
+
* The left column holds ONE panel, and a plugin's second panel goes to the
|
|
52
|
+
* editor column — that is what its `panel` slot is FOR. A second left pane was
|
|
53
|
+
* built and removed: it made the left column two columns' worth of width for a
|
|
54
|
+
* plugin that already had a home for its second panel, and a pane with no
|
|
55
|
+
* switcher is a surface whose occupant nothing on screen names.
|
|
56
|
+
*/
|
|
57
|
+
export type PanelSide = 'left' | 'right' | 'editor';
|
|
58
|
+
|
|
59
|
+
/** The destinations a PLUGIN panel may be placed on. */
|
|
60
|
+
export const PLUGIN_SIDES: readonly PanelSide[] = ['left', 'right', 'editor'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where a panel may go, given its SLOT.
|
|
64
|
+
*
|
|
65
|
+
* Not every slot can use every destination, and the rule is the slot's shape:
|
|
66
|
+
*
|
|
67
|
+
* - `rightPanel` is a VIEW — an activity-bar button and a body in a column — so
|
|
68
|
+
* it may sit on the left dock or on the right, never in the editor column
|
|
69
|
+
* (which a takeover owns whole).
|
|
70
|
+
* - `panel` is a TAKEOVER — it draws the editor column with no tab strip and no
|
|
71
|
+
* chrome — so it may sit there and nowhere else. A takeover on a rail would be
|
|
72
|
+
* a button that opens a column that is not the one it is on.
|
|
73
|
+
*/
|
|
74
|
+
export function ridesSides(slot: PluginPanelSlot | null): readonly PanelSide[] {
|
|
75
|
+
if (slot === 'panel') return ['editor'];
|
|
76
|
+
if (slot === 'rightPanel') return ['left', 'right'];
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The dock's stored state, validated. */
|
|
81
|
+
export interface LeftDockState {
|
|
82
|
+
/** Whether the dock's activity bar is drawn at all. OFF unless set. */
|
|
83
|
+
enabled: boolean;
|
|
84
|
+
/** Panel ids the user placed on the left dock, in bar order. */
|
|
85
|
+
panels: string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const EMPTY: LeftDockState = { enabled: false, panels: [] };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The ids that may live on the left.
|
|
92
|
+
*
|
|
93
|
+
* The sessions view is the dock's own; anything else must be a plugin panel —
|
|
94
|
+
* a bare built-in id (`git`, `files`) names a view whose width slot, floor and
|
|
95
|
+
* workspace gate were all declared for the RIGHT column, and moving it is a
|
|
96
|
+
* second feature rather than a placement.
|
|
97
|
+
*/
|
|
98
|
+
export function isLeftDockPanelId(value: unknown): value is string {
|
|
99
|
+
if (value === SESSIONS_VIEW_ID) return true;
|
|
100
|
+
return pluginKeyOf(value) !== null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The stored state, validated.
|
|
105
|
+
*
|
|
106
|
+
* A row this build does not recognise is DROPPED rather than kept: unlike the
|
|
107
|
+
* right panel's order, this list is also the enablement of the dock's buttons,
|
|
108
|
+
* so a stale id would draw a button that resolves to nothing.
|
|
109
|
+
*/
|
|
110
|
+
export function readLeftDock(): LeftDockState {
|
|
111
|
+
const stored = readKabmoSetting<unknown>(LEFT_DOCK_SETTING_KEY);
|
|
112
|
+
if (typeof stored !== 'object' || stored === null) return EMPTY;
|
|
113
|
+
const raw = stored as { enabled?: unknown; panels?: unknown };
|
|
114
|
+
const panels: string[] = [];
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
if (Array.isArray(raw.panels)) {
|
|
117
|
+
for (const value of raw.panels) {
|
|
118
|
+
if (!isLeftDockPanelId(value) || value === SESSIONS_VIEW_ID || seen.has(value)) continue;
|
|
119
|
+
seen.add(value);
|
|
120
|
+
panels.push(value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { enabled: raw.enabled === true, panels };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Persist one change to the dock, keeping the other half. */
|
|
127
|
+
export function writeLeftDock(patch: Partial<LeftDockState>): LeftDockState {
|
|
128
|
+
const current = readLeftDock();
|
|
129
|
+
const next: LeftDockState = {
|
|
130
|
+
enabled: patch.enabled ?? current.enabled,
|
|
131
|
+
panels: patch.panels
|
|
132
|
+
? [
|
|
133
|
+
...new Set(
|
|
134
|
+
patch.panels.filter(
|
|
135
|
+
(id) => isLeftDockPanelId(id) && ridesSides(pluginSlotOf(id)).includes('left'),
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
]
|
|
139
|
+
: current.panels,
|
|
140
|
+
};
|
|
141
|
+
writeSetting(LEFT_DOCK_SETTING_KEY, next);
|
|
142
|
+
return next;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Place a plugin panel on the left, or take it back to the right.
|
|
147
|
+
*
|
|
148
|
+
* **Placing a panel turns the rail ON.** The rail exists to reach the panels on
|
|
149
|
+
* it, so a successful placement with the rail still off is a move with no
|
|
150
|
+
* button, no view and nothing on screen to undo it — the settings row would be
|
|
151
|
+
* the only control, which is the state a user reads as broken. The switch is
|
|
152
|
+
* written in the SAME state as the placement, so no reader can observe one
|
|
153
|
+
* without the other.
|
|
154
|
+
*
|
|
155
|
+
* Moving a panel back does NOT turn the rail off: that switch is the user's, and
|
|
156
|
+
* the dock may still be worth showing without this panel on it.
|
|
157
|
+
*/
|
|
158
|
+
export function setPanelSide(panelId: string, side: PanelSide): LeftDockState {
|
|
159
|
+
if (!isLeftDockPanelId(panelId) || panelId === SESSIONS_VIEW_ID) return readLeftDock();
|
|
160
|
+
// A destination the slot cannot use is refused, not stored: a `panel`
|
|
161
|
+
// takeover on a rail, or a view inside the editor column, is a placement
|
|
162
|
+
// that could never be drawn.
|
|
163
|
+
if (!ridesSides(pluginSlotOf(panelId)).includes(side)) return readLeftDock();
|
|
164
|
+
const current = readLeftDock();
|
|
165
|
+
const placed = current.panels.includes(panelId);
|
|
166
|
+
if (side === 'left') {
|
|
167
|
+
if (placed && current.enabled) return current;
|
|
168
|
+
const panels = placed ? current.panels : [...current.panels, panelId];
|
|
169
|
+
return writeLeftDock({ enabled: true, panels });
|
|
170
|
+
}
|
|
171
|
+
if (!placed) return current;
|
|
172
|
+
return writeLeftDock({ panels: current.panels.filter((id) => id !== panelId) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Which side a panel is drawn on.
|
|
177
|
+
*
|
|
178
|
+
* The sessions view is left by construction; a plugin panel is left only while
|
|
179
|
+
* the user has placed it there; a built-in view is always right.
|
|
180
|
+
*/
|
|
181
|
+
export function panelSideOf(panelId: string, dock: LeftDockState = readLeftDock()): PanelSide {
|
|
182
|
+
if (panelId === SESSIONS_VIEW_ID) return 'left';
|
|
183
|
+
const key = pluginKeyOf(panelId) ?? panelId;
|
|
184
|
+
if (dock.panels.includes(key)) return 'left';
|
|
185
|
+
// A takeover's home is the editor column: that is the only place its slot can
|
|
186
|
+
// be drawn, so it is where it is when the user has not moved it.
|
|
187
|
+
if (pluginSlotOf(panelId) === 'panel') return 'editor';
|
|
188
|
+
return 'right';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The left column's views in bar order: the dock's own view, then every placed
|
|
193
|
+
* plugin in the order they were placed.
|
|
194
|
+
*/
|
|
195
|
+
export function leftPanelIds(dock: LeftDockState = readLeftDock()): string[] {
|
|
196
|
+
return [SESSIONS_VIEW_ID, ...dock.panels];
|
|
197
|
+
}
|
|
@@ -13,24 +13,48 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { test, expect, describe } from 'bun:test';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
isPanelId,
|
|
18
|
+
migrateLegacyPanelId,
|
|
19
|
+
pluginEnablementKey,
|
|
20
|
+
pluginIdOf,
|
|
21
|
+
pluginKeyOf,
|
|
22
|
+
pluginPanelKey,
|
|
23
|
+
pluginSlotOf,
|
|
24
|
+
} from '@/shared/lib/workspace/panel-ids';
|
|
17
25
|
|
|
18
26
|
describe('pluginPanelKey', () => {
|
|
19
|
-
test('
|
|
20
|
-
expect(pluginPanelKey('hello')).toBe('plugin:hello');
|
|
27
|
+
test('names ONE panel: the plugin id AND the slot', () => {
|
|
28
|
+
expect(pluginPanelKey('hello', 'rightPanel')).toBe('plugin:hello:rightPanel');
|
|
29
|
+
expect(pluginPanelKey('hello', 'panel')).toBe('plugin:hello:panel');
|
|
30
|
+
// Two panels of one plugin are two keys, which is what lets a placement and
|
|
31
|
+
// a width follow one of them without dragging the other along.
|
|
32
|
+
expect(pluginPanelKey('hello', 'panel')).not.toBe(pluginPanelKey('hello', 'rightPanel'));
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('pluginEnablementKey', () => {
|
|
37
|
+
test('names the PLUGIN, whatever it contributes', () => {
|
|
38
|
+
// Enablement is per plugin: the switch says "Disable <plugin>" and the
|
|
39
|
+
// server gates that plugin's bundle on this key, so one switch is one id.
|
|
40
|
+
expect(pluginEnablementKey('hello')).toBe('plugin:hello');
|
|
21
41
|
});
|
|
22
42
|
});
|
|
23
43
|
|
|
24
44
|
describe('pluginKeyOf', () => {
|
|
25
|
-
test('accepts
|
|
26
|
-
expect(pluginKeyOf('plugin:hello')).toBe('plugin:hello');
|
|
45
|
+
test('accepts both forms, normalizing the retired one to rightPanel', () => {
|
|
46
|
+
expect(pluginKeyOf('plugin:hello:rightPanel')).toBe('plugin:hello:rightPanel');
|
|
47
|
+
expect(pluginKeyOf('plugin:hello:panel')).toBe('plugin:hello:panel');
|
|
48
|
+
// The form that shipped, before the slot segment: the only reachable slot
|
|
49
|
+
// then was the right panel, so that is where such a row still points.
|
|
50
|
+
expect(pluginKeyOf('plugin:hello')).toBe('plugin:hello:rightPanel');
|
|
27
51
|
});
|
|
28
52
|
|
|
29
|
-
test('rejects a bare prefix and an
|
|
53
|
+
test('rejects a bare prefix, a slash, a third segment and an unknown slot', () => {
|
|
30
54
|
expect(pluginKeyOf('plugin:')).toBeNull();
|
|
31
|
-
// The id used to carry a panel segment; a blob written then must not be
|
|
32
|
-
// adopted as a plugin id that no registry can report.
|
|
33
55
|
expect(pluginKeyOf('plugin:hello/greeter')).toBeNull();
|
|
56
|
+
expect(pluginKeyOf('plugin:a:b:c')).toBeNull();
|
|
57
|
+
expect(pluginKeyOf('plugin:hello:headerPanel')).toBeNull();
|
|
34
58
|
});
|
|
35
59
|
|
|
36
60
|
test('rejects a built-in view id and a non-string', () => {
|
|
@@ -40,9 +64,33 @@ describe('pluginKeyOf', () => {
|
|
|
40
64
|
});
|
|
41
65
|
});
|
|
42
66
|
|
|
67
|
+
describe('migrateLegacyPanelId', () => {
|
|
68
|
+
test('reports the retired form and what it becomes', () => {
|
|
69
|
+
expect(migrateLegacyPanelId('plugin:hello')).toEqual({
|
|
70
|
+
from: 'plugin:hello',
|
|
71
|
+
to: 'plugin:hello:rightPanel',
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('says nothing for a value that is already current or not a panel id', () => {
|
|
76
|
+
expect(migrateLegacyPanelId('plugin:hello:panel')).toBeNull();
|
|
77
|
+
expect(migrateLegacyPanelId('files')).toBeNull();
|
|
78
|
+
expect(migrateLegacyPanelId(null)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('pluginSlotOf', () => {
|
|
83
|
+
test('names the slot, so a placement can be per panel', () => {
|
|
84
|
+
expect(pluginSlotOf('plugin:hello:panel')).toBe('panel');
|
|
85
|
+
expect(pluginSlotOf('plugin:hello')).toBe('rightPanel');
|
|
86
|
+
expect(pluginSlotOf('files')).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
43
90
|
describe('pluginIdOf', () => {
|
|
44
|
-
test('unwraps the plugin id', () => {
|
|
91
|
+
test('unwraps the plugin id, from either form', () => {
|
|
45
92
|
expect(pluginIdOf('plugin:hello')).toBe('hello');
|
|
93
|
+
expect(pluginIdOf('plugin:hello:panel')).toBe('hello');
|
|
46
94
|
expect(pluginIdOf('files')).toBeNull();
|
|
47
95
|
});
|
|
48
96
|
});
|
|
@@ -54,8 +102,9 @@ describe('isPanelId', () => {
|
|
|
54
102
|
expect(isPanelId('plan')).toBe(true);
|
|
55
103
|
});
|
|
56
104
|
|
|
57
|
-
test('accepts a plugin key', () => {
|
|
105
|
+
test('accepts a plugin panel key, in both forms', () => {
|
|
58
106
|
expect(isPanelId('plugin:hello')).toBe(true);
|
|
107
|
+
expect(isPanelId('plugin:hello:panel')).toBe(true);
|
|
59
108
|
});
|
|
60
109
|
|
|
61
110
|
test('rejects an unknown view and a malformed plugin key', () => {
|
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Panel ids: the built-in views plus the panels plugin code contributes.
|
|
4
8
|
*
|
|
5
|
-
* A plugin panel's id is `plugin:<pluginId>` — one string the layout
|
|
6
|
-
* keys widths by, and resolves a component from.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* A plugin panel's id is `plugin:<pluginId>:<slot>` — one string the layout
|
|
10
|
+
* stores, keys widths by, and resolves a component from. One plugin contributes
|
|
11
|
+
* at most one panel PER SLOT (`rightPanel`, `panel`), and a plugin may register
|
|
12
|
+
* both: `marketplace/plugins/session-info` ships a `rightPanel` and a `panel`.
|
|
13
|
+
* The slot segment is what keeps those two apart.
|
|
9
14
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* It was not always. The id used to carry a panel segment, then dropped it on the
|
|
16
|
+
* claim that a plugin contributes at most one panel — true per slot, false per
|
|
17
|
+
* plugin. With the editor column's slot reachable again, one id for both would
|
|
18
|
+
* make them one entry in the enablement set, one width and one placement, so
|
|
19
|
+
* moving a plugin's right panel would move its editor panel with it.
|
|
20
|
+
*
|
|
21
|
+
* The BARE form is the one that shipped and is still accepted, resolving to the
|
|
22
|
+
* `rightPanel` slot — see {@link migrateLegacyPanelId}. Writers emit the
|
|
23
|
+
* segmented form; readers accept both.
|
|
13
24
|
*/
|
|
14
25
|
|
|
15
26
|
import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
|
|
@@ -17,43 +28,120 @@ import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/r
|
|
|
17
28
|
/** Prefix every plugin-contributed panel id carries. */
|
|
18
29
|
export const PLUGIN_PANEL_PREFIX = 'plugin:';
|
|
19
30
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The slots a plugin can contribute a panel to.
|
|
33
|
+
*
|
|
34
|
+
* NOT `headerPanel`/`settingsSection`: those are not panels — one is a navbar
|
|
35
|
+
* entry, the other a settings block — and neither keys a width or a placement.
|
|
36
|
+
*/
|
|
37
|
+
export const PLUGIN_PANEL_SLOTS = ['rightPanel', 'panel'] as const;
|
|
38
|
+
|
|
39
|
+
export type PluginPanelSlot = (typeof PLUGIN_PANEL_SLOTS)[number];
|
|
40
|
+
|
|
41
|
+
/** The slot a bare `plugin:<id>` names: the form that shipped. */
|
|
42
|
+
/** The slot a bare `plugin:<id>` names, where an INSTANCE key is expected. */
|
|
43
|
+
export const LEGACY_PLUGIN_SLOT: PluginPanelSlot = 'rightPanel';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `plugin:<pluginId>:<slot>` — ONE PANEL of a plugin.
|
|
47
|
+
*
|
|
48
|
+
* The key every panel-shaped store uses: the width map, the dock placement, the
|
|
49
|
+
* stored order and the active selection. Two panels of one plugin are two panels
|
|
50
|
+
* here, which is the whole point.
|
|
51
|
+
*/
|
|
52
|
+
export function pluginPanelKey(pluginId: string, slot: PluginPanelSlot): string {
|
|
53
|
+
return `${PLUGIN_PANEL_PREFIX}${pluginId}:${slot}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `plugin:<pluginId>` — the PLUGIN, not a panel.
|
|
58
|
+
*
|
|
59
|
+
* Separate from {@link pluginPanelKey} on purpose. Enablement is per PLUGIN: the
|
|
60
|
+
* Settings switch says "Disable <plugin>" and the server gates that plugin's
|
|
61
|
+
* BUNDLE on this key, so one switch has to be one id no matter how many panels
|
|
62
|
+
* the plugin contributes. Making enablement per panel would leave a plugin whose
|
|
63
|
+
* bundle is still served while half its panels are "off".
|
|
64
|
+
*
|
|
65
|
+
* This is also the form that shipped, so every stored disabled row still names a
|
|
66
|
+
* plugin and needs no migration.
|
|
67
|
+
*/
|
|
68
|
+
export function pluginEnablementKey(pluginId: string): string {
|
|
22
69
|
return `${PLUGIN_PANEL_PREFIX}${pluginId}`;
|
|
23
70
|
}
|
|
24
71
|
|
|
72
|
+
function isSlot(value: string): value is PluginPanelSlot {
|
|
73
|
+
return (PLUGIN_PANEL_SLOTS as readonly string[]).includes(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
25
76
|
/**
|
|
26
|
-
* The
|
|
77
|
+
* The parts of a plugin panel id, or null.
|
|
78
|
+
*
|
|
79
|
+
* Two shapes are accepted, and the second is DELIBERATELY the one that shipped:
|
|
27
80
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
81
|
+
* - `plugin:<id>:<slot>` — the current form;
|
|
82
|
+
* - `plugin:<id>` — a stored blob, an enablement row or an order entry written
|
|
83
|
+
* before the slot segment existed. It resolves to `rightPanel`, because that
|
|
84
|
+
* was the only reachable slot when it was written.
|
|
85
|
+
*
|
|
86
|
+
* The plugin id is validated as ONE segment: an empty id, a slash or a third
|
|
87
|
+
* colon is a truncated write or a hand-edited blob, and accepting it would key a
|
|
88
|
+
* width map and a component lookup on an id no registry can report.
|
|
31
89
|
*/
|
|
32
|
-
export function
|
|
90
|
+
export function pluginPanelParts(value: unknown): { pluginId: string; slot: PluginPanelSlot } | null {
|
|
33
91
|
if (typeof value !== 'string' || !value.startsWith(PLUGIN_PANEL_PREFIX)) return null;
|
|
34
92
|
const rest = value.slice(PLUGIN_PANEL_PREFIX.length);
|
|
35
|
-
|
|
36
|
-
return
|
|
93
|
+
const pieces = rest.split(':');
|
|
94
|
+
if (pieces.length > 2) return null;
|
|
95
|
+
const [pluginId, slot] = pieces;
|
|
96
|
+
if (!pluginId || pluginId.includes('/')) return null;
|
|
97
|
+
if (slot === undefined) return { pluginId, slot: LEGACY_PLUGIN_SLOT };
|
|
98
|
+
if (!isSlot(slot)) return null;
|
|
99
|
+
return { pluginId, slot };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The PANEL INSTANCE key a stored value names, or null.
|
|
104
|
+
*
|
|
105
|
+
* A bare `plugin:<id>` normalizes to the `rightPanel` instance, because that was
|
|
106
|
+
* the only reachable slot when such a row was written — the width map, the dock
|
|
107
|
+
* placement and the stored order all read through here, so a row from an older
|
|
108
|
+
* build keeps pointing at the panel it pointed at before.
|
|
109
|
+
*/
|
|
110
|
+
export function pluginKeyOf(value: unknown): string | null {
|
|
111
|
+
const parts = pluginPanelParts(value);
|
|
112
|
+
return parts ? pluginPanelKey(parts.pluginId, parts.slot) : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether a stored value is the retired bare form, and what it resolves to. */
|
|
116
|
+
export function migrateLegacyPanelId(value: unknown): { from: string; to: string } | null {
|
|
117
|
+
const parts = pluginPanelParts(value);
|
|
118
|
+
if (!parts || typeof value !== 'string') return null;
|
|
119
|
+
const to = pluginPanelKey(parts.pluginId, parts.slot);
|
|
120
|
+
return value === to ? null : { from: value, to };
|
|
37
121
|
}
|
|
38
122
|
|
|
39
123
|
/** The plugin id inside a panel id, or null. */
|
|
40
124
|
export function pluginIdOf(value: unknown): string | null {
|
|
41
|
-
|
|
42
|
-
|
|
125
|
+
return pluginPanelParts(value)?.pluginId ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The slot a panel id names, or null when `value` is not a plugin panel id. */
|
|
129
|
+
export function pluginSlotOf(value: unknown): PluginPanelSlot | null {
|
|
130
|
+
return pluginPanelParts(value)?.slot ?? null;
|
|
43
131
|
}
|
|
44
132
|
|
|
45
133
|
/**
|
|
46
|
-
* Whether a stored `layout.activeRightPanel` may be adopted on load.
|
|
134
|
+
* Whether a stored `layout.activeRightPanel` / width key may be adopted on load.
|
|
47
135
|
*
|
|
48
136
|
* A plugin panel is accepted on the shape of its id alone: the registry is a
|
|
49
137
|
* network read and the slots exist only after a bundle has been imported, so
|
|
50
|
-
* requiring the panel to be present would drop a perfectly valid selection
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
138
|
+
* requiring the panel to be present would drop a perfectly valid selection every
|
|
139
|
+
* time the server is slower than the first paint. A panel whose plugin has since
|
|
140
|
+
* been uninstalled renders its own "not installed" notice rather than falling
|
|
141
|
+
* back silently to Files.
|
|
54
142
|
*/
|
|
55
143
|
export function isPanelId(value: unknown): value is string {
|
|
56
|
-
return (RIGHT_PANEL_TYPES as readonly string[]).includes(value as string) ||
|
|
144
|
+
return (RIGHT_PANEL_TYPES as readonly string[]).includes(value as string) || pluginPanelParts(value) !== null;
|
|
57
145
|
}
|
|
58
146
|
|
|
59
147
|
export type { RightPanelType };
|
|
@@ -43,6 +43,10 @@ export const DEFAULT_LEFT_PANEL_WIDTH = 280;
|
|
|
43
43
|
* `MIN_CHAT_PANEL_WIDTH` in `panel-widths.ts`: the sidebar cannot open past 500
|
|
44
44
|
* and still leave the editor (320) and a browser view (320) room on a 1440px
|
|
45
45
|
* display.
|
|
46
|
+
*
|
|
47
|
+
* It was raised to 720 for a second left pane and put back when that pane was
|
|
48
|
+
* removed: the dock holds ONE panel beside the list, and a wider ceiling would
|
|
49
|
+
* only invite a column nothing else in the layout budgets for.
|
|
46
50
|
*/
|
|
47
51
|
export const MIN_LEFT_PANEL_WIDTH = 264;
|
|
48
52
|
export const MAX_LEFT_PANEL_WIDTH = 500;
|
|
@@ -4,6 +4,7 @@ export * from '@/shared/types/fs';
|
|
|
4
4
|
export * from '@/shared/types/git';
|
|
5
5
|
export * from '@/shared/types/chat';
|
|
6
6
|
export * from '@/shared/types/schedule';
|
|
7
|
+
export * from '@/shared/types/waiting';
|
|
7
8
|
export * from '@/shared/types/btw';
|
|
8
9
|
export * from '@/shared/types/models';
|
|
9
10
|
export * from '@/shared/types/terminal';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A prompt held until another session finishes.
|
|
8
|
+
*
|
|
9
|
+
* The row is Kabmo's own (SQLite `waiting_prompts`): omp has no notion of a
|
|
10
|
+
* prompt that has not been sent yet, so the wait has to live somewhere that
|
|
11
|
+
* outlives the tab. Its `dependsOn` session is a SESSION ID, never a snapshot of
|
|
12
|
+
* its state — the state is re-read at each sweep, because a session that looks
|
|
13
|
+
* finished can pick up a queued follow-up a moment later.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Attachment, QueuedMessageModel } from '@/shared/types/chat';
|
|
17
|
+
|
|
18
|
+
export type WaitingPromptStatus = 'waiting' | 'dispatched' | 'cancelled' | 'error';
|
|
19
|
+
|
|
20
|
+
/** Plan/goal flags the spawn must carry (a new session has no JSONL to read). */
|
|
21
|
+
export interface WaitingPromptModes {
|
|
22
|
+
plan: boolean;
|
|
23
|
+
goal: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WaitingPrompt {
|
|
27
|
+
id: string;
|
|
28
|
+
/**
|
|
29
|
+
* The client's pending id (`new-…`) this prompt belongs to. The dispatch
|
|
30
|
+
* answers with omp's real session id, and the client moves the sidebar row
|
|
31
|
+
* across by this pairing — the same adoption the local send path performs.
|
|
32
|
+
*/
|
|
33
|
+
pendingId: string | null;
|
|
34
|
+
/** The session whose completion releases this prompt. */
|
|
35
|
+
dependsOn: string;
|
|
36
|
+
/** Title of `dependsOn` at creation time, for the waiting row's own label. */
|
|
37
|
+
dependsOnTitle: string | null;
|
|
38
|
+
/** Composer text as the user typed it. */
|
|
39
|
+
prompt: string;
|
|
40
|
+
attachments: Attachment[];
|
|
41
|
+
/** Model/thinking/access snapshot, replayed at dispatch. */
|
|
42
|
+
model: QueuedMessageModel | null;
|
|
43
|
+
modes: WaitingPromptModes | null;
|
|
44
|
+
/** Absolute directory the new session spawns in, resolved at creation. */
|
|
45
|
+
cwd: string;
|
|
46
|
+
folderId: number | null;
|
|
47
|
+
status: WaitingPromptStatus;
|
|
48
|
+
/** omp's real session id, once the prompt was dispatched. */
|
|
49
|
+
sessionId: string | null;
|
|
50
|
+
/** Why the dispatch failed (status `error`). */
|
|
51
|
+
error: string | null;
|
|
52
|
+
createdAt: number;
|
|
53
|
+
updatedAt: number;
|
|
54
|
+
}
|
|
@@ -30,6 +30,14 @@ export interface SessionItemData {
|
|
|
30
30
|
* provider/model in every tab and Kabmo instances. Absent for a session
|
|
31
31
|
* with no run row; callers fall back to the transcript's model entry. */
|
|
32
32
|
runModel?: { provider: string; modelId: string };
|
|
33
|
+
/**
|
|
34
|
+
* Set on a PENDING row whose prompt is held until another session finishes.
|
|
35
|
+
*
|
|
36
|
+
* The row has no transcript and no omp id yet — it exists because the user
|
|
37
|
+
* sent a prompt that is deferred, and the cue is the only thing on screen
|
|
38
|
+
* saying so. Absent means the row is not waiting (the normal case).
|
|
39
|
+
*/
|
|
40
|
+
waitingFor?: { waitId: string; sessionId: string; title: string | null };
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/**
|
|
@@ -23,14 +23,14 @@ import { mkdtempSync } from 'node:fs';
|
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
|
|
26
|
+
import { closeDb } from '@/server/db.server';
|
|
26
27
|
import { removeDir } from '@/test-support/remove-dir';
|
|
27
28
|
|
|
28
29
|
let dir: string | null = null;
|
|
29
30
|
|
|
30
31
|
/** Point the process at a fresh database. Call from `beforeEach`. */
|
|
31
32
|
export function isolateDb(): string {
|
|
32
|
-
|
|
33
|
-
globalThis.__kabmoDb = undefined;
|
|
33
|
+
closeDb();
|
|
34
34
|
|
|
35
35
|
dir = mkdtempSync(join(tmpdir(), 'omc-db-'));
|
|
36
36
|
const path = join(dir, 'db.sqlite');
|
|
@@ -40,8 +40,7 @@ export function isolateDb(): string {
|
|
|
40
40
|
|
|
41
41
|
/** Close the handle and drop the temp directory. Call from `afterEach`. */
|
|
42
42
|
export function releaseDb(): void {
|
|
43
|
-
|
|
44
|
-
globalThis.__kabmoDb = undefined;
|
|
43
|
+
closeDb();
|
|
45
44
|
delete Bun.env.KABMO_DB_PATH;
|
|
46
45
|
// `removeDir`, not a bare `rmSync`: a closed SQLite handle is released by the
|
|
47
46
|
// OS asynchronously on Windows, so the delete races it and fails with EBUSY —
|