@kabmo-labs/kabmo 0.5.2 → 0.5.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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>",
@@ -218,13 +218,11 @@ export function MobileSessionSidebar({
218
218
  viewLabel={showingSessions ? 'Sessions' : dock.activePanel?.label ?? 'Panel'}
219
219
  />
220
220
 
221
- {/* The strip appears only once there is something to switch TO: with the
222
- rail off, or with nothing placed on it, a row holding one chip would
223
- be chrome that never changes. The exception is a stored view that no
224
- longer resolves — the body is a notice, and without the strip the
225
- session list would be unreachable (the desktop has the same escape in
226
- its rail, which always holds Sessions). */}
227
- {phoneLeftStripVisible(dock.enabled, dock.placed.length, showingSessions) ? (
221
+ {/* The strip is the phone's RAIL, so it follows the switch: with the rail
222
+ on it always draws, exactly like the desktop column's rail (which
223
+ always holds Sessions). Requiring a second destination made the
224
+ Settings switch a no-op on a phone with nothing placed. */}
225
+ {phoneLeftStripVisible(dock.enabled) ? (
228
226
  <MobileDockStrip panels={dock.catalog} activeId={activeView} onSelect={dock.setActiveView} />
229
227
  ) : null}
230
228
 
@@ -31,8 +31,6 @@ import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
31
31
  export interface MobileDockState {
32
32
  /** The strip's chips: `sessions` and every placed panel. */
33
33
  catalog: ResolvedPanel[];
34
- /** The placed panels only — what makes the strip worth drawing. */
35
- placed: ResolvedPanel[];
36
34
  activeView: string;
37
35
  setActiveView: (id: string) => void;
38
36
  /** True while the session list is the view on screen. */
@@ -47,11 +45,9 @@ export function useMobileDock(): MobileDockState {
47
45
  const catalog = usePanelCatalog('left');
48
46
  const [stored, setStored] = useSessionState<string>('layout.mobileLeftPanel', SESSIONS_VIEW_ID);
49
47
  const activeView = useLeftDockView(stored, dock.enabled);
50
- const placed = catalog.filter((panel) => panel.id !== SESSIONS_VIEW_ID);
51
48
 
52
49
  return {
53
50
  catalog,
54
- placed,
55
51
  activeView,
56
52
  setActiveView: setStored,
57
53
  showingSessions: activeView === SESSIONS_VIEW_ID,
@@ -158,30 +158,6 @@ async function mountProvider(): Promise<Harness> {
158
158
  }
159
159
 
160
160
  describe('SidebarDataProvider reads both sidebar topics', () => {
161
- test('paints the structure from the snapshot, without any fetch', async () => {
162
- const { holder, server } = await mountProvider();
163
- expect(holder.api?.initializing).toBe(false);
164
- expect(holder.api?.folders).toHaveLength(1);
165
- expect(holder.api?.folders[0]?.sessions[0]?.title).toBe('A');
166
- server.stop();
167
- });
168
-
169
- test('merges the volatile status onto the session it belongs to', async () => {
170
- const harness = await mountProvider();
171
- expect(renderedStatus(harness.holder.api)).toBeUndefined();
172
-
173
- await harness.push(undefined, statusBody('stream'));
174
- expect(renderedStatus(harness.holder.api)).toBe('stream');
175
-
176
- // A terminal badge, then cleared — the map is authoritative for removals.
177
- await harness.push(undefined, statusBody('finish'));
178
- expect(renderedStatus(harness.holder.api)).toBe('finish');
179
- await harness.push(undefined, statusBody());
180
- expect(renderedStatus(harness.holder.api)).toBeUndefined();
181
-
182
- harness.server.stop();
183
- });
184
-
185
161
  test('reports a live run for a session the structure has not scanned yet', async () => {
186
162
  // A fresh spawn: omp writes the session file only when the first assistant
187
163
  // message settles (measured ~17s), so no structure row exists. The status
@@ -66,12 +66,6 @@ describe('describeShellBuildFailure', () => {
66
66
  expect(await describeShellBuildFailure(root)).toBeNull();
67
67
  });
68
68
 
69
- test('returns null when there is no shell entrypoint to build', async () => {
70
- rmSync(join(root, 'index.html'));
71
-
72
- expect(await describeShellBuildFailure(root)).toBeNull();
73
- });
74
-
75
69
  test('loads the plugins named in bunfig.toml, as the route does', async () => {
76
70
  // A plugin that fails a file the route would bundle: if the probe ignored
77
71
  // bunfig it would report a clean build and hide the real failure.
@@ -254,17 +254,6 @@ describe('continue_operation', () => {
254
254
  });
255
255
 
256
256
  describe('abort_operation', () => {
257
- test('restores the pre-merge state', async () => {
258
- const repo = makeConflictingRepo();
259
- await post({ actionType: 'merge_branch', repo: '.', root: repo, branch: 'feature' });
260
-
261
- const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
262
- expect(res.body.success).toBe(true);
263
- expect((await readGitState(repo)).operation).toBeNull();
264
- expect(fs.readFileSync(path.join(repo, 'f.txt'), 'utf8')).toBe('a\nMAIN\n');
265
- expect(git(repo, 'status', '--porcelain')).toBe('');
266
- });
267
-
268
257
  test('refuses when nothing is in progress', async () => {
269
258
  const repo = makeConflictingRepo();
270
259
  const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
@@ -132,13 +132,6 @@ describe('the README route', () => {
132
132
 
133
133
  expect((await call(readmeLoader, { plugin: 'demo', '*': '../outside.md' })).status).toBe(403);
134
134
  });
135
-
136
- test('404s a plugin that ships no README', async () => {
137
- const dir = writePlugin('demo', getMarketplacePluginsDir());
138
- fs.rmSync(join(dir, 'README.md'));
139
- invalidatePanelScan();
140
- expect((await call(readmeLoader, { plugin: 'demo', '*': 'README.md' })).status).toBe(404);
141
- });
142
135
  });
143
136
 
144
137
  describe('the install route', () => {
@@ -46,20 +46,14 @@ describe('phoneRightPanels', () => {
46
46
 
47
47
  describe('phoneLeftStripVisible', () => {
48
48
  test('no rail, no strip', () => {
49
- expect(phoneLeftStripVisible(false, 2, true)).toBe(false);
49
+ expect(phoneLeftStripVisible(false)).toBe(false);
50
50
  });
51
51
 
52
- test('a strip holding only Sessions is chrome that never changes', () => {
53
- expect(phoneLeftStripVisible(true, 0, true)).toBe(false);
54
- });
55
-
56
- test('a placed panel is a second destination, so the strip appears', () => {
57
- expect(phoneLeftStripVisible(true, 1, true)).toBe(true);
58
- });
59
-
60
- test('a stored view that no longer resolves still gets the strip — its body is a notice', () => {
61
- // Without this the drawer would be stuck on a notice with no way back to
62
- // the session list: the body has no chips of its own.
63
- expect(phoneLeftStripVisible(true, 0, false)).toBe(true);
52
+ test('the rail being on IS the strip, even with nothing placed', () => {
53
+ // The bug this pins: the rule used to require a second destination, so the
54
+ // Settings switch "Show the left activity bar" drew nothing at all on a
55
+ // phone with no plugin panel placed on the left — a switch whose only
56
+ // effect was a stored boolean. On a phone the strip is the rail.
57
+ expect(phoneLeftStripVisible(true)).toBe(true);
64
58
  });
65
59
  });
@@ -13,6 +13,15 @@
13
13
  * the left drawer draws the dock too, so leaving `all` in the right one puts a
14
14
  * single panel on two strips of one phone.
15
15
  *
16
+ * **The left drawer's strip follows the rail SWITCH and nothing else.** It used
17
+ * to need a second destination (a placed panel, or a stored view that no longer
18
+ * resolves), on the reasoning that a strip holding only `Sessions` is chrome
19
+ * that never changes. That rule made the Settings switch — "Show the left
20
+ * activity bar" — a no-op on every phone with no plugin panel placed on the
21
+ * left: the rail was on, the desktop column drew its `Sessions` button, and the
22
+ * phone drawer drew nothing, which reads as the switch being broken. On a phone
23
+ * the strip IS the rail, so it is drawn whenever the rail is on.
24
+ *
16
25
  * Pure, and separate from both components, so the two drawers cannot drift and
17
26
  * the rule can be tested without mounting either.
18
27
  */
@@ -45,11 +54,11 @@ export function phoneRightPanels<T extends SidedPanel>(
45
54
  /**
46
55
  * Whether the left drawer draws its chip strip.
47
56
  *
48
- * A strip holding one chip is chrome that never changes, so it needs a second
49
- * destination — either a placed panel, or a stored view that no longer
50
- * resolves. The second half is the escape hatch: that body is a notice, and
51
- * without the strip the session list would be unreachable.
57
+ * The switch alone: on a phone the strip is the rail, so "Show the left activity
58
+ * bar" that draws nothing is a control with no effect. The strip holding one
59
+ * `Sessions` chip is the honest picture of a rail with one view on it — the same
60
+ * picture the desktop column draws — and it grows the moment a panel is placed.
52
61
  */
53
- export function phoneLeftStripVisible(dockEnabled: boolean, placedCount: number, showingSessions: boolean): boolean {
54
- return dockEnabled && (placedCount > 0 || !showingSessions);
62
+ export function phoneLeftStripVisible(dockEnabled: boolean): boolean {
63
+ return dockEnabled;
55
64
  }
@@ -1,192 +0,0 @@
1
- /**
2
- * @license
3
- * SPDX-License-Identifier: Apache-2.0
4
- */
5
-
6
- /**
7
- * The left column draws ONE separator, and its chrome spans the WHOLE column.
8
- *
9
- * Both `LeftColumn` and `DesktopLayout` rendered a `ResizeHandle` after the
10
- * dock, and both were mounted — so the column boundary drew two hairlines 3px
11
- * apart and a reader saw a double rule between the session list and the chat.
12
- * It was reproduced only when the rail was ON, because that is the layout a
13
- * screenshotted report came from, though the extra handle was there in both
14
- * states.
15
- *
16
- * A geometry test cannot catch the separator (happy-dom computes no layout), so
17
- * that assertion is on the STRUCTURE: the group's direct children, in order. The
18
- * second handle was a sibling — `LeftColumn`'s own sits between the dock and
19
- * whatever follows — which is exactly what an order comparison shows.
20
- *
21
- * The chrome half is structural too, and both cases were reported. The strip
22
- * lived inside the dock BODY while the rail was a sibling panel, so with the
23
- * rail on it stopped at the body's left edge and left an empty strip under the
24
- * rail. And the rail started at the column's top, running up past the title row
25
- * and under the app's own titlebar. The column is three ROWS — title, then
26
- * (rail | view), then strip — so both are asserted by the row each one lives in.
27
- */
28
-
29
- import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
30
- import { Window } from 'happy-dom';
31
- import { h, render } from 'preact';
32
- import { act } from 'preact/test-utils';
33
- import { Group } from '@/client/components/layout/desktop-layout/resizer';
34
- import { LeftColumn } from '@/client/components/layout/desktop-layout/LeftColumn';
35
- import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
36
- import { primeKabmoSettings } from '@/shared/lib/settings/client';
37
- import { realtimeClient, resetRealtimeClient } from '@/shared/lib/realtime/client';
38
- import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
39
-
40
- let container: HTMLElement | null = null;
41
-
42
- /**
43
- * A socket that never dials.
44
- *
45
- * The rail subscribes to the Source Control topic on mount. Left to a REAL
46
- * socket, its close lands after this file has restored the globals and surfaces
47
- * as `Unhandled error. (ErrorEvent …)` attributed to whichever suite ran next —
48
- * measured. This column's subject is its own structure, so the channel is
49
- * silenced rather than served.
50
- */
51
- class SilentSocket {
52
- static readonly CLOSING = 2;
53
- static readonly CLOSED = 3;
54
- readyState = SilentSocket.CLOSED;
55
- onopen: (() => void) | null = null;
56
- onclose: (() => void) | null = null;
57
- onmessage: ((event: { data: string }) => void) | null = null;
58
- onerror: (() => void) | null = null;
59
- send(): void {}
60
- close(): void {}
61
- }
62
-
63
- beforeEach(() => {
64
- installDomGlobals(new Window({ url: 'http://127.0.0.1:1' }));
65
- realtimeClient.setSocketConstructor(SilentSocket as unknown as typeof WebSocket);
66
- primeKabmoSettings({});
67
- });
68
-
69
- afterAll(() => {
70
- realtimeClient.setSocketConstructor(null);
71
- restoreDomGlobals();
72
- });
73
-
74
- afterEach(() => {
75
- // The rail's Source Control dot subscribes to a realtime topic, so the render
76
- // opens a socket. Left open, its close lands in the NEXT file's globals once
77
- // this one has restored them — measured as `Unhandled error. (ErrorEvent …)`
78
- // between tests, attributed to whichever suite happened to run next.
79
- resetRealtimeClient();
80
- if (container) {
81
- render(null, container);
82
- container.remove();
83
- container = null;
84
- }
85
- primeKabmoSettings({});
86
- });
87
-
88
- async function mountColumn(railEnabled: boolean) {
89
- const host = document.createElement('div');
90
- document.body.appendChild(host);
91
- container = host;
92
-
93
- await act(async () => {
94
- render(
95
- h(SessionStateProvider, {
96
- sessionId: 'new-separator',
97
- children: h(Group, {
98
- orientation: 'horizontal',
99
- id: 'kabmo-main',
100
- children: [
101
- h(LeftColumn, {
102
- panelRef: { current: null },
103
- open: true,
104
- railEnabled,
105
- view: 'sessions',
106
- onSelectView: () => {},
107
- onCollapse: () => {},
108
- width: 280,
109
- minWidth: 264,
110
- hasActiveContext: true,
111
- refreshKey: 0,
112
- onRefresh: () => {},
113
- onOpenFile: () => {},
114
- }),
115
- h('div', { 'data-panel-id': 'main-right-stack' }),
116
- ],
117
- }),
118
- }),
119
- host,
120
- );
121
- });
122
- // The dock's view is `lazy()`, so the boundary matters before it resolves:
123
- // the separator is a SIBLING of the panel, not part of its content.
124
- for (let i = 0; i < 5; i += 1) await act(async () => {});
125
- }
126
-
127
- /** The group's DIRECT children, in order — a separator appears once per entry. */
128
- const children = (): string[] =>
129
- [...(container!.querySelector('[data-panel-group-id="kabmo-main"]')?.children ?? [])].map((child) =>
130
- child.hasAttribute('data-separator') ? 'separator' : (child.getAttribute('data-panel-id') ?? child.tagName),
131
- );
132
-
133
- describe('left column separators', () => {
134
- test('one separator with the rail on', async () => {
135
- await mountColumn(true);
136
- expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
137
- });
138
-
139
- test('one separator with the rail off', async () => {
140
- await mountColumn(false);
141
- expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
142
- });
143
-
144
- test('the separator is the column boundary — after the dock, not before it', async () => {
145
- await mountColumn(true);
146
- const kids = children();
147
- expect(kids.at(-1)).toBe('main-right-stack');
148
- expect(kids.at(-2)).toBe('separator');
149
- });
150
- });
151
-
152
- describe('left column chrome', () => {
153
- const panel = () => container!.querySelector('[data-panel-id="left-panel"]') as HTMLElement;
154
- const rail = () => panel().querySelector('nav[aria-label="Left panel views"]');
155
- const title = () => panel().querySelector('.h-10.flex-shrink-0.flex.items-center.justify-between');
156
- const footer = () => panel().querySelector('button[aria-label="Settings"]');
157
-
158
- test('the title row, the rail and the strip are three rows, in that order', async () => {
159
- await mountColumn(true);
160
- const column = panel().firstElementChild as HTMLElement;
161
- const rows = [...column.children];
162
- expect(rows).toHaveLength(3);
163
- // Row 0 is the title, row 1 holds the rail beside the view, row 2 the strip.
164
- expect(rows[0].contains(title())).toBe(true);
165
- expect(rows[1].contains(rail())).toBe(true);
166
- expect(rows[2].contains(footer())).toBe(true);
167
- });
168
-
169
- test('the rail starts BELOW the title, never above it', async () => {
170
- await mountColumn(true);
171
- const column = panel().firstElementChild as HTMLElement;
172
- expect(column.children[0].contains(rail())).toBe(false);
173
- expect(column.children[1].contains(rail())).toBe(true);
174
- });
175
-
176
- test('the strip spans the column, so the rail does not outlive it', async () => {
177
- await mountColumn(true);
178
- const column = panel().firstElementChild as HTMLElement;
179
- expect(column.children[2].contains(rail())).toBe(false);
180
- expect(column.children[2].contains(footer())).toBe(true);
181
- // Same row container for title and strip: both are full-width rows of the
182
- // column, which is what makes the strip run under the rail too.
183
- expect(column.children[0].parentElement).toBe(column.children[2].parentElement);
184
- });
185
-
186
- test('the strip survives a view that is not the session list', async () => {
187
- await mountColumn(true);
188
- // The column's furniture is rendered outside `LeftDockView` entirely, so it
189
- // is present even while the lazy view is still resolving.
190
- expect(footer()).not.toBeNull();
191
- });
192
- });