@kabmo-labs/kabmo 0.5.1 → 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.1",
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>",
@@ -181,13 +181,10 @@ export function SessionItem({
181
181
  ) : status === 'stream' ? (
182
182
  // The run spinner is the ONE thing on this row that says a run
183
183
  // is in flight, and it was an unlabelled icon: a screen reader
184
- // got a bare `svg`, and a test had no accessible way to ask
185
- // "is this session running". `waiting-for.e2e.ts` needs exactly
186
- // that as its gate — the wait picker only appears once this
187
- // session's `stream` row has reached the client, so asserting
188
- // the picker without asserting the row was a race measured on
189
- // the Windows runner (the dependency's 20s run expired while the
190
- // picker's own 20s timeout was still counting).
184
+ // got a bare `svg`, and nothing could ask "is this session
185
+ // running" by name. The rename and waiting cues beside it were
186
+ // already labelled, so this was the one status a reader — or an
187
+ // assertion — had to identify by shape.
191
188
  <span role="status" aria-label="Running">
192
189
  <Loader2 size={12} className="animate-spin" />
193
190
  </span>
@@ -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
 
@@ -159,7 +159,7 @@ export function MobileSessionRow({
159
159
  ) : status === 'stream' ? (
160
160
  // Same label the desktop row carries, for the same reason: the
161
161
  // phone draws the identical cue, and an unlabelled spinner is
162
- // both unreachable to a screen reader and unassertable.
162
+ // unreachable to a screen reader.
163
163
  <span role="status" aria-label="Running">
164
164
  <Loader2 size={13} className="text-ink/50 animate-spin" />
165
165
  </span>
@@ -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.
@@ -22,13 +22,30 @@
22
22
  * `MERGE_HEAD` file are the whole subject.
23
23
  */
24
24
 
25
- import { afterAll, describe, expect, test } from 'bun:test';
25
+ import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
26
26
  import fs from 'node:fs';
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  import { GIT_BRANCH_ACTIONS } from '@/server/lib/fs/git-actions-branch';
30
30
  import { removeDir } from '@/test-support/remove-dir';
31
31
 
32
+ /**
33
+ * The suite drives REAL git processes — `init`, `clone`, `commit`, `push`,
34
+ * `fetch`, `pull`, `merge` — and one test of it pays for most of them. Bun's
35
+ * default per-test budget is 5s, which a Linux runner clears and a Windows
36
+ * runner does not: measured on `windows-latest`, "reconciles a diverged branch"
37
+ * timed out at 5001ms while the same suite is green locally in 1.4s. Process
38
+ * spawn on that runner costs several times what it does on POSIX, and the
39
+ * log line above the failure (`killed 1 dangling process`) is the timeout
40
+ * firing mid-`git`.
41
+ *
42
+ * A budget, not a skip: the behaviour — a diverged branch reconciled by a merge
43
+ * and then pushed — is identical on both platforms, and skipping it there would
44
+ * report the Sync button as unverified on a platform the CLI ships to. The same
45
+ * budget is stated for the same reason in `git-update.server.test.ts`.
46
+ */
47
+ setDefaultTimeout(30_000);
48
+
32
49
  const tempDirs: string[] = [];
33
50
 
34
51
  function tempDir(): string {
@@ -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
- });