@kabmo-labs/kabmo 0.0.1 → 0.2.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/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `K` of the wordmark: the app icon itself.
|
|
3
|
+
*
|
|
4
|
+
* The wordmark used to spell its leading letter in the UI font and colour it
|
|
5
|
+
* orange, which made the K a coloured character rather than the brand mark.
|
|
6
|
+
* This is `public/icon.svg` — the same rounded green tile, the same three
|
|
7
|
+
* strokes at the same 7/64 weight, the same orange upper arm — drawn inline so
|
|
8
|
+
* the word starts with the logo.
|
|
9
|
+
*
|
|
10
|
+
* **The tile is sized to the cap band, not to the line box.** The icon's ink is
|
|
11
|
+
* 41 of its 64 units tall (0.64), so a tile of `1.5625 × cap` puts the K's own
|
|
12
|
+
* height exactly on the cap height — the letter does not shrink when it gains
|
|
13
|
+
* its plate. That makes the plate taller than the cap band, and a plate whose
|
|
14
|
+
* bottom sits on the baseline would lift the K half that difference; the
|
|
15
|
+
* `vertical-align` below shifts the whole box down by the overhang so the ink's
|
|
16
|
+
* bottom lands on the baseline and its top on the cap line. Both numbers are
|
|
17
|
+
* derived from the same two constants (`1.5625 = 1 / 0.64`, overhang
|
|
18
|
+
* `(1.5625 − 1) / 2`), so they cannot drift apart.
|
|
19
|
+
*
|
|
20
|
+
* Both are expressed in `cap`, which is the font's own cap height — the mark
|
|
21
|
+
* then aligns on every face the stack resolves to, and not just on the one this
|
|
22
|
+
* was measured against. `0.7046em` is that cap height on the current stack
|
|
23
|
+
* (10.5688px at 15px, 11.2734 at 16, 16.9102 at 24 — 0.70459 each) and the
|
|
24
|
+
* class carries it as the fallback for engines without the unit; an unsupported
|
|
25
|
+
* inline declaration is dropped, so the class applies there.
|
|
26
|
+
*
|
|
27
|
+
* `inline-block` is not decoration: Tailwind's preflight makes every `svg`
|
|
28
|
+
* `display: block`, and a block box inside the wordmark's inline flow breaks the
|
|
29
|
+
* word onto its own line.
|
|
30
|
+
*
|
|
31
|
+
* **The `a` is tucked into the plate by `0.28em` of negative right margin**, so
|
|
32
|
+
* the word reads as a lockup rather than a plate with a word after it. The limit
|
|
33
|
+
* is the icon's lower arm, which ends 2.1px inside the plate at 15px: measured
|
|
34
|
+
* at five depths, `-0.20em` left 2.12px of clearance to that stroke and
|
|
35
|
+
* `-0.28em` leaves 0.93px — the deepest value that still keeps the letter off
|
|
36
|
+
* the arm's round cap, while putting its ink 3.8px inside a 16.5px plate.
|
|
37
|
+
*
|
|
38
|
+
* The tile and cream are `--color-brand-tile` / `--color-brand-cream`, not theme
|
|
39
|
+
* tokens: the icon is the same mark in every palette, and on a dark canvas a
|
|
40
|
+
* themed plate would be a hole rather than a plate. They are set as inline
|
|
41
|
+
* `style` declarations rather than presentation attributes, because a `var()`
|
|
42
|
+
* in a presentation attribute is not resolved (measured: the rect computed to
|
|
43
|
+
* black and the stem to `none`).
|
|
44
|
+
*/
|
|
45
|
+
export function BrandMark({ className = '' }: { className?: string }) {
|
|
46
|
+
return (
|
|
47
|
+
<svg
|
|
48
|
+
viewBox="0 0 64 64"
|
|
49
|
+
className={`inline-block h-[1.1009em] w-auto flex-none [vertical-align:-0.1982em] ${className}`}
|
|
50
|
+
style={{ height: '1.5625cap', verticalAlign: '-0.28125cap', marginRight: '-0.28em' }}
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
focusable="false"
|
|
53
|
+
>
|
|
54
|
+
<rect width="64" height="64" rx="14" style={{ fill: 'var(--color-brand-tile)' }} />
|
|
55
|
+
<g fill="none" strokeWidth={7} strokeLinecap="round" strokeLinejoin="round">
|
|
56
|
+
<path d="M20 15v34" style={{ stroke: 'var(--color-brand-cream)' }} />
|
|
57
|
+
<path d="M20 32 42 15" style={{ stroke: 'var(--color-brand)' }} />
|
|
58
|
+
<path d="M20 32 42 49" style={{ stroke: 'var(--color-brand-cream)' }} />
|
|
59
|
+
</g>
|
|
60
|
+
</svg>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -70,24 +70,46 @@ describe('BrandWordmark', () => {
|
|
|
70
70
|
expect([...runs.keys()]).toEqual(['K', 'ab', 'mo']);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
test('each run carries its own colour
|
|
73
|
+
test('each run carries its own colour', () => {
|
|
74
74
|
const runs = runsOf(mount());
|
|
75
|
-
// The K
|
|
76
|
-
//
|
|
77
|
-
|
|
75
|
+
// The K is the app icon, so its colours live on the SVG's own paint: the
|
|
76
|
+
// green plate, the cream strokes and the brand's orange upper arm.
|
|
77
|
+
const mark = runs.get('K')?.parentElement?.querySelector('svg');
|
|
78
|
+
expect(mark).toBeTruthy();
|
|
79
|
+
expect(mark?.getAttribute('aria-hidden')).toBe('true');
|
|
80
|
+
const tile = mark?.querySelector('rect');
|
|
81
|
+
expect(tile?.getAttribute('style')).toContain('--color-brand-tile');
|
|
82
|
+
const strokes = [...(mark?.querySelectorAll('path') ?? [])].map((p) => p.getAttribute('style'));
|
|
83
|
+
expect(strokes).toEqual([
|
|
84
|
+
'stroke: var(--color-brand-cream);',
|
|
85
|
+
'stroke: var(--color-brand);',
|
|
86
|
+
'stroke: var(--color-brand-cream);',
|
|
87
|
+
]);
|
|
88
|
+
expect(runs.get('K')?.className).toContain('sr-only');
|
|
78
89
|
// `ab` keeps the previous wordmark's gradient, as a gradient (not a fill).
|
|
79
90
|
expect(runs.get('ab')?.className).toContain('bg-clip-text');
|
|
80
91
|
expect(runs.get('ab')?.className).toContain('bg-gradient-to-r');
|
|
81
|
-
expect(runs.get('ab')?.className).toContain('from-
|
|
82
|
-
expect(runs.get('ab')?.className).toContain('to-
|
|
92
|
+
expect(runs.get('ab')?.className).toContain('from-amber-500');
|
|
93
|
+
expect(runs.get('ab')?.className).toContain('to-orange-600');
|
|
83
94
|
// `mo` stays surface ink, which is what keeps it legible on dark palettes.
|
|
84
95
|
expect(runs.get('mo')?.className).toContain('text-ink');
|
|
85
96
|
});
|
|
86
97
|
|
|
98
|
+
test('the gradient span pays for the b overhang and mo is spaced off it', () => {
|
|
99
|
+
const runs = runsOf(mount());
|
|
100
|
+
// `background-clip: text` clips to the span's box, and the b's right
|
|
101
|
+
// sidebearing is negative — without the padding its last pixels vanish.
|
|
102
|
+
const ab = runs.get('ab')!;
|
|
103
|
+
expect(ab.style.paddingRight).toBe('0.06em');
|
|
104
|
+
expect(ab.style.marginRight).toBe('-0.06em');
|
|
105
|
+
// b→m is the tight pair in this face; the margin makes it match a→b.
|
|
106
|
+
expect(runs.get('mo')!.style.marginLeft).toBe('0.02em');
|
|
107
|
+
});
|
|
108
|
+
|
|
87
109
|
test('a caller sets the size and cannot drop the wordmark classes', () => {
|
|
88
110
|
const root = mount('text-2xl').firstElementChild as HTMLElement;
|
|
89
111
|
expect(root.className).toContain('text-2xl');
|
|
90
|
-
expect(root.className).toContain('tracking-
|
|
112
|
+
expect(root.className).toContain('tracking-tight');
|
|
91
113
|
expect(root.className).toContain('font-extrabold');
|
|
92
114
|
});
|
|
93
115
|
});
|
|
@@ -10,26 +10,48 @@
|
|
|
10
10
|
* The word is drawn in three runs, each with its own colour, and the split is
|
|
11
11
|
* deliberate rather than decorative:
|
|
12
12
|
*
|
|
13
|
-
* - **`K`**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
13
|
+
* - **`K`** is the icon's mark, not a letter: `BrandMark` draws the logo's three
|
|
14
|
+
* strokes (ink stem and lower arm, `--color-brand` upper arm) so the word
|
|
15
|
+
* begins with the same shape the app icon and the favicon carry.
|
|
16
|
+
* - **`ab`** keeps the previous wordmark's gradient (`amber-500 → orange-600`),
|
|
17
|
+
* run from light to dark so the brightest end sits against the icon's dark
|
|
18
|
+
* green plate: the `a` is tucked into that plate, and a dark `a` on dark green
|
|
19
|
+
* is the one place the lockup would lose a letter. The warm end still lands
|
|
20
|
+
* where the word returns to ink.
|
|
20
21
|
* - **`mo`** is plain surface ink, the colour the trailing run has always had.
|
|
21
22
|
* Ink is what keeps that run legible on every palette, dark ones included.
|
|
22
23
|
*
|
|
24
|
+
* Two measurements are encoded in the render, and both fix a letter that was
|
|
25
|
+
* being cut rather than merely crowded:
|
|
26
|
+
*
|
|
27
|
+
* - **`ab` carries a right padding of `0.06em` and an equal negative margin.**
|
|
28
|
+
* `background-clip: text` clips the gradient to the span's box, and the `b`'s
|
|
29
|
+
* right sidebearing is NEGATIVE — the glyph's ink ends past its advance — so
|
|
30
|
+
* the last 2-3 px of the `b` were clipped away at 15px and the letter read as
|
|
31
|
+
* if `m` were covering it. Measured on the live wordmark at 4x: the span's box
|
|
32
|
+
* ended 0.5px past the `b`'s ink with the clip and 3px past it once padded, so
|
|
33
|
+
* the padding pays for the overhang and the negative margin cancels it from
|
|
34
|
+
* the layout. A tracked-out `tracking-tight` (`-0.025em`) was part of the same
|
|
35
|
+
* crowding: at `-0.05em` the ink gap between `b` and `m` measured 0.017em.
|
|
36
|
+
* - **`mo` carries `margin-left: 0.02em`.** With the tracking loosened, `a`→`b`
|
|
37
|
+
* measures 0.063em of ink gap while `b`→`m` measures 0.043em — the pair reads
|
|
38
|
+
* tight because `m`'s left stem is a full vertical beside `b`'s bowl. The
|
|
39
|
+
* margin brings the second gap to 0.063em, the same as the first, and leaves
|
|
40
|
+
* `m`→`o` (0.042em, the font's own spacing) alone.
|
|
41
|
+
*
|
|
23
42
|
* `className` carries the SIZE (each surface sets its own) and nothing else: the
|
|
24
43
|
* colour split and the tracking are the brand and are not a per-call-site
|
|
25
44
|
* decision.
|
|
26
45
|
*/
|
|
46
|
+
import { BrandMark } from '@/client/components/common/brand-wordmark/Mark';
|
|
47
|
+
|
|
27
48
|
export function BrandWordmark({ className = 'text-[15px]' }: { className?: string }) {
|
|
28
49
|
return (
|
|
29
|
-
<span className={`font-extrabold tracking-
|
|
30
|
-
<
|
|
31
|
-
<span className="
|
|
32
|
-
<span className="text-
|
|
50
|
+
<span className={`font-extrabold tracking-tight ${className}`}>
|
|
51
|
+
<BrandMark />
|
|
52
|
+
<span className="sr-only">K</span>
|
|
53
|
+
<span className="text-transparent bg-clip-text bg-gradient-to-r from-amber-500 to-orange-600" style={{ paddingRight: '0.06em', marginRight: '-0.06em' }}>ab</span>
|
|
54
|
+
<span className="text-ink" style={{ marginLeft: '0.02em' }}>mo</span>
|
|
33
55
|
</span>
|
|
34
56
|
);
|
|
35
57
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The README wordmark (`public/kabmo-wordmark.svg`).
|
|
3
|
+
*
|
|
4
|
+
* It is a standalone asset — no app stylesheet, no font, no component — so a
|
|
5
|
+
* README can embed it from any renderer. That independence is exactly why it
|
|
6
|
+
* needs a test: nothing else in the repository reads the file, and every
|
|
7
|
+
* property below is one a hand edit would break silently.
|
|
8
|
+
*
|
|
9
|
+
* The numbers are the app's own, measured from `BrandWordmark`: a 100-unit cap
|
|
10
|
+
* height, the plate at `1.5625 x cap` (the icon's ink is 0.64 of its tile, so
|
|
11
|
+
* the K keeps its proportions), the `a` tucked 0.28em into the plate, the
|
|
12
|
+
* `b`→`m` gap widened 0.02em, and `tracking-tight` at -0.025em.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const SOURCE = readFileSync(join(import.meta.dir, '../../../../../public/kabmo-wordmark.svg'), 'utf8');
|
|
20
|
+
|
|
21
|
+
function attr(name: string): string {
|
|
22
|
+
const match = SOURCE.match(new RegExp(`${name}="([^"]+)"`));
|
|
23
|
+
if (!match) throw new Error(`missing ${name}`);
|
|
24
|
+
return match[1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('public/kabmo-wordmark.svg', () => {
|
|
28
|
+
test('is a self-contained, accessible image', () => {
|
|
29
|
+
expect(SOURCE.startsWith('<?xml')).toBe(true);
|
|
30
|
+
expect(SOURCE).toContain('xmlns="http://www.w3.org/2000/svg"');
|
|
31
|
+
// A README's <img> gets its accessible name from the SVG itself when the
|
|
32
|
+
// renderer honours it; `alt` on the <img> is the other half, and both say
|
|
33
|
+
// the same thing.
|
|
34
|
+
expect(attr('role')).toBe('img');
|
|
35
|
+
expect(attr('aria-label')).toBe('Kabmo');
|
|
36
|
+
expect(SOURCE).toContain('<title>Kabmo</title>');
|
|
37
|
+
// No external dependency: a font reference or a fetched resource would fail
|
|
38
|
+
// in a renderer that sandboxes images. (The xmlns URI is a namespace name,
|
|
39
|
+
// never fetched, so it is not a dependency.)
|
|
40
|
+
expect(SOURCE).not.toMatch(/@font-face|(?:href|src)="https?:|url\(['"]?https?:/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('draws the icon plate at the size the K needs', () => {
|
|
44
|
+
const viewBox = attr('viewBox').split(/\s+/).map(Number);
|
|
45
|
+
expect(viewBox).toHaveLength(4);
|
|
46
|
+
const plate = SOURCE.match(/<rect width="([\d.]+)" height="([\d.]+)" y="([-\d.]+)"/);
|
|
47
|
+
expect(plate).toBeTruthy();
|
|
48
|
+
const [, w, h, y] = plate!.map(Number);
|
|
49
|
+
// 1.5625 x a 100-unit cap: the icon's ink is 41 of its 64 units, so this is
|
|
50
|
+
// the tile that puts the K exactly on the cap height.
|
|
51
|
+
expect(w).toBe(156.25);
|
|
52
|
+
expect(h).toBe(156.25);
|
|
53
|
+
// The ink (41/64 of the tile) is centred on the cap band, whose top is at
|
|
54
|
+
// -100 in this coordinate system.
|
|
55
|
+
const inkTop = y + (h - 100) / 2;
|
|
56
|
+
expect(Math.abs(inkTop + 100)).toBeLessThan(0.02);
|
|
57
|
+
// The viewBox is the artwork's own bounds, so a renderer adds no padding.
|
|
58
|
+
expect(Math.abs(viewBox[1] - y)).toBeLessThan(0.02);
|
|
59
|
+
expect(viewBox[3]).toBeCloseTo(156.25, 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('keeps the brand colours, with one ink that works on any README', () => {
|
|
63
|
+
expect(SOURCE).toContain('fill="#0b5d3b"'); // plate
|
|
64
|
+
expect(SOURCE).toContain('stroke="#f7f4ea"'); // stem + lower arm
|
|
65
|
+
expect(SOURCE).toContain('stroke="#f26522"'); // upper arm
|
|
66
|
+
expect(SOURCE).toContain('stop-color="#f59e0b"'); // a, the light end
|
|
67
|
+
expect(SOURCE).toContain('stop-color="#ea580c"'); // b, the dark end
|
|
68
|
+
// One ink, not a media query. A README embeds this as an image, and inside
|
|
69
|
+
// an image `prefers-color-scheme` follows the CONTAINER only in Chromium —
|
|
70
|
+
// WebKit resolves it from the OS. Measured: GitHub dark on a light OS
|
|
71
|
+
// rendered #141310 on #0d1117 (1.02:1) in WebKit. #7e7a72 holds 4.27 on
|
|
72
|
+
// white and 4.43 on GitHub dark, so it needs no switch at all.
|
|
73
|
+
expect(SOURCE).toContain('.kabmo-mo { fill: #7e7a72; }');
|
|
74
|
+
// No media rule at all: the ink must not depend on how the renderer
|
|
75
|
+
// resolves a colour scheme, which differs between engines inside an image.
|
|
76
|
+
expect(SOURCE).not.toContain('@media');
|
|
77
|
+
expect(SOURCE).toContain('fill="#7e7a72"');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('outlines the word, so no font is required', () => {
|
|
81
|
+
// Three strokes plus two outlined runs: `ab` and `mo`.
|
|
82
|
+
expect(SOURCE.match(/<path /g)?.length).toBe(5);
|
|
83
|
+
expect(SOURCE).toContain('fill="url(#kabmo-ab)"');
|
|
84
|
+
expect(SOURCE).toContain('class="kabmo-mo"');
|
|
85
|
+
// Outlines are curves, not text elements a renderer could re-flow.
|
|
86
|
+
expect(SOURCE).not.toContain('<text');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The phone header's `⋯` menu: the two controls that do not need a slot.
|
|
8
|
+
*
|
|
9
|
+
* Reload and sign-out are rare actions, and each costs 36px of a header that is
|
|
10
|
+
* already sharing 320–412px between the wordmark, the session title and the
|
|
11
|
+
* four controls that ARE used mid-conversation (context, panel, and whatever a
|
|
12
|
+
* plugin contributes). Behind `⋯` they still cost 36px — but 36px ONCE, for
|
|
13
|
+
* both, against 72px standing in the row.
|
|
14
|
+
*
|
|
15
|
+
* This is the same answer the sidebar toolbar reached when its row ran out of
|
|
16
|
+
* room (`ToolbarOverflowMenu`): hiding a control outright makes it unreachable
|
|
17
|
+
* at one width, and letting the row overflow hides it behind a scroll with
|
|
18
|
+
* nothing on screen to say so. A menu keeps it one click away and names it in
|
|
19
|
+
* words rather than as a glyph.
|
|
20
|
+
*
|
|
21
|
+
* ## Sign-out confirms HERE, not in the row it came from
|
|
22
|
+
*
|
|
23
|
+
* `SignOutButton` is a two-press control because it sits a pixel from reload.
|
|
24
|
+
* A menu already costs an open and a click, so the confirmation is the first
|
|
25
|
+
* row press: the row turns into `Click again to sign out` and the second press
|
|
26
|
+
* ends the session. The safety property is unchanged — one stray click still
|
|
27
|
+
* cannot sign the user out — and focus never leaves the menu.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Ellipsis, Lock, LockOpen, RefreshCw } from 'lucide-preact';
|
|
31
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
32
|
+
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
33
|
+
import { logout, useAuthEnabled, useAuthState } from '@/client/hooks/ui/auth';
|
|
34
|
+
|
|
35
|
+
const ROW_CLASS =
|
|
36
|
+
'w-full text-left px-3 py-2 flex items-center gap-2 transition-colors text-ink/75 hover:bg-ink/5 hover:text-ink cursor-pointer';
|
|
37
|
+
|
|
38
|
+
export function HeaderMoreMenu() {
|
|
39
|
+
const authEnabled = useAuthEnabled();
|
|
40
|
+
const authState = useAuthState();
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const [confirming, setConfirming] = useState(false);
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
useOnClickOutside(rootRef, () => setOpen(false));
|
|
46
|
+
|
|
47
|
+
const canSignOut = authEnabled && authState !== 'required';
|
|
48
|
+
// The open flag is mirrored into a ref so the Escape listener can be bound
|
|
49
|
+
// ONCE, at mount. Binding it from an effect keyed on `open` lost the first
|
|
50
|
+
// keypress after the click that opened the menu — the effect had not run yet
|
|
51
|
+
// — and a menu that swallows Escape intermittently is worse than one that
|
|
52
|
+
// never does, because the user cannot tell which they are looking at.
|
|
53
|
+
const openRef = useRef(open);
|
|
54
|
+
openRef.current = open;
|
|
55
|
+
|
|
56
|
+
// A menu left open while the session ends elsewhere would offer an action for
|
|
57
|
+
// a session that no longer exists — the same rule `SignOutButton` applies.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!canSignOut) setConfirming(false);
|
|
60
|
+
}, [canSignOut]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
64
|
+
if (event.key !== 'Escape' || !openRef.current) return;
|
|
65
|
+
setOpen(false);
|
|
66
|
+
setConfirming(false);
|
|
67
|
+
};
|
|
68
|
+
document.addEventListener('keydown', onKeyDown);
|
|
69
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const signOut = async () => {
|
|
73
|
+
if (busy) return;
|
|
74
|
+
if (!confirming) {
|
|
75
|
+
setConfirming(true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setBusy(true);
|
|
79
|
+
// `logout` reloads into the login screen; a failure still leaves no usable
|
|
80
|
+
// session, so there is nothing to recover here.
|
|
81
|
+
await logout();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="relative" ref={rootRef}>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setOpen((current) => !current)}
|
|
89
|
+
className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
|
90
|
+
open ? 'bg-ink/10 text-ink' : 'text-ink hover:bg-ink/5 active:bg-ink/10'
|
|
91
|
+
}`}
|
|
92
|
+
title="More actions"
|
|
93
|
+
aria-label="More actions"
|
|
94
|
+
aria-haspopup="menu"
|
|
95
|
+
aria-expanded={open}
|
|
96
|
+
>
|
|
97
|
+
<Ellipsis size={18} />
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
{open && (
|
|
101
|
+
<div
|
|
102
|
+
role="menu"
|
|
103
|
+
className="absolute right-0 top-full mt-1 w-44 bg-paper border border-ink/15 rounded-xl shadow-lg z-50 py-1 text-xs"
|
|
104
|
+
>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
role="menuitem"
|
|
108
|
+
onClick={() => {
|
|
109
|
+
setOpen(false);
|
|
110
|
+
window.location.reload();
|
|
111
|
+
}}
|
|
112
|
+
className={ROW_CLASS}
|
|
113
|
+
>
|
|
114
|
+
<RefreshCw size={14} />
|
|
115
|
+
<span>Reload page</span>
|
|
116
|
+
</button>
|
|
117
|
+
|
|
118
|
+
{canSignOut && (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
role="menuitem"
|
|
122
|
+
onClick={signOut}
|
|
123
|
+
disabled={busy}
|
|
124
|
+
className={ROW_CLASS}
|
|
125
|
+
aria-expanded={confirming}
|
|
126
|
+
data-confirming={confirming ? 'true' : undefined}
|
|
127
|
+
>
|
|
128
|
+
{confirming ? <LockOpen size={14} /> : <Lock size={14} />}
|
|
129
|
+
<span>{confirming ? 'Click again to sign out' : 'Sign out'}</span>
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -157,7 +157,26 @@ function controlNames(): string[] {
|
|
|
157
157
|
return names;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Mount the cluster at a chosen viewport width.
|
|
162
|
+
*
|
|
163
|
+
* The width is load-bearing since the phone folds two controls into `⋯` only
|
|
164
|
+
* while the row cannot hold them (`MOBILE_FULL_ROW_PX`), so a case that does
|
|
165
|
+
* not say where it is mounted is asserting a layout nobody chose. happy-dom
|
|
166
|
+
* resolves `matchMedia` against `innerWidth`, so setting it is the same signal
|
|
167
|
+
* a real resize raises.
|
|
168
|
+
*/
|
|
169
|
+
async function mount(
|
|
170
|
+
layout: 'desktop' | 'mobile',
|
|
171
|
+
props: { editor?: boolean; switch?: boolean; width?: number } = {},
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
// happy-dom resolves `matchMedia` against `innerWidth`, so writing it is the
|
|
174
|
+
// same signal a real resize raises.
|
|
175
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
176
|
+
value: props.width ?? 1024,
|
|
177
|
+
configurable: true,
|
|
178
|
+
writable: true,
|
|
179
|
+
});
|
|
161
180
|
container = document.createElement('div');
|
|
162
181
|
document.body.appendChild(container);
|
|
163
182
|
await act(async () => {
|
|
@@ -205,12 +224,42 @@ describe('HeaderControls', () => {
|
|
|
205
224
|
]);
|
|
206
225
|
});
|
|
207
226
|
|
|
208
|
-
test('
|
|
209
|
-
await mount('mobile');
|
|
227
|
+
test('a narrow phone draws the row, minus the desktop-only pair', async () => {
|
|
228
|
+
await mount('mobile', { width: 390 });
|
|
210
229
|
|
|
211
230
|
// Install is desktop-only by decision: a phone's browser never fires the
|
|
212
231
|
// prompt (iOS has its own guide), so the control is not part of the phone's
|
|
213
232
|
// row. The editor column does not exist on the phone at all.
|
|
233
|
+
//
|
|
234
|
+
// At 390px reload and sign-out are rows of the `⋯` menu rather than
|
|
235
|
+
// standing in the row: that is what pays for the wordmark and the session
|
|
236
|
+
// title (see `HeaderControls`' own doc).
|
|
237
|
+
expect(controlNames()).toEqual([
|
|
238
|
+
'Demo Stats',
|
|
239
|
+
'Context usage',
|
|
240
|
+
'Open Right Panel',
|
|
241
|
+
'More actions',
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('a narrow phone folds reload and sign-out into the ⋯ menu', async () => {
|
|
246
|
+
await mount('mobile', { width: 360 });
|
|
247
|
+
|
|
248
|
+
// Below `MOBILE_FULL_ROW_PX` the row cannot hold them beside a full-width
|
|
249
|
+
// picker, so they are rows of the `⋯` menu instead.
|
|
250
|
+
expect(controlNames()).toEqual([
|
|
251
|
+
'Demo Stats',
|
|
252
|
+
'Context usage',
|
|
253
|
+
'Open Right Panel',
|
|
254
|
+
'More actions',
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('a phone wide enough for the whole row draws every control, no menu', async () => {
|
|
259
|
+
// 500px is past both thresholds (400 open, 436 with the lock), so nothing
|
|
260
|
+
// is folded: the fold is a width, not a device class.
|
|
261
|
+
await mount('mobile', { width: 500 });
|
|
262
|
+
|
|
214
263
|
expect(controlNames()).toEqual([
|
|
215
264
|
'Demo Stats',
|
|
216
265
|
'Reload page',
|
|
@@ -226,15 +275,32 @@ describe('HeaderControls', () => {
|
|
|
226
275
|
render(null, container);
|
|
227
276
|
container.remove();
|
|
228
277
|
|
|
229
|
-
await mount('mobile');
|
|
278
|
+
await mount('mobile', { width: 500 });
|
|
230
279
|
const mobile = controlNames();
|
|
231
280
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
expect(
|
|
235
|
-
expect(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
281
|
+
// Context before the panel on both — the two controls a user presses while
|
|
282
|
+
// reading a run.
|
|
283
|
+
expect(desktop.indexOf('Context usage')).toBeLessThan(desktop.indexOf('Toggle Right Panel'));
|
|
284
|
+
expect(mobile.indexOf('Context usage')).toBeLessThan(mobile.indexOf('Open Right Panel'));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('the folded controls stay one click away, not absent', async () => {
|
|
288
|
+
await mount('mobile', { width: 360 });
|
|
289
|
+
|
|
290
|
+
expect(controlNames()).not.toContain('Reload page');
|
|
291
|
+
expect(controlNames()).not.toContain('Sign out');
|
|
292
|
+
|
|
293
|
+
const trigger = container.querySelector('button[aria-label="More actions"]') as HTMLElement;
|
|
294
|
+
expect(trigger).toBeTruthy();
|
|
295
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
296
|
+
await act(async () => {
|
|
297
|
+
trigger.click();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const menu = container.querySelector('[role="menu"]');
|
|
301
|
+
expect(menu).toBeTruthy();
|
|
302
|
+
expect(menu!.textContent).toContain('Reload page');
|
|
303
|
+
expect(menu!.textContent).toContain('Sign out');
|
|
304
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
239
305
|
});
|
|
240
306
|
});
|
|
@@ -4,10 +4,29 @@ import { ReloadPageButton } from '@/client/components/common/ReloadPageButton';
|
|
|
4
4
|
import { SignOutButton } from '@/client/components/common/SignOutButton';
|
|
5
5
|
import { ContextUsageButton } from '@/client/components/common/context-usage-button';
|
|
6
6
|
import { HeaderPanelButtons } from '@/client/components/common/header-panel-buttons';
|
|
7
|
+
import { HeaderMoreMenu } from '@/client/components/common/header-controls/MoreMenu';
|
|
8
|
+
import { useAuthEnabled, useAuthState } from '@/client/hooks/ui/auth';
|
|
9
|
+
import { useViewportAtLeast } from '@/client/hooks/ui/media-query';
|
|
7
10
|
import { withChord } from '@/client/lib/ui/with-chord';
|
|
8
11
|
|
|
9
12
|
export type HeaderLayout = 'desktop' | 'mobile';
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The narrowest phone header that still holds every control in the row.
|
|
16
|
+
*
|
|
17
|
+
* Measured against the real header, with a long session title so the picker is
|
|
18
|
+
* at its 150px cap and cannot absorb the difference:
|
|
19
|
+
*
|
|
20
|
+
* - open instance, reload in the row: the picker reaches 150px at **400px**;
|
|
21
|
+
* - with a UI password, reload + lock: at **436px**.
|
|
22
|
+
*
|
|
23
|
+
* Below those the picker would start truncating the session title to pay for a
|
|
24
|
+
* control that is pressed once a session, so those two move into `⋯` instead.
|
|
25
|
+
* A wider phone or a rotated one gets them back — the fold is not a device
|
|
26
|
+
* class, it is the width at which the row stops fitting.
|
|
27
|
+
*/
|
|
28
|
+
const MOBILE_FULL_ROW_PX = { without: 400, withSignOut: 436 } as const;
|
|
29
|
+
|
|
11
30
|
interface HeaderControlsProps {
|
|
12
31
|
layout: HeaderLayout;
|
|
13
32
|
sessionId: string | null;
|
|
@@ -40,14 +59,30 @@ const ICON_SIZE: Record<HeaderLayout, number> = { desktop: 16, mobile: 18 };
|
|
|
40
59
|
*
|
|
41
60
|
* 1. Install (PWA) — desktop only
|
|
42
61
|
* 2. Plugin header slots
|
|
43
|
-
* 3. Reload page
|
|
62
|
+
* 3. Reload page — desktop; behind `⋯` on a phone
|
|
44
63
|
* 4. Context usage
|
|
45
|
-
* 5. Sign out (the lock)
|
|
64
|
+
* 5. Sign out (the lock) — desktop; behind `⋯` on a phone
|
|
46
65
|
* 6. Editor toggle — desktop only
|
|
47
66
|
* 7. Right panel
|
|
67
|
+
* 8. More actions (`⋯`) — phone only, in the slot the two moved controls
|
|
68
|
+
* left behind
|
|
48
69
|
*
|
|
49
70
|
* The dev-only layout switch sits AFTER that list: it is not part of the
|
|
50
71
|
* product's navbar and must not push a listed control out of the agreed order.
|
|
72
|
+
*
|
|
73
|
+
* ## Why a narrow phone folds two of them into a menu
|
|
74
|
+
*
|
|
75
|
+
* The row is 320–412px wide and already shares it with the wordmark, the slash
|
|
76
|
+
* and the session title — the only functional thing in the left group. Reload
|
|
77
|
+
* and sign-out are rare (a reload is Cmd/Ctrl+R; a sign-out happens once a
|
|
78
|
+
* session), while context and the right panel are read and pressed mid-turn.
|
|
79
|
+
* Measured with a UI password set and all four standing in the row, the title
|
|
80
|
+
* got 89px at 390 and 19px at 320 — one letter and an ellipsis.
|
|
81
|
+
*
|
|
82
|
+
* The fold is therefore a WIDTH, not a layout: `MOBILE_FULL_ROW_PX` is the
|
|
83
|
+
* narrowest header that still fits every control beside a full 150px picker,
|
|
84
|
+
* and above it the phone draws the same row the desktop does. Nothing is
|
|
85
|
+
* permanently unreachable, and nothing is hidden at a width that can hold it.
|
|
51
86
|
*/
|
|
52
87
|
export function HeaderControls({
|
|
53
88
|
layout,
|
|
@@ -60,14 +95,27 @@ export function HeaderControls({
|
|
|
60
95
|
}: HeaderControlsProps) {
|
|
61
96
|
const iconClass = ICON_BUTTON_CLASS[layout];
|
|
62
97
|
const size = ICON_SIZE[layout];
|
|
98
|
+
const authEnabled = useAuthEnabled();
|
|
99
|
+
const authState = useAuthState();
|
|
100
|
+
const signOutShown = authEnabled && authState !== 'required';
|
|
101
|
+
// Called unconditionally: a hook inside the ternary below would run in a
|
|
102
|
+
// different order on the two layouts.
|
|
103
|
+
const wideRow = useViewportAtLeast(signOutShown ? MOBILE_FULL_ROW_PX.withSignOut : MOBILE_FULL_ROW_PX.without);
|
|
104
|
+
const wideEnoughForAll = layout === 'desktop' || wideRow;
|
|
105
|
+
// A phone folds the two rare controls only while the row cannot hold them.
|
|
106
|
+
const folded = layout === 'mobile' && !wideEnoughForAll;
|
|
63
107
|
|
|
64
108
|
return (
|
|
65
109
|
<div className="flex items-center space-x-1">
|
|
66
110
|
{layout === 'desktop' && <PWAInstallButton />}
|
|
67
111
|
<HeaderPanelButtons layout={layout} />
|
|
68
|
-
|
|
112
|
+
{/* The cluster's ORDER is fixed; what changes on a narrow phone is that
|
|
113
|
+
reload and sign-out move behind `⋯`, which sits in the slot they
|
|
114
|
+
left. Nothing is hidden at a width that can hold it: measured, the
|
|
115
|
+
row fits every control at 400px open and 436px with the lock. */}
|
|
116
|
+
{(layout === 'desktop' || !folded) && <ReloadPageButton size={size} className={iconClass} />}
|
|
69
117
|
<ContextUsageButton sessionId={sessionId} layout={layout} />
|
|
70
|
-
<SignOutButton size={size} className={iconClass} />
|
|
118
|
+
{(layout === 'desktop' || !folded) && <SignOutButton size={size} className={iconClass} />}
|
|
71
119
|
{layout === 'desktop' && onToggleEditor && (
|
|
72
120
|
<button
|
|
73
121
|
type="button"
|
|
@@ -102,6 +150,7 @@ export function HeaderControls({
|
|
|
102
150
|
{showRightPanel ? <PanelRightClose size={size} /> : <PanelRight size={size} />}
|
|
103
151
|
</button>
|
|
104
152
|
)}
|
|
153
|
+
{folded && <HeaderMoreMenu />}
|
|
105
154
|
{layout === 'desktop' && onSwitchToMobile && (
|
|
106
155
|
<button
|
|
107
156
|
type="button"
|
|
@@ -25,7 +25,8 @@ import { h, render } from 'preact';
|
|
|
25
25
|
import { act } from 'preact/test-utils';
|
|
26
26
|
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
27
27
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
28
|
-
import {
|
|
28
|
+
import { adoptPendingSession } from '@/client/hooks/chat/omp/pending-sessions';
|
|
29
|
+
import { flushSession, forgetSession } from '@/shared/lib/workspace/session-state/store';
|
|
29
30
|
|
|
30
31
|
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
|
|
31
32
|
/** The runner's own globals, restored on teardown — deleting them would strip natives (Event/CustomEvent) every later file needs. */const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
@@ -139,4 +140,24 @@ describe('SessionStateProvider on a pending session', () => {
|
|
|
139
140
|
expect(calls.some((c) => c.url === '/api/sessions/real-9/state' && c.method === 'GET')).toBe(true);
|
|
140
141
|
expect(calls.some((c) => c.method === 'POST')).toBe(false);
|
|
141
142
|
});
|
|
143
|
+
|
|
144
|
+
test('a spawn adoption carries the panels the user opened onto the real id', async () => {
|
|
145
|
+
// The reported bug: open a new chat, open some right-panel views, send the
|
|
146
|
+
// first prompt — the spawn renames the session and everything the user had
|
|
147
|
+
// opened closed again. The provider migrates the pending slot in its OWN
|
|
148
|
+
// effect, which Preact runs AFTER its children's: each `useSessionState`
|
|
149
|
+
// copy had already re-read the (still empty) adopted slot, landed on its
|
|
150
|
+
// fallback, and never re-read because `ready` does not move across an
|
|
151
|
+
// adoption.
|
|
152
|
+
const pending = 'new-test-adopt';
|
|
153
|
+
const el = await mountPending(pending);
|
|
154
|
+
(el.querySelector('button') as HTMLButtonElement).click();
|
|
155
|
+
await act(async () => {});
|
|
156
|
+
|
|
157
|
+
adoptPendingSession(pending, 'omp-adopted', 1);
|
|
158
|
+
await switchTo(el, 'omp-adopted');
|
|
159
|
+
|
|
160
|
+
expect(el.textContent).toContain('git');
|
|
161
|
+
forgetSession('omp-adopted');
|
|
162
|
+
});
|
|
142
163
|
});
|