@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabmo-labs/kabmo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -35,6 +35,12 @@ export interface BatchActionBarProps {
|
|
|
35
35
|
onUnselectAll: () => void;
|
|
36
36
|
onDelete: () => void;
|
|
37
37
|
onCancel: () => void;
|
|
38
|
+
/**
|
|
39
|
+
* The bar sits in the phone drawer, whose list and toolbar are both `p-3`.
|
|
40
|
+
* The desktop sidebar's list is `p-2`, so its bar keeps the tighter inset —
|
|
41
|
+
* one shared value would leave the phone's bar 2px inside the rows it counts.
|
|
42
|
+
*/
|
|
43
|
+
drawer?: boolean;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export function BatchActionBar({
|
|
@@ -47,6 +53,7 @@ export function BatchActionBar({
|
|
|
47
53
|
onUnselectAll,
|
|
48
54
|
onDelete,
|
|
49
55
|
onCancel,
|
|
56
|
+
drawer = false,
|
|
50
57
|
}: BatchActionBarProps) {
|
|
51
58
|
const noun = mode === 'workspace' ? 'workspace' : 'session';
|
|
52
59
|
const plural = count === 1 ? noun : `${noun}s`;
|
|
@@ -55,7 +62,7 @@ export function BatchActionBar({
|
|
|
55
62
|
return (
|
|
56
63
|
<div
|
|
57
64
|
data-testid="batch-action-bar"
|
|
58
|
-
className=
|
|
65
|
+
className={`border-t border-ink/15 bg-paper ${drawer ? 'px-3' : 'px-2.5'} py-2 flex flex-col gap-1.5 flex-shrink-0`}
|
|
59
66
|
>
|
|
60
67
|
<div className="flex items-center justify-between gap-2 text-[11px]">
|
|
61
68
|
<span className="min-w-0 truncate text-ink/70">
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
29
29
|
import type { TargetedMouseEvent } from 'preact';
|
|
30
30
|
import { createPortal } from 'preact/compat';
|
|
31
|
-
import { Archive, ArchiveRestore, Pencil, Trash2, WandSparkles } from 'lucide-preact';
|
|
31
|
+
import { Archive, ArchiveRestore, Hourglass, Pencil, Trash2, WandSparkles } from 'lucide-preact';
|
|
32
32
|
|
|
33
33
|
/** Viewport coordinates the menu hangs off, plus which edge it aligns to. */
|
|
34
34
|
export interface MenuAnchor {
|
|
@@ -90,6 +90,13 @@ export interface SessionActionsMenuProps {
|
|
|
90
90
|
onRenameWithAi?: () => void;
|
|
91
91
|
onArchive?: () => void;
|
|
92
92
|
onDelete?: () => void;
|
|
93
|
+
/**
|
|
94
|
+
* Drop this row's held prompt (a `new-…` chat whose prompt is waiting on
|
|
95
|
+
* another session). Omitted for every other row: a wait is the only state a
|
|
96
|
+
* pending chat can cancel from here, and offering the item elsewhere would
|
|
97
|
+
* promise an action with nothing behind it.
|
|
98
|
+
*/
|
|
99
|
+
onCancelWait?: () => void;
|
|
93
100
|
onClose: () => void;
|
|
94
101
|
}
|
|
95
102
|
|
|
@@ -100,6 +107,7 @@ export function SessionActionsMenu({
|
|
|
100
107
|
onRenameWithAi,
|
|
101
108
|
onArchive,
|
|
102
109
|
onDelete,
|
|
110
|
+
onCancelWait,
|
|
103
111
|
onClose,
|
|
104
112
|
}: SessionActionsMenuProps) {
|
|
105
113
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
@@ -188,6 +196,19 @@ export function SessionActionsMenu({
|
|
|
188
196
|
<span>Rename with AI</span>
|
|
189
197
|
</button>
|
|
190
198
|
)}
|
|
199
|
+
{/* A held prompt is the row's whole content, so dropping it belongs in
|
|
200
|
+
the same menu as the row's other verbs rather than on the row face. */}
|
|
201
|
+
{onCancelWait && (
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
role="menuitem"
|
|
205
|
+
onClick={run(onCancelWait)}
|
|
206
|
+
className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center gap-2 transition-colors cursor-pointer"
|
|
207
|
+
>
|
|
208
|
+
<Hourglass size={12} className="text-ink/60" />
|
|
209
|
+
<span>Cancel waiting prompt</span>
|
|
210
|
+
</button>
|
|
211
|
+
)}
|
|
191
212
|
{onArchive && (
|
|
192
213
|
<button
|
|
193
214
|
type="button"
|
|
@@ -201,7 +222,7 @@ export function SessionActionsMenu({
|
|
|
201
222
|
<span>{isArchived ? 'Unarchive' : 'Archive'}</span>
|
|
202
223
|
</button>
|
|
203
224
|
)}
|
|
204
|
-
{(onRename || onRenameWithAi || onArchive) && onDelete && <div className="my-1 border-t border-ink/10" />}
|
|
225
|
+
{(onRename || onRenameWithAi || onArchive || onCancelWait) && onDelete && <div className="my-1 border-t border-ink/10" />}
|
|
205
226
|
{onDelete && (
|
|
206
227
|
<button
|
|
207
228
|
type="button"
|
|
@@ -20,7 +20,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:tes
|
|
|
20
20
|
import { Window } from 'happy-dom';
|
|
21
21
|
import { h, render } from 'preact';
|
|
22
22
|
import { act } from 'preact/test-utils';
|
|
23
|
-
import {
|
|
23
|
+
import { ActivityBar } from '@/client/components/layout/ActivityBar';
|
|
24
24
|
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
25
25
|
import { useRepoScope } from '@/client/hooks/workspace/repo-scope';
|
|
26
26
|
import { primeKabmoSettings, readKabmoSetting, writeSetting } from '@/shared/lib/settings/client';
|
|
@@ -110,13 +110,13 @@ async function mount() {
|
|
|
110
110
|
render(
|
|
111
111
|
h(SessionStateProvider, {
|
|
112
112
|
sessionId: SESSION,
|
|
113
|
-
children: h('div', null, h(Picker, { repo: '.' }), h(
|
|
113
|
+
children: h('div', null, h(Picker, { repo: '.' }), h(ActivityBar, {
|
|
114
|
+
side: 'right',
|
|
114
115
|
activePanel: 'git',
|
|
115
116
|
onChangePanel: () => {},
|
|
116
117
|
isPanelOpen: true,
|
|
117
118
|
hasActiveContext: true,
|
|
118
119
|
activeProjectPath: ROOT,
|
|
119
|
-
refreshKey: 0,
|
|
120
120
|
})),
|
|
121
121
|
}),
|
|
122
122
|
container,
|
|
@@ -129,7 +129,7 @@ async function mount() {
|
|
|
129
129
|
|
|
130
130
|
const dot = () => Boolean(container.querySelector('[title="Ada perubahan git"]'));
|
|
131
131
|
|
|
132
|
-
describe('
|
|
132
|
+
describe('ActivityBar git dot', () => {
|
|
133
133
|
test('reports the selected repo, and follows the picker to the root', async () => {
|
|
134
134
|
await mount();
|
|
135
135
|
|
|
@@ -200,7 +200,7 @@ async function dragOnto(from: string, to: string) {
|
|
|
200
200
|
for (let i = 0; i < 8; i += 1) await act(async () => {});
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
describe('
|
|
203
|
+
describe('ActivityBar panel order', () => {
|
|
204
204
|
test('a drag reorders the bar and persists the new order', async () => {
|
|
205
205
|
primeKabmoSettings({});
|
|
206
206
|
await mount();
|
|
@@ -1,20 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An activity bar: the rail of panel buttons, for one column.
|
|
8
|
+
*
|
|
9
|
+
* ONE component for both sides. The two rails list different catalogs and sit
|
|
10
|
+
* on opposite edges, but everything that made the right rail work — a drag that
|
|
11
|
+
* commits the order once, a right-click menu that keeps a switched-off view
|
|
12
|
+
* reachable, the Source Control dot — is the same problem on the left. Writing
|
|
13
|
+
* it twice is how the two headers drifted, and a user switching layouts had to
|
|
14
|
+
* re-find every control.
|
|
15
|
+
*
|
|
16
|
+
* What is per-side is data, not branches:
|
|
17
|
+
*
|
|
18
|
+
* - the CATALOG (`usePanelCatalog(side)`), so the left rail draws the session
|
|
19
|
+
* list plus whatever the user placed there;
|
|
20
|
+
* - the ORDER, which the right column stores in `omp_right_panel_order`; the
|
|
21
|
+
* left column is ordered by its placement list, so it is drawn as the catalog
|
|
22
|
+
* returns it and a drag is not offered there;
|
|
23
|
+
* - the DOT, which belongs to Source Control and therefore to the right rail.
|
|
24
|
+
*/
|
|
25
|
+
|
|
1
26
|
import { useRef, useState } from 'preact/hooks';
|
|
2
27
|
import type { TargetedDragEvent, TargetedMouseEvent } from 'preact';
|
|
3
|
-
|
|
4
28
|
import { useGitStatus } from '@/client/hooks/workspace/git-status';
|
|
5
29
|
import { useResolvedRepo } from '@/client/hooks/workspace/repo-scope';
|
|
6
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
usePanelCatalog,
|
|
32
|
+
type PanelCatalogSide,
|
|
33
|
+
type ResolvedPanel,
|
|
34
|
+
} from '@/client/components/workspace/plugin-panel/catalog';
|
|
7
35
|
import { usePanelPluginActions, usePanelRegistry } from '@/client/hooks/workspace/panel-registry';
|
|
8
36
|
import { usePanelOrder } from '@/client/hooks/workspace/panel-order';
|
|
9
37
|
import { usePanelReorder } from '@/client/hooks/workspace/panel-reorder';
|
|
10
|
-
import {
|
|
38
|
+
import { pluginEnablementKey } from '@/shared/lib/workspace/panel-ids';
|
|
11
39
|
import { PANEL_MARK_SIZE } from '@/shared/lib/workspace/right-panels';
|
|
40
|
+
import { SESSIONS_VIEW_ID, isLeftDockPanelId } from '@/shared/lib/workspace/left-dock';
|
|
12
41
|
import { BUILTIN_PANELS } from '@/client/components/workspace/panels/builtin';
|
|
13
42
|
import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
|
|
14
43
|
import { PanelVisibilityMenu, type PanelVisibilityItem } from '@/client/components/layout/PanelVisibilityMenu';
|
|
15
44
|
|
|
16
|
-
interface
|
|
17
|
-
|
|
45
|
+
interface ActivityBarProps {
|
|
46
|
+
side: PanelCatalogSide;
|
|
47
|
+
/** A built-in view id, `sessions`, or a plugin panel key (`plugin:<pluginId>`). */
|
|
18
48
|
activePanel: string;
|
|
19
49
|
onChangePanel: (panel: string) => void;
|
|
20
50
|
isPanelOpen: boolean;
|
|
@@ -23,39 +53,25 @@ interface RightActivityBarProps {
|
|
|
23
53
|
}
|
|
24
54
|
|
|
25
55
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* ONE list of buttons: the built-in views and the installed plugins both come
|
|
29
|
-
* from `usePanelCatalog`, so the bar cannot draw one kind from a table and the
|
|
30
|
-
* other from a fetch — and the catalog is what the phone's tab strip and the
|
|
31
|
-
* desktop panel stack read too, so the order here is the only order any of them
|
|
32
|
-
* can draw.
|
|
56
|
+
* One column's rail.
|
|
33
57
|
*
|
|
34
|
-
* The
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* Right-clicking the bar opens the enablement menu — VS Code's own affordance,
|
|
39
|
-
* with its own semantics here: a view that is switched off leaves the bar but
|
|
40
|
-
* stays in that menu with its checkbox off, which is the only place it can be
|
|
41
|
-
* switched back on. EVERY view is switchable, built-in or plugin, because
|
|
42
|
-
* enablement is one set of ids and a built-in view can be put away exactly like
|
|
43
|
-
* an installed one. A switched-off view KEEPS its place in the stored order, so
|
|
44
|
-
* switching it back on returns it where the user put it.
|
|
45
|
-
*
|
|
46
|
-
* A plugin contributes AT MOST ONE right panel, so one plugin can never produce
|
|
47
|
-
* two buttons here.
|
|
58
|
+
* The left rail is the DOCK's, so its ordering is the placement list and a drag
|
|
59
|
+
* would have nothing to commit — `draggable` is false there and the reorder hook
|
|
60
|
+
* is never given a commit. Everything else is shared.
|
|
48
61
|
*/
|
|
49
|
-
export function
|
|
62
|
+
export function ActivityBar({ side, activePanel, onChangePanel, isPanelOpen, hasActiveContext, activeProjectPath }: ActivityBarProps) {
|
|
50
63
|
// The dot is the Source Control view's, so it follows the repo that view is
|
|
51
64
|
// on. Reading it through the shared pick (not through a private copy) is what
|
|
52
65
|
// makes this update the moment the panel's picker moves; polling the workspace
|
|
53
|
-
// root instead showed the wrong repository's changes.
|
|
54
|
-
|
|
55
|
-
const
|
|
66
|
+
// root instead showed the wrong repository's changes. The left rail has no
|
|
67
|
+
// Source Control, so it does not poll at all.
|
|
68
|
+
const wantsGitDot = side !== 'left';
|
|
69
|
+
const activeRepo = useResolvedRepo(activeProjectPath ?? undefined, wantsGitDot && hasActiveContext);
|
|
70
|
+
const { changes } = useGitStatus(activeProjectPath ?? undefined, activeRepo, wantsGitDot && hasActiveContext);
|
|
56
71
|
const hasGitChanges = changes.length > 0;
|
|
57
72
|
|
|
58
|
-
const catalog = usePanelCatalog();
|
|
73
|
+
const catalog = usePanelCatalog(side);
|
|
74
|
+
const reorderable = side !== 'left';
|
|
59
75
|
const { commitReorder } = usePanelOrder();
|
|
60
76
|
const { disabledPanels } = usePanelRegistry();
|
|
61
77
|
const { setEnabled } = usePanelPluginActions();
|
|
@@ -71,7 +87,8 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
|
|
|
71
87
|
// simply not drawn — the id stays in the stored order, so switching the panel
|
|
72
88
|
// back on returns it where it was.
|
|
73
89
|
const byId = new Map(catalog.map((panel) => [panel.id, panel]));
|
|
74
|
-
const
|
|
90
|
+
const order = reorderable ? reorder.order : catalogIds;
|
|
91
|
+
const drawn = order
|
|
75
92
|
.map((id) => byId.get(id))
|
|
76
93
|
.filter((panel): panel is ResolvedPanel => panel !== undefined);
|
|
77
94
|
|
|
@@ -84,13 +101,13 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
|
|
|
84
101
|
const dividerBefore =
|
|
85
102
|
firstPlugin > 0 && drawn.slice(firstPlugin).every((panel) => !panel.builtin) ? firstPlugin : -1;
|
|
86
103
|
|
|
87
|
-
// The menu lists EVERY panel, off or on — a switched-off
|
|
88
|
-
// reachable, and this list is the only place it can be switched
|
|
89
|
-
// is built from the FULL id list rather than from the catalog,
|
|
90
|
-
// switched-off view is not in the catalog at all. The entry is keyed
|
|
91
|
-
// SAME id the disabled set stores, so the checkbox and the write cannot
|
|
104
|
+
// The menu lists EVERY panel this rail could draw, off or on — a switched-off
|
|
105
|
+
// view must stay reachable, and this list is the only place it can be switched
|
|
106
|
+
// back on. It is built from the FULL id list rather than from the catalog,
|
|
107
|
+
// because a switched-off view is not in the catalog at all. The entry is keyed
|
|
108
|
+
// by the SAME id the disabled set stores, so the checkbox and the write cannot
|
|
92
109
|
// address different panels.
|
|
93
|
-
const allItems = useAllPanelItems();
|
|
110
|
+
const allItems = useAllPanelItems(side);
|
|
94
111
|
|
|
95
112
|
const openMenu = (event: TargetedMouseEvent<HTMLElement>) => {
|
|
96
113
|
event.preventDefault();
|
|
@@ -108,7 +125,8 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
|
|
|
108
125
|
<nav
|
|
109
126
|
ref={navRef}
|
|
110
127
|
onContextMenu={openMenu}
|
|
111
|
-
|
|
128
|
+
aria-label={side === 'left' ? 'Left panel views' : 'Right panel views'}
|
|
129
|
+
className={`w-11 flex-shrink-0 ${side === 'left' ? 'border-r' : 'border-l'} border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10`}
|
|
112
130
|
>
|
|
113
131
|
<div className="flex flex-col items-center space-y-1 w-full">
|
|
114
132
|
{drawn.map((panel, index) => (
|
|
@@ -116,8 +134,9 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
|
|
|
116
134
|
{index === dividerBefore ? <div className="w-6 mx-auto border-t border-ink/10 my-1" /> : null}
|
|
117
135
|
<PanelButton
|
|
118
136
|
panel={panel}
|
|
137
|
+
draggable={reorderable}
|
|
119
138
|
isActive={isPanelOpen && activePanel === panel.id}
|
|
120
|
-
isDragging={reorder.draggingId === panel.id}
|
|
139
|
+
isDragging={reorderable && reorder.draggingId === panel.id}
|
|
121
140
|
onClick={() => onChangePanel(panel.id)}
|
|
122
141
|
onDragStart={(event) => reorder.onDragStart(event, panel.id)}
|
|
123
142
|
onDragOver={(event) => reorder.onDragOver(event, panel.id)}
|
|
@@ -154,17 +173,29 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
|
|
|
154
173
|
* static list, so they are always listed; a plugin is listed while it is
|
|
155
174
|
* INSTALLED, read from `plugins` rather than from `panels`, because `panels`
|
|
156
175
|
* holds only the enabled ones and a disabled plugin would lose its row.
|
|
176
|
+
*
|
|
177
|
+
* The list is per SIDE: the left rail offering eleven built-in views it cannot
|
|
178
|
+
* draw would be a menu of controls that do nothing.
|
|
157
179
|
*/
|
|
158
|
-
function useAllPanelItems(): PanelVisibilityItem[] {
|
|
180
|
+
function useAllPanelItems(side: PanelCatalogSide): PanelVisibilityItem[] {
|
|
159
181
|
const { plugins } = usePanelRegistry();
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
182
|
+
const items: PanelVisibilityItem[] = [];
|
|
183
|
+
if (side !== 'right') {
|
|
184
|
+
// The session list is the dock's own view and cannot be switched off: it is
|
|
185
|
+
// the only way back to a conversation.
|
|
186
|
+
items.push({ id: SESSIONS_VIEW_ID, title: 'Sessions', removable: false });
|
|
187
|
+
}
|
|
188
|
+
if (side !== 'left') {
|
|
189
|
+
for (const panel of BUILTIN_PANELS) items.push({ id: panel.id, title: panel.title, removable: true });
|
|
190
|
+
}
|
|
191
|
+
for (const plugin of plugins) {
|
|
192
|
+
const id = pluginEnablementKey(plugin.pluginId);
|
|
193
|
+
// A plugin is offered on both rails: its placement decides which one draws
|
|
194
|
+
// it, and the switch is the same switch on either.
|
|
195
|
+
if (side === 'left' && !isLeftDockPanelId(id)) continue;
|
|
196
|
+
items.push({ id, title: plugin.name, removable: true });
|
|
197
|
+
}
|
|
198
|
+
return items;
|
|
168
199
|
}
|
|
169
200
|
|
|
170
201
|
/**
|
|
@@ -175,6 +206,7 @@ function useAllPanelItems(): PanelVisibilityItem[] {
|
|
|
175
206
|
*/
|
|
176
207
|
function PanelButton({
|
|
177
208
|
panel,
|
|
209
|
+
draggable,
|
|
178
210
|
isActive,
|
|
179
211
|
isDragging,
|
|
180
212
|
onClick,
|
|
@@ -185,6 +217,7 @@ function PanelButton({
|
|
|
185
217
|
showGitDot,
|
|
186
218
|
}: {
|
|
187
219
|
panel: ResolvedPanel;
|
|
220
|
+
draggable: boolean;
|
|
188
221
|
isActive: boolean;
|
|
189
222
|
isDragging: boolean;
|
|
190
223
|
onClick: () => void;
|
|
@@ -196,11 +229,13 @@ function PanelButton({
|
|
|
196
229
|
}) {
|
|
197
230
|
return (
|
|
198
231
|
<button
|
|
199
|
-
className={`relative w-full h-10 flex items-center justify-center transition-colors
|
|
232
|
+
className={`relative w-full h-10 flex items-center justify-center transition-colors ${
|
|
233
|
+
panel.side === 'left' ? 'border-r-2' : 'border-l-2'
|
|
234
|
+
} ${
|
|
200
235
|
isActive ? 'text-ink border-ink bg-ink/5' : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'
|
|
201
236
|
} ${isDragging ? 'opacity-40 cursor-grabbing' : 'cursor-pointer'}`}
|
|
202
237
|
onClick={onClick}
|
|
203
|
-
draggable
|
|
238
|
+
draggable={draggable}
|
|
204
239
|
onDragStart={onDragStart}
|
|
205
240
|
onDragOver={onDragOver}
|
|
206
241
|
onDrop={onDrop}
|
|
@@ -208,7 +243,7 @@ function PanelButton({
|
|
|
208
243
|
title={panel.title}
|
|
209
244
|
aria-label={panel.title}
|
|
210
245
|
>
|
|
211
|
-
{panel.builtin ? panel.icon : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
|
|
246
|
+
{panel.builtin && panel.icon ? panel.icon : panel.builtin ? <PluginMark name={panel.label} size={PANEL_MARK_SIZE} /> : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
|
|
212
247
|
{showGitDot ? (
|
|
213
248
|
<span
|
|
214
249
|
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The LEFT rail: what it draws, and what it refuses to draw.
|
|
8
|
+
*
|
|
9
|
+
* Three properties, and each is a bug the right rail's shape would produce if
|
|
10
|
+
* the left one were a copy of it:
|
|
11
|
+
*
|
|
12
|
+
* - **The session list is always on it and cannot be switched off.** It is the
|
|
13
|
+
* only way back to a conversation, so the visibility menu lists it as pinned
|
|
14
|
+
* rather than offering a switch that would strand the user.
|
|
15
|
+
* - **The rail draws only what the LEFT column can show.** A menu of eleven
|
|
16
|
+
* built-in views it cannot render would be a list of controls that do nothing.
|
|
17
|
+
* - **A plugin is on exactly one rail.** Its placement decides which, so the
|
|
18
|
+
* same panel cannot be two buttons — one of which would draw nothing.
|
|
19
|
+
*
|
|
20
|
+
* Rendered with `h()` (no JSX) against happy-dom, through the real session-state
|
|
21
|
+
* provider: the rail reads placement from the settings snapshot, which is module
|
|
22
|
+
* state a test has to prime.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
26
|
+
import { Window } from 'happy-dom';
|
|
27
|
+
import { h, render } from 'preact';
|
|
28
|
+
import { act } from 'preact/test-utils';
|
|
29
|
+
import { ActivityBar } from '@/client/components/layout/ActivityBar';
|
|
30
|
+
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
31
|
+
import { definePluginApp } from '@kabmo-labs/kabmo-sdk/app';
|
|
32
|
+
import { primeKabmoSettings } from '@/shared/lib/settings/client';
|
|
33
|
+
import { registerPluginBundle, resetPluginSlots } from '@/client/lib/plugins/slots';
|
|
34
|
+
import { resetPanelRegistry } from '@/client/hooks/workspace/panel-registry';
|
|
35
|
+
import { LEFT_DOCK_SETTING_KEY } from '@/shared/lib/workspace/left-dock';
|
|
36
|
+
import { resetRealtimeClient } from '@/shared/lib/realtime/client';
|
|
37
|
+
import { TOPIC_PANELS } from '@/shared/lib/realtime/protocol';
|
|
38
|
+
import { startRealtimeTestServer, type RealtimeTestServer } from '@/test-support/realtime-server';
|
|
39
|
+
import {
|
|
40
|
+
installDomGlobals,
|
|
41
|
+
pristineWebSocket,
|
|
42
|
+
restoreDomGlobals,
|
|
43
|
+
} from '@/test-support/pristine-globals';
|
|
44
|
+
import type { TopicResolver } from '@/server/lib/realtime/hub.server';
|
|
45
|
+
|
|
46
|
+
const SESSION = 'new-left-rail';
|
|
47
|
+
const nativeFetch = Bun.fetch;
|
|
48
|
+
let container: HTMLElement | null = null;
|
|
49
|
+
let server: RealtimeTestServer;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ONE harness per file, started before any case — the rule the helper documents:
|
|
53
|
+
* the hub, its topics and the listener are process state, so a server started
|
|
54
|
+
* per test leaves the previous one's socket to report its close into the NEXT
|
|
55
|
+
* file's globals (`Unhandled error. (ErrorEvent …)`, measured).
|
|
56
|
+
*/
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
// The rail reads the plugin registry, which is a realtime topic — so the test
|
|
59
|
+
// needs a real channel rather than a stub, the same reason the right rail's
|
|
60
|
+
// suite starts one.
|
|
61
|
+
const registry: TopicResolver = async () => ({
|
|
62
|
+
// `demo` is INSTALLED; whether it draws a button depends on its bundle
|
|
63
|
+
// having registered a component, which is what the placement case sets up.
|
|
64
|
+
panels: [{ pluginId: 'demo', name: 'demo', version: '1.0.0', appUrl: '/api/panels/bundle/demo' }],
|
|
65
|
+
marketplaces: [],
|
|
66
|
+
errors: [],
|
|
67
|
+
plugins: [],
|
|
68
|
+
catalog: [],
|
|
69
|
+
disabledPanels: [],
|
|
70
|
+
gitStatus: {},
|
|
71
|
+
autoUpdate: false,
|
|
72
|
+
syncBlocked: {},
|
|
73
|
+
});
|
|
74
|
+
server = await startRealtimeTestServer(new Map<string, TopicResolver>([[TOPIC_PANELS, registry]]));
|
|
75
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
76
|
+
target.fetch = async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
const win = new Window({ url: `http://127.0.0.1:${server.port}` });
|
|
81
|
+
installDomGlobals(win);
|
|
82
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
83
|
+
target.WebSocket = pristineWebSocket;
|
|
84
|
+
primeKabmoSettings({});
|
|
85
|
+
resetRealtimeClient();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
90
|
+
target.fetch = nativeFetch;
|
|
91
|
+
restoreDomGlobals();
|
|
92
|
+
server.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
if (container) {
|
|
97
|
+
render(null, container);
|
|
98
|
+
container.remove();
|
|
99
|
+
container = null;
|
|
100
|
+
}
|
|
101
|
+
primeKabmoSettings({});
|
|
102
|
+
resetRealtimeClient();
|
|
103
|
+
// The registry store is a `globalThis` slot, so a payload adopted here would
|
|
104
|
+
// be every later suite's starting state — the right rail would draw this
|
|
105
|
+
// file's plugin.
|
|
106
|
+
resetPanelRegistry();
|
|
107
|
+
resetPluginSlots();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function mount() {
|
|
111
|
+
// The registry is a realtime read: the bar draws plugins only once the
|
|
112
|
+
// payload has been adopted, so the mount waits for the client's own value
|
|
113
|
+
// rather than for a duration.
|
|
114
|
+
await server.waitForTopic(TOPIC_PANELS, (value) => value !== null);
|
|
115
|
+
container = document.createElement('div');
|
|
116
|
+
document.body.appendChild(container);
|
|
117
|
+
await act(async () => {
|
|
118
|
+
render(
|
|
119
|
+
h(SessionStateProvider, {
|
|
120
|
+
sessionId: SESSION,
|
|
121
|
+
children: h(ActivityBar, {
|
|
122
|
+
side: 'left',
|
|
123
|
+
activePanel: 'sessions',
|
|
124
|
+
onChangePanel: () => {},
|
|
125
|
+
isPanelOpen: true,
|
|
126
|
+
hasActiveContext: true,
|
|
127
|
+
}),
|
|
128
|
+
}),
|
|
129
|
+
container!,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
await flush();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Let the channel's frames land and the tree re-render.
|
|
137
|
+
*
|
|
138
|
+
* The registry arrives over a real socket, so a microtask flush is not enough:
|
|
139
|
+
* the frame is a macrotask. This yields to the event loop a bounded number of
|
|
140
|
+
* times rather than sleeping a guessed duration.
|
|
141
|
+
*/
|
|
142
|
+
async function flush(times = 12) {
|
|
143
|
+
for (let i = 0; i < times; i += 1) {
|
|
144
|
+
await act(async () => {
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const labels = () =>
|
|
151
|
+
[...(container?.querySelectorAll('nav button[aria-label]') ?? [])].map((el) => el.getAttribute('aria-label') ?? '');
|
|
152
|
+
|
|
153
|
+
describe('left activity bar', () => {
|
|
154
|
+
test('draws the session list, and nothing from the right column', async () => {
|
|
155
|
+
await mount();
|
|
156
|
+
expect(labels()).toEqual(['Sessions']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('the sessions row is offered as pinned, not switchable', async () => {
|
|
160
|
+
await mount();
|
|
161
|
+
const nav = container!.querySelector('nav') as HTMLElement;
|
|
162
|
+
await act(async () => {
|
|
163
|
+
nav.dispatchEvent(new window.Event('contextmenu', { bubbles: true, cancelable: true }));
|
|
164
|
+
});
|
|
165
|
+
const menu = document.querySelector('[role="menu"][aria-label="Panel visibility"]');
|
|
166
|
+
expect(menu).not.toBeNull();
|
|
167
|
+
const row = [...menu!.querySelectorAll('[role="menuitemcheckbox"]')].find(
|
|
168
|
+
(el) => el.textContent?.includes('Sessions'),
|
|
169
|
+
) as HTMLButtonElement;
|
|
170
|
+
expect(row).toBeDefined();
|
|
171
|
+
// Disabled and still checked: the row exists so the menu is complete, and
|
|
172
|
+
// it says the view cannot be put away rather than pretending a click works.
|
|
173
|
+
expect(row.disabled).toBe(true);
|
|
174
|
+
expect(row.getAttribute('aria-checked')).toBe('true');
|
|
175
|
+
// The built-ins are RIGHT-column views and are not offered here at all.
|
|
176
|
+
expect(menu!.textContent).not.toContain('Source Control');
|
|
177
|
+
menu!.parentElement?.remove();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('a placed plugin panel is drawn here and gone from the right rail', async () => {
|
|
181
|
+
// One bundle, registered the way a loaded plugin registers it, and a
|
|
182
|
+
// registry that names it — then the placement decides the rail.
|
|
183
|
+
registerPluginBundle('demo', {
|
|
184
|
+
default: definePluginApp((app) => app.rightPanel({ id: 'main', title: 'Demo panel', component: () => null })),
|
|
185
|
+
});
|
|
186
|
+
// The INSTANCE key (plugin + slot). A bare `plugin:demo` still resolves —
|
|
187
|
+
// to the right-panel instance — but the dock holds instance keys, so that
|
|
188
|
+
// is what a placement writes.
|
|
189
|
+
primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: ['plugin:demo:rightPanel'] } });
|
|
190
|
+
await mount();
|
|
191
|
+
expect(labels()).toEqual(['Sessions', 'Demo panel — demo']);
|
|
192
|
+
|
|
193
|
+
// The same plugin on the RIGHT: the left rail loses the button, because a
|
|
194
|
+
// panel is on one rail at a time.
|
|
195
|
+
primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: [] } });
|
|
196
|
+
render(null, container!);
|
|
197
|
+
container = null;
|
|
198
|
+
await mount();
|
|
199
|
+
expect(labels()).toEqual(['Sessions']);
|
|
200
|
+
});
|
|
201
|
+
});
|