@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useRef, useState } from 'preact/hooks';
|
|
2
|
-
import { ChevronDown, ChevronRight,
|
|
2
|
+
import { ChevronDown, ChevronRight, GitBranch, MessageSquare, MoreHorizontal, Plus } from 'lucide-preact';
|
|
3
3
|
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
4
4
|
import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
|
|
5
5
|
import { SubagentList } from '@/client/components/common/subagent-list';
|
|
@@ -17,6 +17,7 @@ import type { WorkspaceFolderData } from '@/shared/types';
|
|
|
17
17
|
import type { BatchBinding } from '@/client/components/common/batch-selection/types';
|
|
18
18
|
import { folderBatchState } from '@/client/components/common/batch-selection/folder-state';
|
|
19
19
|
import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
|
|
20
|
+
import { SessionRevealRow } from '@/client/components/mobile/mobile-session-sidebar/RevealRow';
|
|
20
21
|
|
|
21
22
|
interface MobileSessionCategoryProps {
|
|
22
23
|
folder: WorkspaceFolderData;
|
|
@@ -32,6 +33,8 @@ interface MobileSessionCategoryProps {
|
|
|
32
33
|
batch?: BatchBinding;
|
|
33
34
|
/** Start a session selection scoped to this workspace, from its `⋯` menu. */
|
|
34
35
|
onStartSessionSelection?: (folderId: number) => void;
|
|
36
|
+
/** Drop a held prompt from its row's menu. */
|
|
37
|
+
onCancelWait?: (waitId: string) => void;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function MobileSessionCategory({
|
|
@@ -46,6 +49,7 @@ export function MobileSessionCategory({
|
|
|
46
49
|
onToast,
|
|
47
50
|
batch,
|
|
48
51
|
onStartSessionSelection,
|
|
52
|
+
onCancelWait,
|
|
49
53
|
}: MobileSessionCategoryProps) {
|
|
50
54
|
const { visibleCount, showMore, showLess, canShowLess } = useShowMore();
|
|
51
55
|
const { refresh } = useSidebarData();
|
|
@@ -125,7 +129,7 @@ export function MobileSessionCategory({
|
|
|
125
129
|
<div className="mb-4">
|
|
126
130
|
<div
|
|
127
131
|
onClick={batch ? undefined : onToggleExpand}
|
|
128
|
-
className="flex items-center justify-between
|
|
132
|
+
className="flex items-center justify-between gap-2 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
|
|
129
133
|
>
|
|
130
134
|
<div className="flex items-center space-x-2 min-w-0">
|
|
131
135
|
{folderCheckbox && folderSummary ? (
|
|
@@ -138,11 +142,11 @@ export function MobileSessionCategory({
|
|
|
138
142
|
/>
|
|
139
143
|
) : (
|
|
140
144
|
<>
|
|
141
|
-
{/* The folder glyph occupies the same 16px
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
{/* The folder glyph occupies the same 16px slot the session rows give
|
|
146
|
+
their status/roster glyph, and the row carries NO leading inset of
|
|
147
|
+
its own: the drawer list's `p-3` is the gutter, so this glyph starts
|
|
148
|
+
at the same x as the New Session button above it (12px) and as the
|
|
149
|
+
search field's frame. One left edge down the whole drawer. */}
|
|
146
150
|
<span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
|
|
147
151
|
{folder.customIconUrl ? (
|
|
148
152
|
<img
|
|
@@ -227,14 +231,23 @@ export function MobileSessionCategory({
|
|
|
227
231
|
)}
|
|
228
232
|
</div>
|
|
229
233
|
|
|
230
|
-
{
|
|
234
|
+
{/* The disclosure glyph's BOX ends on the list's content edge — the
|
|
235
|
+
same x the toolbar row's last control ends on above it — and its
|
|
236
|
+
ink sits 4px inside it (`pr-1`), which is the inset a session
|
|
237
|
+
row's `⋯` / roster chevron uses. So the drawer's right column is
|
|
238
|
+
one line of INK, not just one line of boxes: measured before, the
|
|
239
|
+
folder chevron's ink ran 6px further right than every row glyph
|
|
240
|
+
beneath it. */}
|
|
241
|
+
<span className="flex items-center justify-center pr-1">
|
|
242
|
+
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
243
|
+
</span>
|
|
231
244
|
</>
|
|
232
245
|
)}
|
|
233
246
|
</div>
|
|
234
247
|
</div>
|
|
235
248
|
|
|
236
249
|
{listOpen && (
|
|
237
|
-
<div className="mt-0.5 space-y-0.5
|
|
250
|
+
<div className="mt-0.5 space-y-0.5">
|
|
238
251
|
{visibleSessions.map((session) => {
|
|
239
252
|
const sessionKey = String(session.id);
|
|
240
253
|
const isSessionActive = String(activeSessionId) === sessionKey;
|
|
@@ -272,6 +285,12 @@ export function MobileSessionCategory({
|
|
|
272
285
|
onRenameWithAi={
|
|
273
286
|
sessionKey.startsWith('new-') ? undefined : () => void handleRenameWithAi(session)
|
|
274
287
|
}
|
|
288
|
+
waitingFor={session.waitingFor ? { title: session.waitingFor.title } : undefined}
|
|
289
|
+
onCancelWait={
|
|
290
|
+
session.waitingFor
|
|
291
|
+
? () => onCancelWait?.(session.waitingFor!.waitId)
|
|
292
|
+
: undefined
|
|
293
|
+
}
|
|
275
294
|
selectable={sessionCheckboxes}
|
|
276
295
|
selected={Boolean(batch?.selected.includes(sessionKey))}
|
|
277
296
|
onToggleSelect={batch ? () => batch.toggle(sessionKey) : undefined}
|
|
@@ -283,42 +302,13 @@ export function MobileSessionCategory({
|
|
|
283
302
|
);
|
|
284
303
|
})}
|
|
285
304
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
<button
|
|
293
|
-
type="button"
|
|
294
|
-
onClick={showMore}
|
|
295
|
-
className="flex items-center min-w-0 text-left pl-2 pr-3 py-2 text-xs text-ink/60 hover:text-ink"
|
|
296
|
-
>
|
|
297
|
-
{/* `pl-2` inside the `pl-2` list is what lands this chevron's
|
|
298
|
-
centre on the folder header's icon column (16px + 8px), and
|
|
299
|
-
the label then starts at 40px — the x a row's title occupies
|
|
300
|
-
(16px slot + 8px gap). The `space-x-1.5` this replaced put
|
|
301
|
-
both 4px left of the list they belong to. */}
|
|
302
|
-
<span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">
|
|
303
|
-
<ChevronDown size={12} />
|
|
304
|
-
</span>
|
|
305
|
-
<span className="w-2 shrink-0" />
|
|
306
|
-
<span className="truncate">Show more sessions ({hiddenCount})</span>
|
|
307
|
-
</button>
|
|
308
|
-
)}
|
|
305
|
+
<SessionRevealRow
|
|
306
|
+
hiddenCount={sessionCheckboxes ? 0 : hiddenCount}
|
|
307
|
+
canShowLess={!sessionCheckboxes && canShowLess}
|
|
308
|
+
onShowMore={showMore}
|
|
309
|
+
onShowLess={showLess}
|
|
310
|
+
/>
|
|
309
311
|
|
|
310
|
-
{canShowLess && (
|
|
311
|
-
<button
|
|
312
|
-
type="button"
|
|
313
|
-
onClick={showLess}
|
|
314
|
-
className="ml-auto shrink-0 flex items-center gap-1 text-xs text-ink/60 hover:text-ink px-3 py-2"
|
|
315
|
-
>
|
|
316
|
-
<ChevronUp size={12} />
|
|
317
|
-
<span className="truncate">Show less</span>
|
|
318
|
-
</button>
|
|
319
|
-
)}
|
|
320
|
-
</div>
|
|
321
|
-
)}
|
|
322
312
|
</div>
|
|
323
313
|
)}
|
|
324
314
|
|
|
@@ -16,6 +16,8 @@ interface MobileSessionListProps {
|
|
|
16
16
|
/** Present only while a batch selection is active. */
|
|
17
17
|
batch?: BatchBinding;
|
|
18
18
|
onStartSessionSelection?: (folderId: number) => void;
|
|
19
|
+
/** Drop a held prompt from its row's menu. */
|
|
20
|
+
onCancelWait?: (waitId: string) => void;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export function MobileSessionList({
|
|
@@ -30,12 +32,17 @@ export function MobileSessionList({
|
|
|
30
32
|
onToast,
|
|
31
33
|
batch,
|
|
32
34
|
onStartSessionSelection,
|
|
35
|
+
onCancelWait,
|
|
33
36
|
}: MobileSessionListProps) {
|
|
34
37
|
// No fade wiring here: the app-wide scrollbar rule reveals this list's bar
|
|
35
38
|
// from the document's own capture-phase listener (`lib/scrollbar-activity`),
|
|
36
39
|
// so the list does not need a ref, a handler, or a re-render per scroll.
|
|
40
|
+
// `pl-3 pr-2`, not an even `p-3`: this container scrolls, so its scrollbar is
|
|
41
|
+
// taken out of the box and the same 12px on both sides LOOKS wider on the
|
|
42
|
+
// right. One step less on the right evens the optical margin back up, and it
|
|
43
|
+
// is why the rows below are aligned to 12 and not to 12 + the bar.
|
|
37
44
|
return (
|
|
38
|
-
<div className="flex-1 scrollbar-overlay-container
|
|
45
|
+
<div className="flex-1 scrollbar-overlay-container pl-3 pr-2 py-3">
|
|
39
46
|
{folders.length === 0 ? (
|
|
40
47
|
<div className="text-center py-12 text-xs text-ink/50 italic">
|
|
41
48
|
No matching sessions found
|
|
@@ -55,6 +62,7 @@ export function MobileSessionList({
|
|
|
55
62
|
onToast={onToast}
|
|
56
63
|
batch={batch}
|
|
57
64
|
onStartSessionSelection={onStartSessionSelection}
|
|
65
|
+
onCancelWait={onCancelWait}
|
|
58
66
|
/>
|
|
59
67
|
))
|
|
60
68
|
)}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The drawer's reveal / collapse row, below a workspace's sessions.
|
|
8
|
+
*
|
|
9
|
+
* Split out of `Item.tsx` (which is the folder row AND its list) so that file
|
|
10
|
+
* stays under the repo's per-file ceiling. Parity with the desktop category is
|
|
11
|
+
* the whole point: the same window, so the phone cannot offer a different
|
|
12
|
+
* boundary than the sidebar.
|
|
13
|
+
*
|
|
14
|
+
* The chevron starts at the list's own gutter — the same x as a session row's
|
|
15
|
+
* status slot and the folder header's icon — because the list carries no
|
|
16
|
+
* `pl-2` of its own any more. The label then lands on the title column
|
|
17
|
+
* (16px slot + 8px gap).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ChevronDown, ChevronUp } from 'lucide-preact';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
hiddenCount: number;
|
|
24
|
+
canShowLess: boolean;
|
|
25
|
+
onShowMore: () => void;
|
|
26
|
+
onShowLess: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function SessionRevealRow({ hiddenCount, canShowLess, onShowMore, onShowLess }: Props) {
|
|
30
|
+
if (hiddenCount <= 0 && !canShowLess) return null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex items-center gap-1">
|
|
34
|
+
{hiddenCount > 0 && (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={onShowMore}
|
|
38
|
+
className="flex items-center min-w-0 text-left py-2 pr-3 text-xs text-ink/60 hover:text-ink"
|
|
39
|
+
>
|
|
40
|
+
<span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">
|
|
41
|
+
<ChevronDown size={12} />
|
|
42
|
+
</span>
|
|
43
|
+
<span className="w-2 shrink-0" />
|
|
44
|
+
<span className="truncate">Show more sessions ({hiddenCount})</span>
|
|
45
|
+
</button>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
{canShowLess && (
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
onClick={onShowLess}
|
|
52
|
+
className="ml-auto shrink-0 flex items-center gap-1 text-xs text-ink/60 hover:text-ink pl-3 py-2"
|
|
53
|
+
>
|
|
54
|
+
<ChevronUp size={12} />
|
|
55
|
+
<span className="truncate">Show less</span>
|
|
56
|
+
</button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact';
|
|
1
|
+
import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Hourglass, Loader2, MoreHorizontal } from 'lucide-preact';
|
|
2
2
|
import type { SessionItemData } from '@/shared/types';
|
|
3
3
|
import { useInlineRename } from '@/client/hooks/ui/inline-rename';
|
|
4
4
|
import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
|
|
@@ -28,6 +28,11 @@ export interface MobileSessionRowProps {
|
|
|
28
28
|
onRenameWithAi?: () => void;
|
|
29
29
|
/** Draw a checkbox and let the row's tap toggle the selection instead of
|
|
30
30
|
* opening the session (batch mode). */
|
|
31
|
+
/** The row's prompt is held until another session finishes (see the desktop
|
|
32
|
+
* row's `waitingFor`: a pending chat has nothing else to show). */
|
|
33
|
+
waitingFor?: { title: string | null };
|
|
34
|
+
/** Drop this row's held prompt, from the row's own menu. */
|
|
35
|
+
onCancelWait?: () => void;
|
|
31
36
|
selectable?: boolean;
|
|
32
37
|
selected?: boolean;
|
|
33
38
|
onToggleSelect?: () => void;
|
|
@@ -47,6 +52,8 @@ export function MobileSessionRow({
|
|
|
47
52
|
onDelete,
|
|
48
53
|
onRename,
|
|
49
54
|
onRenameWithAi,
|
|
55
|
+
waitingFor,
|
|
56
|
+
onCancelWait,
|
|
50
57
|
selectable = false,
|
|
51
58
|
selected = false,
|
|
52
59
|
onToggleSelect,
|
|
@@ -65,7 +72,8 @@ export function MobileSessionRow({
|
|
|
65
72
|
const renaming = useSessionRenaming(session.id);
|
|
66
73
|
// One trigger for every row action; also reachable by long-press contextmenu.
|
|
67
74
|
const menu = useRowMenu();
|
|
68
|
-
const hasActions = Boolean(onRename || onArchive || onDelete);
|
|
75
|
+
const hasActions = Boolean(onRename || onArchive || onDelete || onCancelWait);
|
|
76
|
+
const waiting = Boolean(waitingFor);
|
|
69
77
|
|
|
70
78
|
// The roster toggle is the row's LAST element, so its glyph lands in the same
|
|
71
79
|
// column as the folder header's expand chevron — one vertical line of
|
|
@@ -101,6 +109,15 @@ export function MobileSessionRow({
|
|
|
101
109
|
data-testid="session-item"
|
|
102
110
|
data-session-id={String(session.id)}
|
|
103
111
|
onContextMenu={hasActions && !selectable ? menu.openAtCursor : undefined}
|
|
112
|
+
// The tint covers the WHOLE row — the `⋯` included. It is the row that
|
|
113
|
+
// is active, not the part of it left over after the triggers, and a
|
|
114
|
+
// block that stopped short of the trigger read as a half-painted
|
|
115
|
+
// selection.
|
|
116
|
+
//
|
|
117
|
+
// The gap between the trigger's ink and the block's rounded corner is
|
|
118
|
+
// `pr-1.5` INSIDE the trigger (see below), never padding on this row:
|
|
119
|
+
// a row inset would shrink the tint and pull every trailing glyph 6px
|
|
120
|
+
// left of the folder header's chevron.
|
|
104
121
|
className={`w-full rounded-lg flex items-center transition-colors ${
|
|
105
122
|
selectable && selected
|
|
106
123
|
? 'bg-ink/10 ring-1 ring-inset ring-ink/20 text-ink'
|
|
@@ -110,7 +127,7 @@ export function MobileSessionRow({
|
|
|
110
127
|
<button
|
|
111
128
|
type="button"
|
|
112
129
|
onClick={selectable ? onToggleSelect : onSelect}
|
|
113
|
-
className="flex-1 text-left
|
|
130
|
+
className="flex-1 text-left py-2 flex items-center justify-between min-w-0"
|
|
114
131
|
>
|
|
115
132
|
<div className="flex items-center min-w-0 pr-2">
|
|
116
133
|
{/* Status slot, 16px at the same x as the folder header's icon slot,
|
|
@@ -135,14 +152,30 @@ export function MobileSessionRow({
|
|
|
135
152
|
</span>
|
|
136
153
|
) : awaitingInput ? (
|
|
137
154
|
<CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
|
|
155
|
+
) : waiting ? (
|
|
156
|
+
<span role="status" aria-label={`Waiting for ${waitingFor?.title ?? 'another session'}`}>
|
|
157
|
+
<Hourglass size={13} className="text-ink/60 animate-pulse" />
|
|
158
|
+
</span>
|
|
138
159
|
) : status === 'stream' ? (
|
|
139
|
-
|
|
160
|
+
// Same label the desktop row carries, for the same reason: the
|
|
161
|
+
// phone draws the identical cue, and an unlabelled spinner is
|
|
162
|
+
// both unreachable to a screen reader and unassertable.
|
|
163
|
+
<span role="status" aria-label="Running">
|
|
164
|
+
<Loader2 size={13} className="text-ink/50 animate-spin" />
|
|
165
|
+
</span>
|
|
140
166
|
) : status ? (
|
|
141
167
|
<Check size={13} className="text-ink/50" />
|
|
142
168
|
) : null}
|
|
143
169
|
</span>
|
|
144
170
|
)}
|
|
145
|
-
<span className="
|
|
171
|
+
<span className="flex flex-col min-w-0 ml-2 leading-snug">
|
|
172
|
+
<span className="text-xs truncate">{title}</span>
|
|
173
|
+
{waiting && (
|
|
174
|
+
<span className="text-[10px] text-ink/40 truncate">
|
|
175
|
+
waiting for {waitingFor?.title ?? 'another session'}
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
</span>
|
|
146
179
|
</div>
|
|
147
180
|
|
|
148
181
|
<span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
|
|
@@ -151,10 +184,10 @@ export function MobileSessionRow({
|
|
|
151
184
|
{/* One trigger for rename / archive / delete, always visible: a touch
|
|
152
185
|
surface has no hover, and the always-on action buttons this replaced
|
|
153
186
|
consumed a third of a phone's row width.
|
|
154
|
-
`
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
187
|
+
`pl-1.5 pr-1`: the BOX still ends on the row's trailing edge (so its
|
|
188
|
+
right edge matches the folder header's chevron and the toolbar's
|
|
189
|
+
last control), while the glyph's ink sits 4px inside it — clear of
|
|
190
|
+
the tinted block's rounded corner without shrinking that block. */}
|
|
158
191
|
{hasActions && !selectable && (
|
|
159
192
|
<button
|
|
160
193
|
type="button"
|
|
@@ -163,15 +196,14 @@ export function MobileSessionRow({
|
|
|
163
196
|
aria-label="Session actions"
|
|
164
197
|
aria-haspopup="menu"
|
|
165
198
|
aria-expanded={menu.anchor !== null}
|
|
166
|
-
className=
|
|
199
|
+
className="flex-shrink-0 py-1.5 pl-1.5 pr-1 text-ink/35 hover:text-ink rounded-lg cursor-pointer"
|
|
167
200
|
>
|
|
168
201
|
<MoreHorizontal size={16} />
|
|
169
202
|
</button>
|
|
170
203
|
)}
|
|
171
204
|
|
|
172
|
-
{/* Last element, so its glyph
|
|
173
|
-
|
|
174
|
-
touch-sized tap target. */}
|
|
205
|
+
{/* Last element, so its glyph shares the trigger's column — the same
|
|
206
|
+
inset pair and the same 4px ink margin. */}
|
|
175
207
|
{showChevron && !selectable && (
|
|
176
208
|
<button
|
|
177
209
|
type="button"
|
|
@@ -179,7 +211,7 @@ export function MobileSessionRow({
|
|
|
179
211
|
title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
|
|
180
212
|
aria-expanded={isExpanded}
|
|
181
213
|
aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
|
|
182
|
-
className="flex-shrink-0
|
|
214
|
+
className="flex-shrink-0 py-1.5 pl-1.5 pr-1 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
|
|
183
215
|
>
|
|
184
216
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
185
217
|
</button>
|
|
@@ -194,6 +226,7 @@ export function MobileSessionRow({
|
|
|
194
226
|
onRenameWithAi={onRenameWithAi}
|
|
195
227
|
onArchive={onArchive}
|
|
196
228
|
onDelete={onDelete}
|
|
229
|
+
onCancelWait={onCancelWait}
|
|
197
230
|
onClose={menu.close}
|
|
198
231
|
/>
|
|
199
232
|
)}
|
|
@@ -16,12 +16,12 @@ export function MobileSessionListSkeleton() {
|
|
|
16
16
|
<div className="flex-1 p-3 space-y-4" aria-hidden="true">
|
|
17
17
|
{PLACEHOLDER_FOLDERS.map((rows, folderIndex) => (
|
|
18
18
|
<div key={folderIndex} className="space-y-2">
|
|
19
|
-
<div className="flex items-center gap-2
|
|
19
|
+
<div className="flex items-center gap-2 py-2">
|
|
20
20
|
<span className="w-4 h-4 rounded-sm animate-pulse bg-ink/10" />
|
|
21
21
|
<span className="h-3 w-24 rounded animate-pulse bg-ink/10" />
|
|
22
22
|
</div>
|
|
23
23
|
{Array.from({ length: rows }, (_, rowIndex) => (
|
|
24
|
-
<div key={rowIndex} className="flex items-center gap-2 rounded
|
|
24
|
+
<div key={rowIndex} className="flex items-center gap-2 rounded py-2">
|
|
25
25
|
<span className="w-4 h-4 rounded-full animate-pulse bg-ink/10" />
|
|
26
26
|
<span
|
|
27
27
|
className="h-3 rounded animate-pulse bg-ink/10"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The phone drawer's LEFT dock state.
|
|
8
|
+
*
|
|
9
|
+
* The desktop's left column is a dock — the session list plus whatever plugin
|
|
10
|
+
* panel the user placed beside it, switched by a vertical rail. A phone has no
|
|
11
|
+
* room for a rail, so the same catalog is switched by the chip strip the right
|
|
12
|
+
* drawer uses, and everything that decides WHICH view is on screen lives here:
|
|
13
|
+
*
|
|
14
|
+
* - the catalog is `usePanelCatalog('left')`, the dock's own list (`sessions`
|
|
15
|
+
* first, then the placed plugins in placement order), so this drawer and the
|
|
16
|
+
* desktop column cannot disagree about what lives here;
|
|
17
|
+
* - the selection is PERSISTED per session, like the right drawer's tab, because
|
|
18
|
+
* the drawer unmounts when it closes;
|
|
19
|
+
* - a stored id naming a panel that has moved back to the right resolves to the
|
|
20
|
+
* session list, while one naming nothing at all is returned as-is so the body
|
|
21
|
+
* can say WHY rather than showing a blank column.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
|
|
25
|
+
import { usePanelCatalog, useLeftDockView } from '@/client/components/workspace/plugin-panel/catalog';
|
|
26
|
+
import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
|
|
27
|
+
import { useLeftDock } from '@/client/hooks/workspace/left-dock';
|
|
28
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
29
|
+
import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
|
|
30
|
+
|
|
31
|
+
export interface MobileDockState {
|
|
32
|
+
/** The strip's chips: `sessions` and every placed panel. */
|
|
33
|
+
catalog: ResolvedPanel[];
|
|
34
|
+
/** The placed panels only — what makes the strip worth drawing. */
|
|
35
|
+
placed: ResolvedPanel[];
|
|
36
|
+
activeView: string;
|
|
37
|
+
setActiveView: (id: string) => void;
|
|
38
|
+
/** True while the session list is the view on screen. */
|
|
39
|
+
showingSessions: boolean;
|
|
40
|
+
activePanel: ResolvedPanel | undefined;
|
|
41
|
+
/** Whether the rail is on at all. */
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useMobileDock(): MobileDockState {
|
|
46
|
+
const dock = useLeftDock();
|
|
47
|
+
const catalog = usePanelCatalog('left');
|
|
48
|
+
const [stored, setStored] = useSessionState<string>('layout.mobileLeftPanel', SESSIONS_VIEW_ID);
|
|
49
|
+
const activeView = useLeftDockView(stored, dock.enabled);
|
|
50
|
+
const placed = catalog.filter((panel) => panel.id !== SESSIONS_VIEW_ID);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
catalog,
|
|
54
|
+
placed,
|
|
55
|
+
activeView,
|
|
56
|
+
setActiveView: setStored,
|
|
57
|
+
showingSessions: activeView === SESSIONS_VIEW_ID,
|
|
58
|
+
activePanel: catalog.find((panel) => panel.id === activeView),
|
|
59
|
+
enabled: dock.enabled,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The props a placed panel's body receives.
|
|
65
|
+
*
|
|
66
|
+
* The drawer draws its own title row, so `showHeader` is false — a view's own
|
|
67
|
+
* header would be a second one, the same rule the right drawer follows.
|
|
68
|
+
*/
|
|
69
|
+
export function dockBodyProps(input: {
|
|
70
|
+
sessionId: number | string | null;
|
|
71
|
+
refreshKey: number;
|
|
72
|
+
rootPath?: string;
|
|
73
|
+
onRefresh?: () => void;
|
|
74
|
+
onOpenFile?: (file: unknown) => void;
|
|
75
|
+
onClose: () => void;
|
|
76
|
+
}): PanelBodyProps {
|
|
77
|
+
return {
|
|
78
|
+
className: 'w-full h-full',
|
|
79
|
+
sessionId: input.sessionId === null ? null : String(input.sessionId),
|
|
80
|
+
enabled: true,
|
|
81
|
+
active: true,
|
|
82
|
+
refreshKey: input.refreshKey,
|
|
83
|
+
...(input.rootPath ? { rootPath: input.rootPath } : {}),
|
|
84
|
+
...(input.onRefresh ? { onRefresh: input.onRefresh } : {}),
|
|
85
|
+
...(input.onOpenFile ? { onOpenFile: input.onOpenFile } : {}),
|
|
86
|
+
onClose: input.onClose,
|
|
87
|
+
showHeader: false,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The phone's panel strip and dock body — the LEFT drawer's half of the panel
|
|
8
|
+
* system, which did not exist before.
|
|
9
|
+
*
|
|
10
|
+
* Three properties, and each was the reported symptom:
|
|
11
|
+
*
|
|
12
|
+
* - A placed plugin panel gets a CHIP in the left drawer. Before this the
|
|
13
|
+
* placement was stored, the desktop drew a button for it, and a phone had no
|
|
14
|
+
* surface at all — the panel was reachable only through the right drawer.
|
|
15
|
+
* - The chip that is ON is the one marked pressed, so the drawer says which
|
|
16
|
+
* panel it is showing.
|
|
17
|
+
* - A stored view naming a panel that no longer resolves draws the NOTICE, not
|
|
18
|
+
* a blank column, and keeps the strip so the session list is reachable.
|
|
19
|
+
*
|
|
20
|
+
* Rendered with `h()` against happy-dom, with the kit's services installed so a
|
|
21
|
+
* plugin row resolves its identity the way the real host resolves it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
25
|
+
import { Window } from 'happy-dom';
|
|
26
|
+
import { h, render } from 'preact';
|
|
27
|
+
import { act } from 'preact/test-utils';
|
|
28
|
+
import { configureUiKit } from '@kabmo-labs/kabmo-ui';
|
|
29
|
+
import { makeUiKitServices } from '@kabmo-labs/kabmo-sdk/testing';
|
|
30
|
+
import { PanelChips } from '@/client/components/mobile/panel-chips';
|
|
31
|
+
import { MobileDockBody } from '@/client/components/mobile/mobile-session-sidebar/Dock';
|
|
32
|
+
import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
|
|
33
|
+
import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
|
|
34
|
+
import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
|
|
35
|
+
|
|
36
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent'] as const;
|
|
37
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
38
|
+
|
|
39
|
+
let container: HTMLElement;
|
|
40
|
+
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
configureUiKit(makeUiKitServices());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
const win = new Window({ url: 'http://localhost' });
|
|
47
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
48
|
+
for (const key of DOM_GLOBALS) {
|
|
49
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
50
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
51
|
+
}
|
|
52
|
+
// The dock's body renders panels, and a real panel reads a realtime topic on
|
|
53
|
+
// mount. This window's origin is `localhost`, where nothing listens, so a real
|
|
54
|
+
// dial fails asynchronously — after this suite has restored the globals, which
|
|
55
|
+
// reports as an unhandled error in the NEXT file.
|
|
56
|
+
silenceRealtime();
|
|
57
|
+
container = document.createElement('div');
|
|
58
|
+
document.body.appendChild(container);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
render(null, container);
|
|
63
|
+
container.remove();
|
|
64
|
+
restoreRealtime();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
69
|
+
for (const key of DOM_GLOBALS) {
|
|
70
|
+
if (key in nativeGlobals) target[key] = nativeGlobals[key];
|
|
71
|
+
else delete target[key];
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** A catalog entry with only what the strip and the body read. */
|
|
76
|
+
function panel(id: string, overrides: Partial<ResolvedPanel> = {}): ResolvedPanel {
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
side: id === 'sessions' ? 'left' : 'left',
|
|
80
|
+
title: `${id} title`,
|
|
81
|
+
label: id,
|
|
82
|
+
name: '',
|
|
83
|
+
builtin: true,
|
|
84
|
+
requiresWorkspace: false,
|
|
85
|
+
liveWhileHidden: false,
|
|
86
|
+
minWidth: 200,
|
|
87
|
+
defaultWidth: 280,
|
|
88
|
+
defaultFraction: 0.25,
|
|
89
|
+
render: () => h('div', { 'data-rendered': id }, `body of ${id}`),
|
|
90
|
+
...overrides,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const body: PanelBodyProps = { enabled: true, active: true, refreshKey: 0 };
|
|
95
|
+
|
|
96
|
+
async function mountStrip(panels: ResolvedPanel[], activeId: string, onSelect = (_id: string) => {}) {
|
|
97
|
+
await act(async () => {
|
|
98
|
+
render(h(PanelChips, { panels, activeId, onSelect }), container);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe('PanelChips', () => {
|
|
103
|
+
test('draws one chip per panel, in the order it is given', async () => {
|
|
104
|
+
await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo', title: 'Demo — demo' })], 'sessions');
|
|
105
|
+
const labels = [...container.querySelectorAll('button')].map((el) => el.getAttribute('title'));
|
|
106
|
+
expect(labels).toEqual(['sessions title', 'Demo — demo']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('the active chip is the pressed one, and only it', async () => {
|
|
110
|
+
await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' })], 'plugin:demo:rightPanel');
|
|
111
|
+
const pressed = [...container.querySelectorAll('button')].map((el) => el.getAttribute('aria-pressed'));
|
|
112
|
+
expect(pressed).toEqual(['false', 'true']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a click hands back the panel id, which is what the drawer stores', async () => {
|
|
116
|
+
const picked: string[] = [];
|
|
117
|
+
await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' })], 'sessions', (id) => picked.push(id));
|
|
118
|
+
const chip = [...container.querySelectorAll('button')].at(-1) as HTMLButtonElement;
|
|
119
|
+
await act(async () => chip.click());
|
|
120
|
+
expect(picked).toEqual(['plugin:demo:rightPanel']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('the Source Control dot is drawn only where it is asked for', async () => {
|
|
124
|
+
// The left catalog never holds a built-in view, so the left drawer passes
|
|
125
|
+
// nothing and must not grow a dot it can never mean.
|
|
126
|
+
await mountStrip([panel('git', { title: 'Source Control', label: 'GIT' })], 'git');
|
|
127
|
+
expect(container.querySelector('[title="Uncommitted changes"]')).toBeNull();
|
|
128
|
+
render(null, container);
|
|
129
|
+
await act(async () => {
|
|
130
|
+
render(h(PanelChips, { panels: [panel('git', { title: 'Source Control', label: 'GIT' })], activeId: 'git', onSelect: () => {}, hasGitChanges: true }), container);
|
|
131
|
+
});
|
|
132
|
+
expect(container.querySelector('[title="Uncommitted changes"]')).not.toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('MobileDockBody', () => {
|
|
137
|
+
test('renders the panel it is handed', async () => {
|
|
138
|
+
await act(async () => {
|
|
139
|
+
render(h(MobileDockBody, { panelKey: 'plugin:demo:rightPanel', panel: panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' }), body }), container);
|
|
140
|
+
});
|
|
141
|
+
expect(container.querySelector('[data-rendered="plugin:demo:rightPanel"]')).not.toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a panel that does not resolve says WHY instead of drawing a blank column', async () => {
|
|
145
|
+
// No provider is installed for this key, so the notice reports "no plugin
|
|
146
|
+
// with this id is installed" — the same component the desktop dock uses.
|
|
147
|
+
await act(async () => {
|
|
148
|
+
render(h(MobileDockBody, { panelKey: 'plugin:gone:rightPanel', panel: undefined, body }), container);
|
|
149
|
+
});
|
|
150
|
+
expect(container.textContent).toContain('Plugin unavailable');
|
|
151
|
+
// The notice names the PLUGIN, not the instance key, and gives the reason.
|
|
152
|
+
expect(container.textContent).toContain('gone');
|
|
153
|
+
expect(container.textContent).toContain('no plugin with this id is installed');
|
|
154
|
+
});
|
|
155
|
+
});
|