@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The phone's panel tab strip — ONE component, drawn by both drawers.
|
|
8
|
+
*
|
|
9
|
+
* A phone has no rail: the desktop's vertical activity bar is a horizontal
|
|
10
|
+
* strip of chips here. The right drawer had this from the start and the left
|
|
11
|
+
* drawer (the session list) did not, so a plugin panel placed on the left was
|
|
12
|
+
* reachable only through the RIGHT drawer's `all` catalog — the placement
|
|
13
|
+
* existed and nothing on the left ever showed it.
|
|
14
|
+
*
|
|
15
|
+
* Both drawers render this, so the two cannot drift in label, active state or
|
|
16
|
+
* the Source Control dot. The strip is scrollable rather than wrapping: a phone
|
|
17
|
+
* drawer is one row tall, and a wrapped strip would eat the list's height.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
|
|
21
|
+
import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
|
|
22
|
+
import { PANEL_MARK_SIZE } from '@/shared/lib/workspace/right-panels';
|
|
23
|
+
|
|
24
|
+
interface PanelChipsProps {
|
|
25
|
+
panels: readonly ResolvedPanel[];
|
|
26
|
+
activeId: string;
|
|
27
|
+
onSelect: (id: string) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Draw the Source Control dot on the `git` chip. The left catalog never holds
|
|
30
|
+
* a built-in view, so only the right drawer passes this.
|
|
31
|
+
*/
|
|
32
|
+
hasGitChanges?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function PanelChips({ panels, activeId, onSelect, hasGitChanges = false }: PanelChipsProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex items-center space-x-1.5 overflow-x-auto no-scrollbar py-1">
|
|
38
|
+
{panels.map((panel) => {
|
|
39
|
+
const active = panel.id === activeId;
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
key={panel.id}
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={() => onSelect(panel.id)}
|
|
45
|
+
aria-pressed={active}
|
|
46
|
+
// The name is the panel's TITLE, never its visible label. The label
|
|
47
|
+
// only renders while the chip is active, so without this the same
|
|
48
|
+
// button is called "Resources — stats" before a click and
|
|
49
|
+
// "Resources" after it — a control that renames itself when used is
|
|
50
|
+
// unusable to a screen reader and unfindable to a spec.
|
|
51
|
+
aria-label={panel.title}
|
|
52
|
+
className={`relative flex items-center transition-all cursor-pointer ${
|
|
53
|
+
active
|
|
54
|
+
? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
|
|
55
|
+
: 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
|
|
56
|
+
}`}
|
|
57
|
+
title={panel.title}
|
|
58
|
+
>
|
|
59
|
+
{panel.builtin ? panel.icon : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
|
|
60
|
+
{active && (
|
|
61
|
+
<span className={panel.id === 'git' ? 'tracking-wide' : undefined}>{panel.label}</span>
|
|
62
|
+
)}
|
|
63
|
+
{panel.id === 'git' && hasGitChanges && (
|
|
64
|
+
<span
|
|
65
|
+
className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-info"
|
|
66
|
+
title="Uncommitted changes"
|
|
67
|
+
/>
|
|
68
|
+
)}
|
|
69
|
+
</button>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -6,7 +6,7 @@ import { PluginMark } from '@/client/components/settings/categories/panel-plugin
|
|
|
6
6
|
import { PluginReadmeModal } from '@/client/components/settings/categories/panel-plugins/ReadmeModal';
|
|
7
7
|
import { GitFreshness } from '@/client/components/settings/categories/panel-plugins/GitFreshness';
|
|
8
8
|
import type { PluginSlots, SlotName } from '@/client/lib/plugins/slots';
|
|
9
|
-
import {
|
|
9
|
+
import { pluginEnablementKey } from '@/shared/lib/workspace/panel-ids';
|
|
10
10
|
|
|
11
11
|
/** The four slots a plugin may fill, in the order a card lists them. */
|
|
12
12
|
const SLOT_ORDER: readonly SlotName[] = ['rightPanel', 'panel', 'headerPanel', 'settingsSection'];
|
|
@@ -74,7 +74,7 @@ export function InstalledList({
|
|
|
74
74
|
// The ENABLEMENT key is the panel key, not the plugin id: it is the
|
|
75
75
|
// id the client filters its panel catalog by, so a switch that wrote
|
|
76
76
|
// the bare id would report success while the panel stayed hidden.
|
|
77
|
-
const panelKey =
|
|
77
|
+
const panelKey = pluginEnablementKey(plugin.pluginId);
|
|
78
78
|
const toggling = busy === `enable:${panelKey}` || busy === `disable:${panelKey}`;
|
|
79
79
|
const contributed = entry ? SLOT_ORDER.filter((slot) => entry.components[slot]) : [];
|
|
80
80
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Panel placement: which destination each contributed panel is drawn on.
|
|
8
|
+
*
|
|
9
|
+
* The section was "Left dock", and its one question was "is this plugin's panel
|
|
10
|
+
* on the left or the right?" — which could not be answered for a plugin that
|
|
11
|
+
* contributes TWO panels. `marketplace/plugins/session-info` ships a `rightPanel`
|
|
12
|
+
* and a `panel`, and with one row per plugin, moving its right panel onto the
|
|
13
|
+
* left dock would have had no way to say what happened to the other one.
|
|
14
|
+
*
|
|
15
|
+
* So the row is per PANEL INSTANCE, and the row's destinations come from the
|
|
16
|
+
* SLOT rather than from the plugin:
|
|
17
|
+
*
|
|
18
|
+
* - a `rightPanel` is a view — it rides the left dock or the right column;
|
|
19
|
+
* - a `panel` is a takeover — it rides the editor column, and only that, because
|
|
20
|
+
* a takeover on a rail would be a button that opens a column it is not on.
|
|
21
|
+
*
|
|
22
|
+
* The dock's switch stays here as well, because a placement on the left is
|
|
23
|
+
* invisible without the rail: placing a panel turns it on (`setPanelSide`), and
|
|
24
|
+
* the pane says so rather than leaving the user to discover it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ArrowLeft, ArrowRight, Columns2, Loader2, PanelLeft, PanelRight } from 'lucide-preact';
|
|
28
|
+
import type { PanelPluginStatus } from '@/shared/types';
|
|
29
|
+
import { pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
|
|
30
|
+
import { SESSIONS_VIEW_ID, panelSideOf, ridesSides, type PanelSide } from '@/shared/lib/workspace/left-dock';
|
|
31
|
+
import type { PluginSlots } from '@/client/lib/plugins/slots';
|
|
32
|
+
import type { LeftDockHandle } from '@/client/hooks/workspace/left-dock';
|
|
33
|
+
|
|
34
|
+
interface LeftDockSettingsProps {
|
|
35
|
+
dock: LeftDockHandle;
|
|
36
|
+
plugins: PanelPluginStatus[];
|
|
37
|
+
slots: Map<string, PluginSlots>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One contributed panel, with the row's own facts resolved. */
|
|
41
|
+
interface PlaceablePanel {
|
|
42
|
+
plugin: PanelPluginStatus;
|
|
43
|
+
key: string;
|
|
44
|
+
/** The slot it was registered for, which decides where it may go. */
|
|
45
|
+
slot: 'rightPanel' | 'panel';
|
|
46
|
+
/** The destination it is on right now. */
|
|
47
|
+
side: PanelSide;
|
|
48
|
+
title: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The status line's wording, which is a sentence rather than a button label. */
|
|
52
|
+
const SIDE_STATUS: Record<PanelSide, string> = {
|
|
53
|
+
left: 'Left dock',
|
|
54
|
+
right: 'Right column',
|
|
55
|
+
editor: 'Editor column',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** What each destination is called, in a button and in a row's status line. */
|
|
59
|
+
const SIDE_LABEL: Record<PanelSide, string> = {
|
|
60
|
+
left: 'left dock',
|
|
61
|
+
right: 'right column',
|
|
62
|
+
editor: 'editor column',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const SIDE_GLYPH: Record<PanelSide, typeof PanelLeft> = {
|
|
66
|
+
left: PanelLeft,
|
|
67
|
+
right: PanelRight,
|
|
68
|
+
editor: Columns2,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The destinations one panel may be moved to.
|
|
73
|
+
*
|
|
74
|
+
* ONE button per destination rather than a toggle: there are up to three, so a
|
|
75
|
+
* toggle cannot express the choice, and a button carrying the CURRENT position
|
|
76
|
+
* would read as a state ("Left") on a control that is about to move it. Each
|
|
77
|
+
* button therefore names its destination, and the one the panel is already on is
|
|
78
|
+
* disabled — a control that can only go where it already is does nothing.
|
|
79
|
+
*/
|
|
80
|
+
function DestinationGroup({
|
|
81
|
+
name,
|
|
82
|
+
destinations,
|
|
83
|
+
current,
|
|
84
|
+
onMove,
|
|
85
|
+
}: {
|
|
86
|
+
name: string;
|
|
87
|
+
destinations: readonly PanelSide[];
|
|
88
|
+
current: PanelSide;
|
|
89
|
+
onMove: (side: PanelSide) => void;
|
|
90
|
+
}) {
|
|
91
|
+
if (destinations.length < 2) return null;
|
|
92
|
+
return (
|
|
93
|
+
<span className="flex items-center gap-1 flex-shrink-0">
|
|
94
|
+
{destinations.map((side) => {
|
|
95
|
+
const Glyph = SIDE_GLYPH[side];
|
|
96
|
+
const here = side === current;
|
|
97
|
+
return (
|
|
98
|
+
<button
|
|
99
|
+
key={side}
|
|
100
|
+
type="button"
|
|
101
|
+
disabled={here}
|
|
102
|
+
onClick={() => onMove(side)}
|
|
103
|
+
title={here ? `${name} is on the ${SIDE_LABEL[side]}` : `Move ${name} to the ${SIDE_LABEL[side]}`}
|
|
104
|
+
aria-label={here ? `${name} is on the ${SIDE_LABEL[side]}` : `Move ${name} to the ${SIDE_LABEL[side]}`}
|
|
105
|
+
aria-pressed={here}
|
|
106
|
+
className={`flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded border flex-shrink-0 transition-colors ${
|
|
107
|
+
here
|
|
108
|
+
? 'border-ink/30 bg-ink/10 text-ink/50 cursor-default'
|
|
109
|
+
: 'border-ink/25 text-ink/80 hover:bg-ink/5 cursor-pointer'
|
|
110
|
+
}`}
|
|
111
|
+
>
|
|
112
|
+
<Glyph size={11} />
|
|
113
|
+
{side === 'left' ? 'Left' : side === 'right' ? 'Right' : 'Editor'}
|
|
114
|
+
</button>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</span>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function LeftDockSettings({ dock, plugins, slots }: LeftDockSettingsProps) {
|
|
122
|
+
// ONE row per contributed panel, not per plugin: a plugin may register both a
|
|
123
|
+
// view and a takeover, and they are placed independently.
|
|
124
|
+
const placeable: PlaceablePanel[] = [];
|
|
125
|
+
for (const plugin of plugins) {
|
|
126
|
+
const entry = slots.get(plugin.pluginId);
|
|
127
|
+
if (!plugin.enabled || !entry) continue;
|
|
128
|
+
for (const slot of ['rightPanel', 'panel'] as const) {
|
|
129
|
+
if (!entry.components[slot]) continue;
|
|
130
|
+
const key = pluginPanelKey(plugin.pluginId, slot);
|
|
131
|
+
placeable.push({
|
|
132
|
+
plugin,
|
|
133
|
+
key,
|
|
134
|
+
slot,
|
|
135
|
+
side: panelSideOf(key, dock),
|
|
136
|
+
title: entry.titles[slot] ?? plugin.name,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const loading = plugins.filter((plugin) => plugin.enabled && !slots.get(plugin.pluginId)).length;
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<section>
|
|
144
|
+
<header className="flex items-center gap-2 mb-2">
|
|
145
|
+
<h3 className="text-sm font-medium text-ink">Panel placement</h3>
|
|
146
|
+
<span className="ml-auto text-[11px] text-ink/50">
|
|
147
|
+
{dock.enabled ? `${dock.panels.length} on the rail` : 'rail off'}
|
|
148
|
+
</span>
|
|
149
|
+
</header>
|
|
150
|
+
|
|
151
|
+
<p className="text-[11px] text-ink/50 mb-2">
|
|
152
|
+
The left column always holds the session list. Turn its rail on to add views beside it — a
|
|
153
|
+
panel placed there gets its own button and its own width, and leaves the right column. Each
|
|
154
|
+
contributed panel is placed on its own: a plugin that ships two panels gets two rows.
|
|
155
|
+
</p>
|
|
156
|
+
|
|
157
|
+
{!dock.enabled ? (
|
|
158
|
+
<p className="text-[11px] text-ink/45 mb-2">
|
|
159
|
+
The rail is off. Placing a panel on the left turns it on, and you can switch it back off
|
|
160
|
+
here at any time.
|
|
161
|
+
</p>
|
|
162
|
+
) : null}
|
|
163
|
+
|
|
164
|
+
<div className="flex items-center gap-2.5 border border-ink/10 rounded-lg px-2.5 py-2 mb-2">
|
|
165
|
+
<span className="min-w-0 flex-1">
|
|
166
|
+
<span className="block text-xs text-ink">Show the left activity bar</span>
|
|
167
|
+
<span className="block text-[10px] text-ink/40">
|
|
168
|
+
Off by default. With it off the column shows only its active view.
|
|
169
|
+
</span>
|
|
170
|
+
</span>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
role="switch"
|
|
174
|
+
aria-checked={dock.enabled}
|
|
175
|
+
aria-label={dock.enabled ? 'Hide the left activity bar' : 'Show the left activity bar'}
|
|
176
|
+
onClick={() => dock.setEnabled(!dock.enabled)}
|
|
177
|
+
className={`relative w-8 h-4 rounded-full transition-colors flex-shrink-0 ${
|
|
178
|
+
dock.enabled ? 'bg-ink/70' : 'bg-ink/20'
|
|
179
|
+
}`}
|
|
180
|
+
>
|
|
181
|
+
<span
|
|
182
|
+
className={`absolute top-0.5 w-3 h-3 rounded-full bg-canvas transition-all ${
|
|
183
|
+
dock.enabled ? 'left-[18px]' : 'left-0.5'
|
|
184
|
+
}`}
|
|
185
|
+
/>
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{placeable.length === 0 ? (
|
|
190
|
+
<p className="text-[11px] text-ink/45">
|
|
191
|
+
{loading > 0
|
|
192
|
+
? `Loading ${loading} plugin ${loading === 1 ? 'bundle' : 'bundles'}…`
|
|
193
|
+
: 'No installed plugin contributes a panel yet. Install one from the store above and it appears here.'}
|
|
194
|
+
</p>
|
|
195
|
+
) : (
|
|
196
|
+
<div className="grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(240px,1fr))]">
|
|
197
|
+
<div className="flex flex-col gap-1.5 border border-ink/10 rounded-lg px-2.5 py-2 bg-ink/[0.02]">
|
|
198
|
+
<span className="min-w-0">
|
|
199
|
+
<span className="block text-xs text-ink">Sessions</span>
|
|
200
|
+
<span className="block text-[10px] text-ink/40">The dock's own view — always here.</span>
|
|
201
|
+
</span>
|
|
202
|
+
<span className="text-[10px] font-mono text-ink/40 self-start">{SESSIONS_VIEW_ID}</span>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{placeable.map(({ plugin, key, slot, side, title }) => {
|
|
206
|
+
const destinations = ridesSides(slot);
|
|
207
|
+
// TWO rows, not one. The identity block and the destination buttons
|
|
208
|
+
// shared a line, and two buttons with glyphs and labels (`Left`
|
|
209
|
+
// `Right`) took the width the title needed — so the title was
|
|
210
|
+
// truncated inside a card that had room for it, at the very column
|
|
211
|
+
// width this grid produces (240px). Stacked, the title gets the full
|
|
212
|
+
// card and the buttons get their own row.
|
|
213
|
+
return (
|
|
214
|
+
<div key={key} className="flex flex-col gap-1.5 border border-ink/10 rounded-lg px-2.5 py-2">
|
|
215
|
+
<span className="min-w-0">
|
|
216
|
+
<span className="block text-xs text-ink" title={`${title} — ${plugin.name}`}>
|
|
217
|
+
{title}
|
|
218
|
+
<span className="text-ink/40"> · {plugin.name}</span>
|
|
219
|
+
</span>
|
|
220
|
+
<span className="block text-[10px] text-ink/40">
|
|
221
|
+
{SIDE_STATUS[side]}
|
|
222
|
+
{slot === 'panel' ? ' · takeover' : ' · view'}
|
|
223
|
+
{side === 'left' && !dock.enabled ? ' · rail off' : ''}
|
|
224
|
+
</span>
|
|
225
|
+
</span>
|
|
226
|
+
{/* A takeover has one destination, so it gets a STATUS rather
|
|
227
|
+
than a control: a button that can only go where it already
|
|
228
|
+
is would be a control that does nothing. */}
|
|
229
|
+
{destinations.length > 1 ? (
|
|
230
|
+
<DestinationGroup
|
|
231
|
+
name={title}
|
|
232
|
+
destinations={destinations}
|
|
233
|
+
current={side}
|
|
234
|
+
onMove={(next) => dock.setSide(key, next)}
|
|
235
|
+
/>
|
|
236
|
+
) : (
|
|
237
|
+
<span
|
|
238
|
+
className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded border border-ink/10 text-ink/50 self-start"
|
|
239
|
+
title={`${title} draws the editor column — its slot has no other destination`}
|
|
240
|
+
>
|
|
241
|
+
<ArrowLeft size={11} className="opacity-40" />
|
|
242
|
+
Fixed
|
|
243
|
+
</span>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
})}
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{loading > 0 && placeable.length > 0 ? (
|
|
252
|
+
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-ink/45">
|
|
253
|
+
<Loader2 size={11} className="animate-spin" />
|
|
254
|
+
{loading} plugin {loading === 1 ? 'bundle is' : 'bundles are'} still loading.
|
|
255
|
+
</p>
|
|
256
|
+
) : null}
|
|
257
|
+
|
|
258
|
+
<p className="mt-2 flex items-start gap-1.5 text-[11px] text-ink/40">
|
|
259
|
+
<ArrowRight size={11} className="mt-0.5 flex-shrink-0 opacity-60" />
|
|
260
|
+
<span>
|
|
261
|
+
A takeover draws the editor column whole, so its switch is the editor panel's own rail
|
|
262
|
+
(Files · your panels). A view rides a rail and gets one button on the column it is placed
|
|
263
|
+
on.
|
|
264
|
+
</span>
|
|
265
|
+
</p>
|
|
266
|
+
</section>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -10,6 +10,8 @@ import { InstallForm } from '@/client/components/settings/categories/panel-plugi
|
|
|
10
10
|
import { AvailableList } from '@/client/components/settings/categories/panel-plugins/AvailableList';
|
|
11
11
|
import { InstalledList } from '@/client/components/settings/categories/panel-plugins/InstalledList';
|
|
12
12
|
import { BuiltinList } from '@/client/components/settings/categories/panel-plugins/BuiltinList';
|
|
13
|
+
import { LeftDockSettings } from '@/client/components/settings/categories/panel-plugins/LeftDockSettings';
|
|
14
|
+
import { useLeftDock } from '@/client/hooks/workspace/left-dock';
|
|
13
15
|
import { PluginToolbar } from '@/client/components/settings/categories/panel-plugins/Toolbar';
|
|
14
16
|
import { SyncBlockedNotice } from '@/client/components/settings/categories/panel-plugins/SyncBlockedNotice';
|
|
15
17
|
|
|
@@ -85,6 +87,7 @@ export function PanelPluginsSection() {
|
|
|
85
87
|
usePanelRegistry();
|
|
86
88
|
const { slots, failures } = usePanelSlots();
|
|
87
89
|
const actions = usePanelPluginActions();
|
|
90
|
+
const dock = useLeftDock();
|
|
88
91
|
|
|
89
92
|
const refresh = () => void refreshPanelRegistry();
|
|
90
93
|
|
|
@@ -137,6 +140,11 @@ export function PanelPluginsSection() {
|
|
|
137
140
|
they are listed whether or not the registry read settled. */}
|
|
138
141
|
<BuiltinList disabled={disabledPanels} busy={actions.busy} onSetEnabled={actions.setEnabled} />
|
|
139
142
|
|
|
143
|
+
{/* After the built-ins, because it places the panels those rows switch on:
|
|
144
|
+
a placement for a panel that is switched off would be a row for
|
|
145
|
+
nothing. */}
|
|
146
|
+
<LeftDockSettings dock={dock} plugins={plugins} slots={slots} />
|
|
147
|
+
|
|
140
148
|
{ready && available.length === 0 && plugins.length === 0 && errors.length === 0 ? (
|
|
141
149
|
<div className="text-xs text-ink/60 space-y-1.5">
|
|
142
150
|
<p>No panel plugins installed.</p>
|
|
@@ -10,6 +10,12 @@ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter
|
|
|
10
10
|
import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
|
|
11
11
|
import { resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
|
|
12
12
|
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
13
|
+
import {
|
|
14
|
+
WaitingDependencyPicker,
|
|
15
|
+
waitingCandidates,
|
|
16
|
+
type WaitingCandidate,
|
|
17
|
+
} from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
18
|
+
import { Hourglass } from 'lucide-preact';
|
|
13
19
|
|
|
14
20
|
interface EmptyWorkspacePromptProps {
|
|
15
21
|
className?: string;
|
|
@@ -55,6 +61,17 @@ interface EmptyWorkspacePromptProps {
|
|
|
55
61
|
onUndo?: (id: string, content?: string) => void;
|
|
56
62
|
onRetry?: (id: string) => void;
|
|
57
63
|
onNewChat?: (content: string, attachments?: Attachment[]) => void;
|
|
64
|
+
/** Server-tracked `stream` ids: the only sessions the wait may name. */
|
|
65
|
+
streamStatusMap?: Record<string, string | undefined>;
|
|
66
|
+
/** A name for a streaming session the sidebar has not scanned yet. */
|
|
67
|
+
titleForSession?: (id: string) => string | undefined;
|
|
68
|
+
/** The "wait for session" pick, persisted per chat. */
|
|
69
|
+
waitingFor?: WaitingCandidate | null;
|
|
70
|
+
onWaitingForChange?: (candidate: WaitingCandidate | null) => void;
|
|
71
|
+
/** A held prompt for THIS chat: the composer is disabled until it is released
|
|
72
|
+
* or cancelled, because there is no session to send a second prompt to. */
|
|
73
|
+
heldWait?: { title: string | null } | null;
|
|
74
|
+
onCancelWait?: () => void;
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
export function EmptyWorkspacePrompt({
|
|
@@ -89,6 +106,12 @@ export function EmptyWorkspacePrompt({
|
|
|
89
106
|
onUndo,
|
|
90
107
|
onRetry,
|
|
91
108
|
onNewChat,
|
|
109
|
+
streamStatusMap = {},
|
|
110
|
+
titleForSession,
|
|
111
|
+
waitingFor = null,
|
|
112
|
+
onWaitingForChange,
|
|
113
|
+
heldWait = null,
|
|
114
|
+
onCancelWait,
|
|
92
115
|
}: EmptyWorkspacePromptProps) {
|
|
93
116
|
const [showWorkspace, setShowWorkspace] = useState(false);
|
|
94
117
|
const workspaceRef = useRef<HTMLDivElement>(null);
|
|
@@ -99,6 +122,13 @@ export function EmptyWorkspacePrompt({
|
|
|
99
122
|
return folders?.find(f => f.id === selectedFolderId);
|
|
100
123
|
}, [folders, selectedFolderId]);
|
|
101
124
|
|
|
125
|
+
// Sessions the wait may name: the ones with a run in flight, from the same
|
|
126
|
+
// server-tracked map the sidebar's spinner is drawn from.
|
|
127
|
+
const candidates = useMemo(
|
|
128
|
+
() => waitingCandidates(folders, streamStatusMap, null, titleForSession),
|
|
129
|
+
[folders, streamStatusMap, titleForSession],
|
|
130
|
+
);
|
|
131
|
+
|
|
102
132
|
// Run footers are boundary rows: emitted after the run's last row, so a
|
|
103
133
|
// trailing notice never separates the footer from the next user message.
|
|
104
134
|
const { streamingIdx, footers } = useMemo(
|
|
@@ -128,6 +158,7 @@ export function EmptyWorkspacePrompt({
|
|
|
128
158
|
background). Hidden once a chat has been sent — the session now has
|
|
129
159
|
a target workspace, so the composer is the only focus. */}
|
|
130
160
|
{messages.length === 0 && (
|
|
161
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
131
162
|
<div className="relative shrink-0" ref={workspaceRef}>
|
|
132
163
|
<button
|
|
133
164
|
type="button"
|
|
@@ -172,6 +203,21 @@ export function EmptyWorkspacePrompt({
|
|
|
172
203
|
</div>
|
|
173
204
|
</div>
|
|
174
205
|
)}
|
|
206
|
+
</div>
|
|
207
|
+
{/* The wait picker sits BESIDE the workspace picker, because both
|
|
208
|
+
answer the same question about the chat that has not started:
|
|
209
|
+
where it runs, and what it waits for. It only appears while there
|
|
210
|
+
is something to wait for (or a pick already made). */}
|
|
211
|
+
{onWaitingForChange && (waitingFor || candidates.length > 0) && (
|
|
212
|
+
<WaitingDependencyPicker
|
|
213
|
+
folders={folders}
|
|
214
|
+
streamStatusMap={streamStatusMap}
|
|
215
|
+
sessionId={null}
|
|
216
|
+
titleFor={titleForSession}
|
|
217
|
+
value={waitingFor}
|
|
218
|
+
onChange={onWaitingForChange}
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
175
221
|
</div>
|
|
176
222
|
)}
|
|
177
223
|
|
|
@@ -197,6 +243,7 @@ export function EmptyWorkspacePrompt({
|
|
|
197
243
|
isStreaming={isLoading}
|
|
198
244
|
onUndo={onUndo}
|
|
199
245
|
onNewChat={onNewChat}
|
|
246
|
+
isMobile={variant === 'mobile'}
|
|
200
247
|
className={isAiFragment ? 'mt-1' : 'mt-8'}
|
|
201
248
|
/>
|
|
202
249
|
{footer && (
|
|
@@ -232,6 +279,27 @@ export function EmptyWorkspacePrompt({
|
|
|
232
279
|
providerNames={providerNames}
|
|
233
280
|
/>
|
|
234
281
|
)}
|
|
282
|
+
{/* A held prompt owns this chat until it is released: there is no
|
|
283
|
+
session to send a second prompt to, and a composer that looked live
|
|
284
|
+
would accept one that could not be delivered. */}
|
|
285
|
+
{heldWait && (
|
|
286
|
+
<div className="flex items-center gap-2 rounded-lg border border-ink/15 bg-ink/[0.03] px-3 py-2 text-xs text-ink/70">
|
|
287
|
+
<Hourglass size={12} className="shrink-0 animate-pulse text-ink/60" />
|
|
288
|
+
<span className="min-w-0 flex-1 truncate">
|
|
289
|
+
Waiting for {heldWait.title ?? 'another session'} to finish before this prompt is sent.
|
|
290
|
+
</span>
|
|
291
|
+
{onCancelWait && (
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
data-testid="cancel-held-prompt"
|
|
295
|
+
onClick={onCancelWait}
|
|
296
|
+
className="shrink-0 rounded border border-ink/20 px-2 py-0.5 text-[11px] text-ink/70 hover:bg-ink/5 hover:text-ink cursor-pointer"
|
|
297
|
+
>
|
|
298
|
+
Cancel
|
|
299
|
+
</button>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
235
303
|
<ChatInput
|
|
236
304
|
value={inputValue}
|
|
237
305
|
onChange={setInputValue}
|
|
@@ -240,7 +308,7 @@ export function EmptyWorkspacePrompt({
|
|
|
240
308
|
onAttachmentsChange={setInputAttachments}
|
|
241
309
|
onSend={onSend}
|
|
242
310
|
isGenerating={isGenerating}
|
|
243
|
-
disabled={!selectedFolderId}
|
|
311
|
+
disabled={!selectedFolderId || Boolean(heldWait)}
|
|
244
312
|
className="w-full shrink-0"
|
|
245
313
|
appSettings={appSettings}
|
|
246
314
|
onThinkingLevelChange={onThinkingLevelChange}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where a framed transcript block's edge lands, per layout.
|
|
8
|
+
*
|
|
9
|
+
* The phone's gutter is the timeline container's own `px-3` — the same 12px
|
|
10
|
+
* the composer dock uses — so a block that adds `mx-3` on top of it sits 12px
|
|
11
|
+
* inside the composer's card. That is what this pins: the same tree, mounted
|
|
12
|
+
* twice, and the only difference is `isMobile`.
|
|
13
|
+
*
|
|
14
|
+
* Asserted through the rendered DOM rather than the helper, because the bug
|
|
15
|
+
* was a call site that never passed the flag: `transcriptGutter` can be
|
|
16
|
+
* perfect and a `ChatMessageItem` that drops `isMobile` still renders `mx-3`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { Window } from 'happy-dom';
|
|
21
|
+
import { h, render } from 'preact';
|
|
22
|
+
import { act } from 'preact/test-utils';
|
|
23
|
+
import { ChatMessageItem } from '@/client/components/workspace/chat-timeline/MessageItem';
|
|
24
|
+
import { SessionStateProvider } from '@/client/components/common/session-state-provider';
|
|
25
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
26
|
+
|
|
27
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
28
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
29
|
+
|
|
30
|
+
beforeAll(() => {
|
|
31
|
+
const win = new Window({ url: 'http://localhost' });
|
|
32
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
33
|
+
for (const key of DOM_GLOBALS) {
|
|
34
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
35
|
+
target[key] = (win as unknown as Record<string, unknown>)[key];
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
41
|
+
for (const key of DOM_GLOBALS) {
|
|
42
|
+
if (nativeGlobals[key] === undefined) delete target[key];
|
|
43
|
+
else target[key] = nativeGlobals[key];
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let container: HTMLElement | null = null;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
container?.remove();
|
|
51
|
+
container = null;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** A user row, and an AI row carrying one tool call — the two framed blocks. */
|
|
55
|
+
const userRow: ChatMessageData = { id: 'u1', role: 'user', content: 'hello' };
|
|
56
|
+
const toolRow: ChatMessageData = {
|
|
57
|
+
id: 'a1',
|
|
58
|
+
role: 'ai',
|
|
59
|
+
content: 'done',
|
|
60
|
+
toolCalls: [{
|
|
61
|
+
id: 't1',
|
|
62
|
+
type: 'bash',
|
|
63
|
+
title: 'Bash',
|
|
64
|
+
command: 'echo hi',
|
|
65
|
+
output: 'hi',
|
|
66
|
+
status: 'success',
|
|
67
|
+
}],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function mount(msg: ChatMessageData, isMobile: boolean): Promise<HTMLElement> {
|
|
71
|
+
const host = document.createElement('div');
|
|
72
|
+
document.body.appendChild(host);
|
|
73
|
+
await act(async () => {
|
|
74
|
+
render(
|
|
75
|
+
h(SessionStateProvider, {
|
|
76
|
+
sessionId: 'gutter-test',
|
|
77
|
+
children: h(ChatMessageItem, { msg, isMobile }),
|
|
78
|
+
}),
|
|
79
|
+
host,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
container = host;
|
|
83
|
+
return host;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The user bubble: the framed block a user row draws. */
|
|
87
|
+
function bubble(host: HTMLElement): HTMLElement {
|
|
88
|
+
return host.querySelector('div.bg-paper.rounded-xl') as HTMLElement;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('transcript block gutter', () => {
|
|
92
|
+
test('a phone draws the user bubble at the container gutter, with no extra inset', async () => {
|
|
93
|
+
const host = await mount(userRow, true);
|
|
94
|
+
const box = bubble(host);
|
|
95
|
+
expect(box.className).not.toContain('mx-3');
|
|
96
|
+
// And its padding is the one that lands text on the 24px column.
|
|
97
|
+
expect(box.className).toContain('p-3');
|
|
98
|
+
expect(box.className).not.toContain('p-3.5');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('desktop keeps the inset frame and the roomier padding', async () => {
|
|
102
|
+
const host = await mount(userRow, false);
|
|
103
|
+
const box = bubble(host);
|
|
104
|
+
expect(box.className).toContain('mx-3');
|
|
105
|
+
expect(box.className).toContain('p-3.5');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the tool section follows the same rule — and is reachable by its own marker', async () => {
|
|
109
|
+
const phone = await mount(toolRow, true);
|
|
110
|
+
const phoneSection = phone.querySelector('[data-tool-section]') as HTMLElement;
|
|
111
|
+
expect(phoneSection).not.toBeNull();
|
|
112
|
+
expect(phoneSection.className).not.toContain('mx-3');
|
|
113
|
+
|
|
114
|
+
const desktop = await mount(toolRow, false);
|
|
115
|
+
const desktopSection = desktop.querySelector('[data-tool-section]') as HTMLElement;
|
|
116
|
+
expect(desktopSection.className).toContain('mx-3');
|
|
117
|
+
});
|
|
118
|
+
});
|