@kabmo-labs/kabmo 0.7.0 → 0.8.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 +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +75 -70
- package/src/server/lib/updates/shell-release.ts +120 -136
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabmo-labs/kabmo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"test": "bun test",
|
|
55
55
|
"test:e2e": "playwright test --config tests/e2e/playwright.config.ts",
|
|
56
56
|
"readme:media": "bun run scripts/readme-media.ts",
|
|
57
|
+
"shell:wordmark": "bun run scripts/shell-wordmark.mjs",
|
|
57
58
|
"typecheck:e2e": "tsc --noEmit -p tests/e2e",
|
|
58
59
|
"publish:plugins": "bash .github/scripts/publish-plugin-packages.sh packages/kabmo-sdk packages/kabmo-ui"
|
|
59
60
|
},
|
|
@@ -6,6 +6,8 @@ import { ContextUsageButton } from '@/client/components/common/context-usage-but
|
|
|
6
6
|
import { HeaderPanelButtons } from '@/client/components/common/header-panel-buttons';
|
|
7
7
|
import { useHeaderEntries } from '@/client/components/common/header-panel-buttons/entries';
|
|
8
8
|
import { HeaderMoreMenu } from '@/client/components/common/header-controls/MoreMenu';
|
|
9
|
+
import { WindowControls } from '@/client/components/common/window-controls';
|
|
10
|
+
import { drawsWindowControls } from '@/client/lib/desktop/titlebar';
|
|
9
11
|
import { useAuthEnabled, useAuthState } from '@/client/hooks/ui/auth';
|
|
10
12
|
import { useViewportAtLeast } from '@/client/hooks/ui/media-query';
|
|
11
13
|
import { withChord } from '@/client/lib/ui/with-chord';
|
|
@@ -111,7 +113,17 @@ export function HeaderControls({
|
|
|
111
113
|
const headerEntries = useHeaderEntries(layout);
|
|
112
114
|
|
|
113
115
|
return (
|
|
114
|
-
|
|
116
|
+
// `titlebar-no-drag` belongs HERE, on the cluster's own root, not on each
|
|
117
|
+
// call site. `app-region` INHERITS (WebView2's own spec says so, and
|
|
118
|
+
// Chromium 152 began enforcing it), so a header that marks itself a drag
|
|
119
|
+
// region makes every descendant a drag handle unless something below it
|
|
120
|
+
// says otherwise — a control inside one is unclickable, silently. The
|
|
121
|
+
// desktop navbar wrapped this cluster in a `titlebar-no-drag` div and the
|
|
122
|
+
// PHONE's header did not, so on Windows every control in a phone-width
|
|
123
|
+
// window (reload, context, sign out, the right panel, `⋯`) swallowed its
|
|
124
|
+
// own click. Stating it on the cluster is what makes the next layout that
|
|
125
|
+
// draws it correct by construction.
|
|
126
|
+
<div className="flex items-center space-x-1 titlebar-no-drag">
|
|
115
127
|
{layout === 'desktop' && <PWAInstallButton />}
|
|
116
128
|
<HeaderPanelButtons layout={layout} entries={headerEntries.inline} />
|
|
117
129
|
{/* The cluster's ORDER is fixed; what changes on a narrow phone is that
|
|
@@ -169,6 +181,17 @@ export function HeaderControls({
|
|
|
169
181
|
<Smartphone size={size} />
|
|
170
182
|
</button>
|
|
171
183
|
)}
|
|
184
|
+
{/* The Windows and Linux shells draw no caption buttons of their own —
|
|
185
|
+
their webview is the whole window — so the PAGE owns minimise,
|
|
186
|
+
maximise and close. This lives in the shared cluster rather than in
|
|
187
|
+
the desktop navbar for a measured reason: a desktop shell window can
|
|
188
|
+
be NARROWER than the layout breakpoint (`App` switches to the mobile
|
|
189
|
+
layout below 768px, and all three shells allow a 720px window), and
|
|
190
|
+
the phone header drew no window controls at all. A user who shrank
|
|
191
|
+
the window lost close/minimise/maximise entirely, with no title bar
|
|
192
|
+
behind them to fall back on. macOS never reaches this branch: its
|
|
193
|
+
traffic lights are the system's, so `drawsWindowControls` is false. */}
|
|
194
|
+
{drawsWindowControls() && <WindowControls />}
|
|
172
195
|
</div>
|
|
173
196
|
);
|
|
174
197
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shell chrome the shared cluster must carry, on BOTH layouts.
|
|
8
|
+
*
|
|
9
|
+
* Two bugs reached a user, and both are invisible in a browser:
|
|
10
|
+
*
|
|
11
|
+
* 1. **`app-region` inherits.** WebView2's own spec says the property inherits,
|
|
12
|
+
* and Chromium 152 began enforcing it. So a header marked `app-region: drag`
|
|
13
|
+
* turns every descendant into a drag handle — and a drag handle swallows the
|
|
14
|
+
* click, silently. The desktop navbar wrapped this cluster in a
|
|
15
|
+
* `titlebar-no-drag` div and the PHONE's header did not, so on Windows every
|
|
16
|
+
* control in a phone-width window (reload, context, sign out, the right
|
|
17
|
+
* panel, `⋯`) looked right and did nothing.
|
|
18
|
+
* 2. **The page-drawn caption buttons lived in one layout.** A desktop shell
|
|
19
|
+
* window can be narrower than `App`'s 768px breakpoint — all three shells
|
|
20
|
+
* allow a 720px window — so a Windows/Linux window resized down switches to
|
|
21
|
+
* the phone layout, which drew no close/minimise/maximise at all and had no
|
|
22
|
+
* native title bar behind them.
|
|
23
|
+
*
|
|
24
|
+
* Both are asserted on the RENDERED tree, because both were a call site's
|
|
25
|
+
* omission: a test reading the component's source would have passed while the
|
|
26
|
+
* phone stayed broken. macOS is covered too — it keeps its own traffic lights,
|
|
27
|
+
* so the page must draw no second set.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
31
|
+
import { Window } from 'happy-dom';
|
|
32
|
+
import { h, render } from 'preact';
|
|
33
|
+
import { act } from 'preact/test-utils';
|
|
34
|
+
import { definePluginApp } from '@kabmo-labs/kabmo-sdk/app';
|
|
35
|
+
import { HeaderControls } from '@/client/components/common/header-controls';
|
|
36
|
+
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
37
|
+
import { registerPluginBundle, resetPluginSlots } from '@/client/lib/plugins/slots';
|
|
38
|
+
import { primeAuthState } from '@/client/hooks/ui/auth';
|
|
39
|
+
import { resetRealtimeClient } from '@/shared/lib/realtime/client';
|
|
40
|
+
import { invalidateModelsCache } from '@/shared/lib/models/client';
|
|
41
|
+
import { sessionTelemetryTopic, TOPIC_PANELS } from '@/shared/lib/realtime/protocol';
|
|
42
|
+
import type { PanelRegistryPayload, SessionContextTelemetry } from '@/shared/types';
|
|
43
|
+
import { startRealtimeTestServer, type RealtimeTestServer } from '@/test-support/realtime-server';
|
|
44
|
+
import { installDomGlobals, pristineWebSocket, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
45
|
+
import type { TopicResolver } from '@/server/lib/realtime/hub.server';
|
|
46
|
+
|
|
47
|
+
const SESSION = 'session-shell-chrome';
|
|
48
|
+
const nativeFetch = Bun.fetch;
|
|
49
|
+
const Noop = () => null;
|
|
50
|
+
|
|
51
|
+
let container: HTMLElement;
|
|
52
|
+
let server: RealtimeTestServer;
|
|
53
|
+
|
|
54
|
+
const PANELS: PanelRegistryPayload = {
|
|
55
|
+
panels: [{ pluginId: 'demo', name: 'Demo', version: '1.0.0', appUrl: '/api/panels/bundle/demo.js' }],
|
|
56
|
+
marketplaces: [],
|
|
57
|
+
errors: [],
|
|
58
|
+
plugins: [{ pluginId: 'demo', name: 'Demo', version: '1.0.0', isPackage: true, built: true, enabled: true, bundled: false, gitOrigin: null }],
|
|
59
|
+
catalog: [],
|
|
60
|
+
disabledPanels: [],
|
|
61
|
+
gitStatus: {},
|
|
62
|
+
autoUpdate: false,
|
|
63
|
+
syncBlocked: {},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function telemetry(): SessionContextTelemetry {
|
|
67
|
+
return {
|
|
68
|
+
sessionId: SESSION,
|
|
69
|
+
sessionTitle: 'Shell',
|
|
70
|
+
modelId: 'kenari/deepseek-v4-1-flash',
|
|
71
|
+
modelName: 'deepseek-v4-1-flash',
|
|
72
|
+
timestamp: 'today',
|
|
73
|
+
contextUsed: 12_000,
|
|
74
|
+
contextLimit: 100_000,
|
|
75
|
+
contextPercent: 12,
|
|
76
|
+
messagesCount: 3,
|
|
77
|
+
userCount: 1,
|
|
78
|
+
assistantCount: 2,
|
|
79
|
+
totalCost: 0.01,
|
|
80
|
+
costFormatted: '$0.01',
|
|
81
|
+
cacheHitAverage: 90,
|
|
82
|
+
lastMessage: { input: 10, output: 4, reasoning: 0, cacheRead: 2, cacheWrite: 0, cacheHitPercent: 16.6 },
|
|
83
|
+
distribution: { userTokens: 1, userPercent: 25, assistantTokens: 1, assistantPercent: 25, toolTokens: 1, toolPercent: 25, otherTokens: 1, otherPercent: 25 },
|
|
84
|
+
rawMessages: [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The shell's two globals, which the DOM's own `window` type does not declare. */
|
|
89
|
+
type Shell = {
|
|
90
|
+
__kabmoCall?: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
|
91
|
+
__kabmoDesktop?: Record<string, unknown>;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
const WINDOWS = { platform: 'windows', shellVersion: '0.1.0', overlayTitlebar: true, windowControls: true };
|
|
96
|
+
const MACOS = { platform: 'macos', shellVersion: '0.1.0', overlayTitlebar: true, trafficLightInset: '5rem' };
|
|
97
|
+
|
|
98
|
+
/** Install a shell identity for one case; `afterEach` removes it. */
|
|
99
|
+
function withShell(identity: Record<string, unknown> | null) {
|
|
100
|
+
// The shell's globals are not in the DOM's `window` type; one cast, at the seam.
|
|
101
|
+
const shell = window as unknown as Shell;
|
|
102
|
+
shell.__kabmoCall = async () => null;
|
|
103
|
+
if (identity) shell.__kabmoDesktop = identity;
|
|
104
|
+
else delete shell.__kabmoDesktop;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
beforeEach(async () => {
|
|
108
|
+
invalidateModelsCache();
|
|
109
|
+
const panels: TopicResolver = async () => PANELS;
|
|
110
|
+
const telemetryTopic: TopicResolver = async () => telemetry();
|
|
111
|
+
server = await startRealtimeTestServer(new Map<string, TopicResolver>([
|
|
112
|
+
[TOPIC_PANELS, panels],
|
|
113
|
+
[sessionTelemetryTopic(SESSION), telemetryTopic],
|
|
114
|
+
]));
|
|
115
|
+
installDomGlobals(new Window({ url: `http://127.0.0.1:${server.port}` }));
|
|
116
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
117
|
+
target.WebSocket = pristineWebSocket;
|
|
118
|
+
target.fetch = async (input: unknown) => {
|
|
119
|
+
const json = (body: unknown) => new Response(JSON.stringify(body), {
|
|
120
|
+
status: 200,
|
|
121
|
+
headers: { 'content-type': 'application/json' },
|
|
122
|
+
});
|
|
123
|
+
const url = String(input);
|
|
124
|
+
if (url.includes('/api/sessions/')) return json({ state: {} });
|
|
125
|
+
if (url.includes('/api/telemetry/context')) return json({ telemetry: telemetry() });
|
|
126
|
+
if (url.includes('/api/models')) return json({ modelList: [], connectedProviders: [] });
|
|
127
|
+
return json({});
|
|
128
|
+
};
|
|
129
|
+
resetPluginSlots();
|
|
130
|
+
registerPluginBundle('demo', {
|
|
131
|
+
default: definePluginApp((app) => app.headerPanel({ id: 'h', title: 'Demo Stats', component: Noop })),
|
|
132
|
+
});
|
|
133
|
+
primeAuthState(true, true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
if (container) {
|
|
138
|
+
render(null, container);
|
|
139
|
+
container.remove();
|
|
140
|
+
}
|
|
141
|
+
resetPluginSlots();
|
|
142
|
+
resetRealtimeClient();
|
|
143
|
+
server?.stop();
|
|
144
|
+
(globalThis as unknown as Record<string, unknown>).fetch = nativeFetch;
|
|
145
|
+
const shell = window as unknown as Shell;
|
|
146
|
+
delete shell.__kabmoDesktop;
|
|
147
|
+
delete shell.__kabmoCall;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
afterAll(() => {
|
|
151
|
+
restoreDomGlobals();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
async function settle(): Promise<void> {
|
|
155
|
+
await server.waitForTopic(TOPIC_PANELS, (value) => value !== null);
|
|
156
|
+
await server.waitForTopic(sessionTelemetryTopic(SESSION), (value) => value !== null);
|
|
157
|
+
for (let i = 0; i < 10; i += 1) await act(async () => {});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every control in the cluster, by its accessible name. */
|
|
161
|
+
function controlNames(): string[] {
|
|
162
|
+
const names: string[] = [];
|
|
163
|
+
for (const element of container.querySelectorAll('button, [title]')) {
|
|
164
|
+
const label = element.getAttribute('aria-label') ?? element.getAttribute('title') ?? '';
|
|
165
|
+
names.push(label.replace(/ \([^)]*\)$/, ''));
|
|
166
|
+
}
|
|
167
|
+
return names;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function mount(layout: 'desktop' | 'mobile', width: number): Promise<void> {
|
|
171
|
+
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
|
|
172
|
+
container = document.createElement('div');
|
|
173
|
+
document.body.appendChild(container);
|
|
174
|
+
await act(async () => {
|
|
175
|
+
render(
|
|
176
|
+
h(SessionStateProvider, {
|
|
177
|
+
sessionId: SESSION,
|
|
178
|
+
children: h(HeaderControls, {
|
|
179
|
+
layout,
|
|
180
|
+
sessionId: SESSION,
|
|
181
|
+
showRightPanel: layout === 'desktop',
|
|
182
|
+
onToggleRightPanel: () => {},
|
|
183
|
+
}),
|
|
184
|
+
}),
|
|
185
|
+
container,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
await settle();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function unmount(): void {
|
|
192
|
+
render(null, container);
|
|
193
|
+
container.remove();
|
|
194
|
+
container = undefined as unknown as HTMLElement;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A desktop shell window is allowed to be 720px — below the 768px breakpoint. */
|
|
198
|
+
const SHELL_MIN_WIDTH = 720;
|
|
199
|
+
const PHONE_WIDTH = 390;
|
|
200
|
+
|
|
201
|
+
describe('shell chrome', () => {
|
|
202
|
+
test('the cluster marks itself no-drag on both layouts', async () => {
|
|
203
|
+
for (const [layout, width] of [['desktop', 1024], ['mobile', PHONE_WIDTH]] as const) {
|
|
204
|
+
await mount(layout, width);
|
|
205
|
+
const root = container.firstElementChild as HTMLElement;
|
|
206
|
+
expect(root.className).toContain('titlebar-no-drag');
|
|
207
|
+
unmount();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('a Windows shell draws the caption buttons on both layouts', async () => {
|
|
212
|
+
withShell(WINDOWS);
|
|
213
|
+
for (const [layout, width] of [['desktop', 1024], ['mobile', PHONE_WIDTH]] as const) {
|
|
214
|
+
await mount(layout, width);
|
|
215
|
+
const names = controlNames();
|
|
216
|
+
expect(names).toContain('Minimize');
|
|
217
|
+
expect(names).toContain('Maximize');
|
|
218
|
+
expect(names).toContain('Close');
|
|
219
|
+
unmount();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The case the bug was actually reported for: a Windows window at the shell's
|
|
225
|
+
* own minimum width, which the app renders with the PHONE layout.
|
|
226
|
+
*/
|
|
227
|
+
test('a Windows window at its minimum width still has its caption buttons', async () => {
|
|
228
|
+
withShell(WINDOWS);
|
|
229
|
+
await mount('mobile', SHELL_MIN_WIDTH);
|
|
230
|
+
const names = controlNames();
|
|
231
|
+
expect(names).toContain('Close');
|
|
232
|
+
expect(names).toContain('Minimize');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('a macOS shell draws no caption buttons of its own', async () => {
|
|
236
|
+
withShell(MACOS);
|
|
237
|
+
await mount('desktop', 1024);
|
|
238
|
+
const names = controlNames();
|
|
239
|
+
expect(names).not.toContain('Minimize');
|
|
240
|
+
expect(names).not.toContain('Close');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/** A browser tab has no shell, so a caption button would be a dead control. */
|
|
244
|
+
test('a browser draws no caption buttons', async () => {
|
|
245
|
+
withShell(null);
|
|
246
|
+
await mount('desktop', 1024);
|
|
247
|
+
expect(controlNames()).not.toContain('Close');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The caption buttons issue the shell's own verbs, from the phone layout too.
|
|
252
|
+
*
|
|
253
|
+
* A button that reaches the DOM but never calls the bridge is the same dead
|
|
254
|
+
* control in a different disguise.
|
|
255
|
+
*/
|
|
256
|
+
test('a caption click reaches the bridge from the phone layout', async () => {
|
|
257
|
+
const calls: string[] = [];
|
|
258
|
+
withShell(WINDOWS);
|
|
259
|
+
;(window as unknown as Shell).__kabmoCall = async (method) => {
|
|
260
|
+
calls.push(method);
|
|
261
|
+
return null;
|
|
262
|
+
};
|
|
263
|
+
await mount('mobile', PHONE_WIDTH);
|
|
264
|
+
const close = Array.from(container.querySelectorAll('button'))
|
|
265
|
+
.find((button) => button.getAttribute('aria-label') === 'Close') as HTMLElement;
|
|
266
|
+
expect(close).toBeTruthy();
|
|
267
|
+
await act(async () => {
|
|
268
|
+
close.click();
|
|
269
|
+
});
|
|
270
|
+
expect(calls).toContain('window_close');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* source of truth.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { FolderOpen, ListChecks, Pin, PinOff, Trash2 } from 'lucide-preact';
|
|
16
|
+
import { FolderOpen, GitBranch, ListChecks, Pin, PinOff, Trash2 } from 'lucide-preact';
|
|
17
17
|
|
|
18
18
|
interface WorkspaceOptionsMenuProps {
|
|
19
19
|
variant: 'desktop' | 'mobile';
|
|
@@ -23,6 +23,12 @@ interface WorkspaceOptionsMenuProps {
|
|
|
23
23
|
onOpenFolder?: () => void;
|
|
24
24
|
/** Enter the session batch-selection mode scoped to this workspace. */
|
|
25
25
|
onSelectSessions?: () => void;
|
|
26
|
+
/**
|
|
27
|
+
* Open the New worktree dialog for this workspace. Offered only for a folder
|
|
28
|
+
* bound to a repository: a worktree needs one, and a row that opens a dialog
|
|
29
|
+
* which can only refuse is a row that teaches the user nothing.
|
|
30
|
+
*/
|
|
31
|
+
onNewWorktree?: () => void;
|
|
26
32
|
confirmDelete: boolean;
|
|
27
33
|
onPin: () => void;
|
|
28
34
|
onDelete: () => void;
|
|
@@ -35,6 +41,7 @@ export function WorkspaceOptionsMenu({
|
|
|
35
41
|
isPinned = false,
|
|
36
42
|
onOpenFolder,
|
|
37
43
|
onSelectSessions,
|
|
44
|
+
onNewWorktree,
|
|
38
45
|
confirmDelete,
|
|
39
46
|
onPin,
|
|
40
47
|
onDelete,
|
|
@@ -71,6 +78,12 @@ export function WorkspaceOptionsMenu({
|
|
|
71
78
|
<span>Select sessions</span>
|
|
72
79
|
</button>
|
|
73
80
|
)}
|
|
81
|
+
{onNewWorktree && (
|
|
82
|
+
<button type="button" onClick={onNewWorktree} className="w-full text-left px-3 py-2 hover:bg-ink/5 text-ink/80 flex items-center space-x-2">
|
|
83
|
+
<GitBranch size={13} />
|
|
84
|
+
<span>New worktree…</span>
|
|
85
|
+
</button>
|
|
86
|
+
)}
|
|
74
87
|
<button type="button" onClick={onRequestDelete} className="w-full text-left px-3 py-2 hover:bg-error/10 text-error flex items-center space-x-2">
|
|
75
88
|
<Trash2 size={13} />
|
|
76
89
|
<span>Delete Workspace</span>
|
|
@@ -105,6 +118,11 @@ export function WorkspaceOptionsMenu({
|
|
|
105
118
|
<ListChecks size={12} /><span>Select sessions</span>
|
|
106
119
|
</button>
|
|
107
120
|
)}
|
|
121
|
+
{onNewWorktree && (
|
|
122
|
+
<button type="button" onClick={onNewWorktree} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-ink/80">
|
|
123
|
+
<GitBranch size={12} /><span>New worktree…</span>
|
|
124
|
+
</button>
|
|
125
|
+
)}
|
|
108
126
|
<button type="button" onClick={onRequestDelete} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 cursor-pointer flex items-center space-x-2 text-error">
|
|
109
127
|
<Trash2 size={12} /><span>Delete Workspace</span>
|
|
110
128
|
</button>
|
|
@@ -12,8 +12,7 @@ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/title
|
|
|
12
12
|
import { ServerSwitcher } from '@/client/components/common/server-switcher';
|
|
13
13
|
import { useDesktopServers } from '@/client/hooks/desktop/servers';
|
|
14
14
|
import { isDesktopShell } from '@/client/lib/desktop/bridge';
|
|
15
|
-
import { dragRegionProps,
|
|
16
|
-
import { WindowControls } from '@/client/components/common/window-controls';
|
|
15
|
+
import { dragRegionProps, titlebarInsetLeft } from '@/client/lib/desktop/titlebar';
|
|
17
16
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
18
17
|
|
|
19
18
|
interface TopNavbarProps {
|
|
@@ -203,10 +202,10 @@ export function TopNavbar({
|
|
|
203
202
|
onToggleEditor={onToggleEditor}
|
|
204
203
|
onSwitchToMobile={onSwitchToMobile}
|
|
205
204
|
/>
|
|
206
|
-
{/* The Windows
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
205
|
+
{/* The Windows/Linux caption buttons come from `HeaderControls` itself:
|
|
206
|
+
a desktop shell window can be narrower than the mobile breakpoint,
|
|
207
|
+
so the phone header has to draw them too — and a control that only
|
|
208
|
+
one layout renders is a control the other layout's window loses. */}
|
|
210
209
|
</div>
|
|
211
210
|
|
|
212
211
|
{menu.anchor && sessionActions && (
|
|
@@ -4,7 +4,7 @@ import { Group, Panel, type PanelImperativeHandle } from '@/client/components/la
|
|
|
4
4
|
import { LeftColumn } from '@/client/components/layout/desktop-layout/LeftColumn';
|
|
5
5
|
import { SettingsModal } from '@/client/components/settings/LazyModal';
|
|
6
6
|
import type { SettingsCategoryId } from '@/shared/types';
|
|
7
|
-
import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
|
|
7
|
+
import { activeProjectForSession, effectiveRootFor } from '@/shared/lib/workspace/active-project';
|
|
8
8
|
import { useFileTabs } from '@/client/hooks/workspace/file-tabs';
|
|
9
9
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
10
10
|
import { TopNavbar } from '@/client/components/layout/desktop-layout/TopNavbar';
|
|
@@ -71,7 +71,13 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
|
|
|
71
71
|
if (folderIdParam) return folders.find(f => String(f.id) === String(folderIdParam)) ?? null;
|
|
72
72
|
return null;
|
|
73
73
|
}, [folders, sessionId, folderIdParam]);
|
|
74
|
-
|
|
74
|
+
// A worktree session runs OUTSIDE its folder's project path, so the panels
|
|
75
|
+
// follow the session's own root — the checkout it left would otherwise be what
|
|
76
|
+
// Files, Search, Git and the terminal read.
|
|
77
|
+
const activeProjectPath = useMemo(
|
|
78
|
+
() => effectiveRootFor(folders, sessionId || '') ?? activeProject?.project_path ?? null,
|
|
79
|
+
[folders, sessionId, activeProject],
|
|
80
|
+
);
|
|
75
81
|
const hasActiveContext = !!activeProject;
|
|
76
82
|
|
|
77
83
|
const handleOpenTab = useCallback(() => {
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* props; this file only decides how the two states look.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { useState } from 'preact/hooks';
|
|
17
18
|
import { ChevronDown, ChevronRight, MoreHorizontal, Pin, Plus } from 'lucide-preact';
|
|
19
|
+
import { NewWorktreeModal } from '@/client/components/workspace/worktree-modal/index';
|
|
18
20
|
import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
|
|
19
21
|
import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
|
|
20
22
|
import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
|
|
@@ -39,6 +41,8 @@ export interface CategoryHeaderProps {
|
|
|
39
41
|
showMenu: boolean;
|
|
40
42
|
onToggleMenu: () => void;
|
|
41
43
|
onNewSession: () => void;
|
|
44
|
+
/** Start a chat in a worktree made from this folder. */
|
|
45
|
+
onNewWorktreeSession?: (folderId: number, worktreePath: string) => void;
|
|
42
46
|
onOpenFolder?: () => void;
|
|
43
47
|
onSelectSessions?: () => void;
|
|
44
48
|
confirmDelete: boolean;
|
|
@@ -58,6 +62,7 @@ export function CategoryHeader({
|
|
|
58
62
|
showMenu,
|
|
59
63
|
onToggleMenu,
|
|
60
64
|
onNewSession,
|
|
65
|
+
onNewWorktreeSession,
|
|
61
66
|
onOpenFolder,
|
|
62
67
|
onSelectSessions,
|
|
63
68
|
confirmDelete,
|
|
@@ -67,6 +72,10 @@ export function CategoryHeader({
|
|
|
67
72
|
onCancelDelete,
|
|
68
73
|
}: CategoryHeaderProps) {
|
|
69
74
|
const ProjectIcon = getProjectIcon(folder.icon);
|
|
75
|
+
// The dialog lives here rather than in the page: the row that opens it is this
|
|
76
|
+
// one, and the folder it belongs to is what the dialog needs.
|
|
77
|
+
const [worktreeDialogOpen, setWorktreeDialogOpen] = useState(false);
|
|
78
|
+
const canCreateWorktree = Boolean(onNewWorktreeSession && folder.project_path);
|
|
70
79
|
|
|
71
80
|
return (
|
|
72
81
|
<div className="group flex items-center justify-between h-7 text-xs font-semibold text-ink px-2 hover:bg-ink/5 rounded-md transition-colors select-none">
|
|
@@ -159,6 +168,7 @@ export function CategoryHeader({
|
|
|
159
168
|
isPinned={folder.isPinned}
|
|
160
169
|
onOpenFolder={onOpenFolder}
|
|
161
170
|
onSelectSessions={onSelectSessions}
|
|
171
|
+
onNewWorktree={canCreateWorktree ? () => { onToggleMenu(); setWorktreeDialogOpen(true); } : undefined}
|
|
162
172
|
confirmDelete={confirmDelete}
|
|
163
173
|
onPin={onPin}
|
|
164
174
|
onDelete={onDelete}
|
|
@@ -170,6 +180,20 @@ export function CategoryHeader({
|
|
|
170
180
|
</div>
|
|
171
181
|
</div>
|
|
172
182
|
)}
|
|
183
|
+
|
|
184
|
+
{canCreateWorktree && (
|
|
185
|
+
<NewWorktreeModal
|
|
186
|
+
isOpen={worktreeDialogOpen}
|
|
187
|
+
onClose={() => setWorktreeDialogOpen(false)}
|
|
188
|
+
root={folder.project_path ?? null}
|
|
189
|
+
folderId={folder.id}
|
|
190
|
+
folderName={folder.name}
|
|
191
|
+
onCreated={(created) => {
|
|
192
|
+
setWorktreeDialogOpen(false);
|
|
193
|
+
onNewWorktreeSession?.(folder.id, created.path);
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
173
197
|
</div>
|
|
174
198
|
);
|
|
175
199
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The folder header's `New worktree…` entry.
|
|
8
|
+
*
|
|
9
|
+
* This row is the ONLY entry point to the dialog on the desktop, and it is
|
|
10
|
+
* reachable through four components (SessionsView → SessionList → Category →
|
|
11
|
+
* CategoryHeader). The prop is optional at every hop, so a hop that forgets to
|
|
12
|
+
* forward it fails silently: the typechecker is satisfied, the menu renders, and
|
|
13
|
+
* the row is simply absent. Measured — that is exactly how the desktop half
|
|
14
|
+
* shipped unreachable while the phone's worked.
|
|
15
|
+
*
|
|
16
|
+
* The header is mounted directly here, which is the half that can be asserted
|
|
17
|
+
* cheaply; the assembled path is what `tests/e2e/specs/worktree.e2e.ts` covers.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
21
|
+
import { Window } from 'happy-dom';
|
|
22
|
+
import { h, render } from 'preact';
|
|
23
|
+
import { act } from 'preact/test-utils';
|
|
24
|
+
import { CategoryHeader } from '@/client/components/layout/session-sidebar/CategoryHeader';
|
|
25
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
26
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
27
|
+
|
|
28
|
+
const folder: WorkspaceFolderData = {
|
|
29
|
+
id: 7,
|
|
30
|
+
name: 'kabmo',
|
|
31
|
+
project_path: '/Users/dev/kabmo',
|
|
32
|
+
isExpanded: true,
|
|
33
|
+
hasMore: false,
|
|
34
|
+
totalSessions: 0,
|
|
35
|
+
sessions: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let host: HTMLElement | null = null;
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
if (host) {
|
|
46
|
+
render(null, host);
|
|
47
|
+
host.remove();
|
|
48
|
+
host = null;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
restoreDomGlobals();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Mount the header with its `⋯` menu already open. */
|
|
57
|
+
function mountHeader(overrides: Partial<Parameters<typeof CategoryHeader>[0]> = {}): HTMLElement {
|
|
58
|
+
host = document.createElement('div');
|
|
59
|
+
document.body.appendChild(host);
|
|
60
|
+
render(
|
|
61
|
+
h(CategoryHeader, {
|
|
62
|
+
folder,
|
|
63
|
+
isOpen: true,
|
|
64
|
+
onToggleFolder: () => {},
|
|
65
|
+
checkbox: null,
|
|
66
|
+
batchActive: false,
|
|
67
|
+
menuRef: { current: null },
|
|
68
|
+
showMenu: true,
|
|
69
|
+
onToggleMenu: () => {},
|
|
70
|
+
onNewSession: () => {},
|
|
71
|
+
confirmDelete: false,
|
|
72
|
+
onPin: () => {},
|
|
73
|
+
onDelete: () => {},
|
|
74
|
+
onRequestDelete: () => {},
|
|
75
|
+
onCancelDelete: () => {},
|
|
76
|
+
...overrides,
|
|
77
|
+
}),
|
|
78
|
+
host,
|
|
79
|
+
);
|
|
80
|
+
return host;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The menu's own buttons, by their words. */
|
|
84
|
+
function menuRows(container: HTMLElement): string[] {
|
|
85
|
+
return [...container.querySelectorAll('button')].map((button) => (button.textContent ?? '').trim()).filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('CategoryHeader worktree entry', () => {
|
|
89
|
+
test('the menu offers New worktree… when a handler is supplied', () => {
|
|
90
|
+
const container = mountHeader({ onNewWorktreeSession: () => {} });
|
|
91
|
+
expect(menuRows(container)).toContain('New worktree…');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('no row without a handler — a folder that cannot create one does not offer it', () => {
|
|
95
|
+
const container = mountHeader();
|
|
96
|
+
expect(menuRows(container).some((row) => row.startsWith('New worktree'))).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a folder with no project path offers nothing: a worktree needs a repository', () => {
|
|
100
|
+
const container = mountHeader({
|
|
101
|
+
folder: { ...folder, project_path: null },
|
|
102
|
+
onNewWorktreeSession: () => {},
|
|
103
|
+
});
|
|
104
|
+
expect(menuRows(container).some((row) => row.startsWith('New worktree'))).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('clicking it opens the dialog, closed on its own name field', async () => {
|
|
108
|
+
const container = mountHeader({ onNewWorktreeSession: () => {} });
|
|
109
|
+
const row = [...container.querySelectorAll('button')].find(
|
|
110
|
+
(button) => (button.textContent ?? '').trim() === 'New worktree…',
|
|
111
|
+
);
|
|
112
|
+
if (!row) throw new Error('no New worktree row rendered');
|
|
113
|
+
|
|
114
|
+
await act(async () => {
|
|
115
|
+
row.click();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// The dialog names the project it will branch from, and starts on a
|
|
119
|
+
// generated branch with its folder following it.
|
|
120
|
+
const text = (document.body.textContent ?? '').replace(/\s+/g, ' ');
|
|
121
|
+
expect(text).toContain('Start from');
|
|
122
|
+
expect(text).toContain('kabmo');
|
|
123
|
+
const branch = (document.body.querySelector('input') as HTMLInputElement | null)?.value ?? '';
|
|
124
|
+
expect(branch).toMatch(/^wt\/[a-z]+-[a-z]+$/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -24,6 +24,7 @@ export function Category({
|
|
|
24
24
|
activeSessionId,
|
|
25
25
|
onSelectSession,
|
|
26
26
|
onNewSessionForFolder,
|
|
27
|
+
onNewWorktreeSession,
|
|
27
28
|
forceExpanded = false,
|
|
28
29
|
showArchived = false,
|
|
29
30
|
sessionStatus = {},
|
|
@@ -36,6 +37,7 @@ export function Category({
|
|
|
36
37
|
activeSessionId: number | string | null;
|
|
37
38
|
onSelectSession: (id: number | string) => void;
|
|
38
39
|
onNewSessionForFolder: (id: number) => void;
|
|
40
|
+
onNewWorktreeSession?: (folderId: number, worktreePath: string) => void;
|
|
39
41
|
forceExpanded?: boolean;
|
|
40
42
|
showArchived?: boolean;
|
|
41
43
|
sessionStatus?: Record<string, 'stream' | 'finish' | 'abort'>;
|
|
@@ -172,6 +174,7 @@ export function Category({
|
|
|
172
174
|
showMenu={showMenu}
|
|
173
175
|
onToggleMenu={() => setShowMenu(!showMenu)}
|
|
174
176
|
onNewSession={() => onNewSessionForFolder(folder.id)}
|
|
177
|
+
onNewWorktreeSession={onNewWorktreeSession}
|
|
175
178
|
onOpenFolder={onOpenFolder}
|
|
176
179
|
onSelectSessions={
|
|
177
180
|
onStartSessionSelection
|
|
@@ -211,6 +214,7 @@ export function Category({
|
|
|
211
214
|
isArchived={session.is_archived === 1}
|
|
212
215
|
status={sessionStatus[sessionKey]}
|
|
213
216
|
awaitingInput={Boolean(session.awaitingInput)}
|
|
217
|
+
worktree={session.worktree}
|
|
214
218
|
timeAgo={relativeTimeAgo(session.updated_at ?? session.created_at)}
|
|
215
219
|
onClick={() => onSelectSession(session.id)}
|
|
216
220
|
onArchive={() => handleArchive(session)}
|