@nextclaw/ui 0.13.1 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +128 -0
- package/dist/assets/app-presenter-provider-DIcnNB9s.js +3 -0
- package/dist/assets/{wrench-qVIwShTF.js → boxes-BvA-I63l.js} +1 -1
- package/dist/assets/channels-list-page-wK3l7p5R.js +8 -0
- package/dist/assets/chat-page-DgehvdJT.js +105 -0
- package/dist/assets/{confirm-dialog-B0TP-vPd.js → confirm-dialog-CGrKHbAW.js} +1 -1
- package/dist/assets/desktop-update-config-CehWxSSg.js +1 -0
- package/dist/assets/doc-browser-BgxmVsTx.js +1 -0
- package/dist/assets/doc-browser-CZn5-Qbu.js +1 -0
- package/dist/assets/doc-browser-context-B8gk7HMr.js +1 -0
- package/dist/assets/index-DORmh0Cy.css +1 -0
- package/dist/assets/index-DcFpvnge.js +101 -0
- package/dist/assets/key-round-D41Oe35x.js +1 -0
- package/dist/assets/mcp-marketplace-page-DBLhJWOJ.js +1 -0
- package/dist/assets/{mcp-marketplace-page-DDjeHd1I.js → mcp-marketplace-page-Dvi8mCBd.js} +2 -2
- package/dist/assets/model-config-B-EJXGtO.js +1 -0
- package/dist/assets/popover-YieElO8c.js +1 -0
- package/dist/assets/{provider-scoped-model-input-t1tCP5y5.js → provider-scoped-model-input-BPRNGqYn.js} +1 -1
- package/dist/assets/providers-list-CtBpL1ZY.js +1 -0
- package/dist/assets/remote-BGHbHoHf.js +1 -0
- package/dist/assets/rotate-cw-jueVCNJQ.js +1 -0
- package/dist/assets/runtime-config-page-C1Hv7mad.js +1 -0
- package/dist/assets/search-config-G2O1fcRb.js +1 -0
- package/dist/assets/{secrets-config-D1AUSEXX.js → secrets-config-TenzxYbE.js} +2 -2
- package/dist/assets/status-dot-BSMG7ZaC.js +1 -0
- package/dist/assets/tabs-D1CjpWNX.js +1 -0
- package/dist/assets/trash-2-DSWTsARW.js +1 -0
- package/dist/index.html +19 -13
- package/package.json +8 -8
- package/src/app/components/layout/app-layout.test.tsx +2 -1
- package/src/app/components/layout/app-layout.tsx +18 -4
- package/src/app/components/layout/sidebar.tsx +1 -1
- package/src/app/presenters/app.presenter.ts +6 -0
- package/src/features/chat/components/layout/chat-sidebar.test.tsx +1 -2
- package/src/features/chat/components/layout/chat-sidebar.tsx +1 -1
- package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +17 -13
- package/src/features/right-panel-resources/components/right-panel-resource-home-page.tsx +96 -0
- package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +171 -0
- package/src/features/right-panel-resources/index.ts +18 -0
- package/src/features/right-panel-resources/types/right-panel-resource.types.ts +36 -0
- package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.test.ts +38 -0
- package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.ts +89 -0
- package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +48 -0
- package/src/features/side-dock/components/side-dock.test.tsx +58 -0
- package/src/features/side-dock/components/side-dock.tsx +117 -0
- package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +43 -0
- package/src/features/side-dock/index.ts +10 -0
- package/src/features/side-dock/managers/side-dock.manager.test.ts +62 -0
- package/src/features/side-dock/managers/side-dock.manager.ts +58 -0
- package/src/features/side-dock/stores/side-dock.store.ts +42 -0
- package/src/features/side-dock/types/side-dock.types.ts +29 -0
- package/src/features/side-dock/utils/side-dock-item.utils.test.ts +77 -0
- package/src/features/side-dock/utils/side-dock-item.utils.ts +126 -0
- package/src/platforms/desktop/components/desktop-app-shell.tsx +3 -0
- package/src/shared/components/doc-browser/doc-browser-context.test.tsx +52 -5
- package/src/shared/components/doc-browser/doc-browser-context.tsx +22 -14
- package/src/shared/components/doc-browser/doc-browser-home-page.tsx +9 -89
- package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -0
- package/src/shared/components/doc-browser/doc-browser.test.tsx +5 -3
- package/src/shared/components/doc-browser/doc-browser.tsx +3 -1
- package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +92 -43
- package/src/shared/components/doc-browser/types/doc-browser.types.ts +24 -0
- package/src/shared/components/doc-browser/utils/doc-browser-route-resolver.utils.ts +65 -0
- package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +5 -4
- package/src/shared/lib/resource-uri/index.ts +11 -0
- package/src/shared/lib/resource-uri/types/resource-uri.types.ts +19 -0
- package/src/shared/lib/resource-uri/utils/resource-uri-resolver.utils.test.ts +49 -0
- package/src/shared/lib/resource-uri/utils/resource-uri-resolver.utils.ts +35 -0
- package/src/shared/lib/resource-uri/utils/resource-uri.utils.ts +48 -0
- package/dist/assets/channels-list-page-DXH1qYLs.js +0 -8
- package/dist/assets/chat-page-Cpo0AFHb.js +0 -105
- package/dist/assets/desktop-update-config-Be8x1jNy.js +0 -1
- package/dist/assets/doc-browser-BI7HSaMK.js +0 -3
- package/dist/assets/doc-browser-context-DB6TTHeE.js +0 -1
- package/dist/assets/doc-browser-route-registry.utils-x5ijvk0u.js +0 -1
- package/dist/assets/doc-browser-yTfN7UBH.js +0 -1
- package/dist/assets/index-GVTiCf1s.js +0 -101
- package/dist/assets/index-v0fAGXQW.css +0 -1
- package/dist/assets/mcp-marketplace-page-WUkc7n94.js +0 -1
- package/dist/assets/model-config-BjXMpU9U.js +0 -1
- package/dist/assets/providers-list-C1D5p9kK.js +0 -1
- package/dist/assets/remote-BHeENKxL.js +0 -1
- package/dist/assets/runtime-config-page--O0Wq01S.js +0 -1
- package/dist/assets/search-config-Dzpizo-_.js +0 -1
- package/src/shared/components/doc-browser/utils/doc-browser-route-registry.utils.ts +0 -232
- /package/dist/assets/{book-open-cPX0Za7Z.js → book-open-CRotBOF2.js} +0 -0
- /package/dist/assets/{config-hints-CY1lg9IE.js → config-hints-A7G_jMTU.js} +0 -0
- /package/dist/assets/{config-split-page-BFh_Qixx.js → config-split-page-BnTO1mAM.js} +0 -0
- /package/dist/assets/{external-link-C3usm3se.js → external-link-DHjFdCpy.js} +0 -0
- /package/dist/assets/{play-CqNi0u02.js → play-C-0vo7hi.js} +0 -0
- /package/dist/assets/{plus-BcIC8dG1.js → plus-Cr_akLp2.js} +0 -0
- /package/dist/assets/{save-D10M5vQg.js → save-aYdSdK97.js} +0 -0
- /package/dist/assets/{setting-row-8VkukJoH.js → setting-row-BNu0qhOM.js} +0 -0
- /package/dist/assets/{tabs-custom-BBXu1OGs.js → tabs-custom-CRSN1ekg.js} +0 -0
- /package/dist/assets/{tag-chip-ix15P-r4.js → tag-chip-DEashiw6.js} +0 -0
- /package/dist/assets/{x-Chb07O-Y.js → x-D1v_vb54.js} +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { AppWindow, BookOpen, Boxes, Plus } from 'lucide-react';
|
|
2
|
+
import type { LucideIcon } from 'lucide-react';
|
|
3
|
+
import type { SideDockManager } from '@/features/side-dock/managers/side-dock.manager';
|
|
4
|
+
import { SIDE_DOCK_BUILT_IN_ITEMS } from '@/features/side-dock/configs/side-dock-built-in-items.config';
|
|
5
|
+
import { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
|
|
6
|
+
import type {
|
|
7
|
+
SideDockIconName,
|
|
8
|
+
SideDockItem,
|
|
9
|
+
SideDockItemIcon,
|
|
10
|
+
} from '@/features/side-dock/types/side-dock.types';
|
|
11
|
+
import { mergeSideDockItems } from '@/features/side-dock/utils/side-dock-item.utils';
|
|
12
|
+
import {
|
|
13
|
+
useDocBrowser,
|
|
14
|
+
type DocBrowserTab,
|
|
15
|
+
} from '@/shared/components/doc-browser/doc-browser-context';
|
|
16
|
+
import {
|
|
17
|
+
Tooltip,
|
|
18
|
+
TooltipContent,
|
|
19
|
+
TooltipProvider,
|
|
20
|
+
TooltipTrigger,
|
|
21
|
+
} from '@/shared/components/ui/tooltip';
|
|
22
|
+
import { t } from '@/shared/lib/i18n';
|
|
23
|
+
import { cn } from '@/shared/lib/utils';
|
|
24
|
+
|
|
25
|
+
type SideDockProps = {
|
|
26
|
+
manager: SideDockManager;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SIDE_DOCK_ICON_COMPONENTS: Record<SideDockIconName, LucideIcon> = {
|
|
30
|
+
apps: Boxes,
|
|
31
|
+
docs: BookOpen,
|
|
32
|
+
'new-tab': Plus,
|
|
33
|
+
'service-apps': AppWindow,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function SideDockItemIconView({ icon }: { icon: SideDockItemIcon }) {
|
|
37
|
+
if (icon.type === 'url') {
|
|
38
|
+
return <img src={icon.url} alt="" className="h-5 w-5 rounded object-cover" />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const Icon = SIDE_DOCK_ICON_COMPONENTS[icon.name];
|
|
42
|
+
return <Icon className="h-5 w-5" aria-hidden="true" />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function SideDockButton({
|
|
46
|
+
active,
|
|
47
|
+
item,
|
|
48
|
+
onOpen,
|
|
49
|
+
}: {
|
|
50
|
+
active: boolean;
|
|
51
|
+
item: SideDockItem;
|
|
52
|
+
onOpen: (item: SideDockItem) => void;
|
|
53
|
+
}) {
|
|
54
|
+
const label = t(item.labelKey);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Tooltip>
|
|
58
|
+
<TooltipTrigger asChild>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
aria-label={label}
|
|
62
|
+
title={label}
|
|
63
|
+
data-side-dock-item-id={item.id}
|
|
64
|
+
onClick={() => onOpen(item)}
|
|
65
|
+
className={cn(
|
|
66
|
+
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors',
|
|
67
|
+
'hover:bg-primary/10 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
68
|
+
active
|
|
69
|
+
? 'bg-primary/10 text-primary'
|
|
70
|
+
: 'bg-transparent',
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
<SideDockItemIconView icon={item.icon} />
|
|
74
|
+
</button>
|
|
75
|
+
</TooltipTrigger>
|
|
76
|
+
<TooltipContent side="left">{label}</TooltipContent>
|
|
77
|
+
</Tooltip>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isSideDockItemActive(
|
|
82
|
+
item: SideDockItem,
|
|
83
|
+
isDocBrowserOpen: boolean,
|
|
84
|
+
currentTab?: DocBrowserTab,
|
|
85
|
+
): boolean {
|
|
86
|
+
if (!isDocBrowserOpen || !currentTab) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (currentTab.currentUrl === item.target.uri) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
return item.id === 'docs' && currentTab.kind === 'docs';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function SideDock({ manager }: SideDockProps) {
|
|
96
|
+
const pinnedItems = useSideDockStore((state) => state.pinnedItems);
|
|
97
|
+
const { currentTab, isOpen } = useDocBrowser();
|
|
98
|
+
const items = mergeSideDockItems(SIDE_DOCK_BUILT_IN_ITEMS, pinnedItems);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<TooltipProvider delayDuration={250}>
|
|
102
|
+
<aside
|
|
103
|
+
data-testid="side-dock"
|
|
104
|
+
className="z-30 flex h-full w-14 shrink-0 flex-col items-center gap-1 border-l border-border/60 bg-background/95 px-2 py-3"
|
|
105
|
+
>
|
|
106
|
+
{items.map((item) => (
|
|
107
|
+
<SideDockButton
|
|
108
|
+
key={item.id}
|
|
109
|
+
active={isSideDockItemActive(item, isOpen, currentTab)}
|
|
110
|
+
item={item}
|
|
111
|
+
onOpen={manager.openItem}
|
|
112
|
+
/>
|
|
113
|
+
))}
|
|
114
|
+
</aside>
|
|
115
|
+
</TooltipProvider>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RIGHT_PANEL_APPS_URL,
|
|
3
|
+
RIGHT_PANEL_HOME_URL,
|
|
4
|
+
RIGHT_PANEL_SERVICE_APPS_URL,
|
|
5
|
+
} from '@/features/right-panel-resources';
|
|
6
|
+
import type { SideDockItem } from '@/features/side-dock/types/side-dock.types';
|
|
7
|
+
|
|
8
|
+
export const SIDE_DOCK_DOCS_URL = 'nextclaw://docs';
|
|
9
|
+
|
|
10
|
+
export const SIDE_DOCK_BUILT_IN_ITEMS: SideDockItem[] = [
|
|
11
|
+
{
|
|
12
|
+
builtIn: true,
|
|
13
|
+
icon: { type: 'builtin', name: 'apps' },
|
|
14
|
+
id: 'apps',
|
|
15
|
+
labelKey: 'appsTitle',
|
|
16
|
+
removable: false,
|
|
17
|
+
target: { type: 'right-panel-resource', uri: RIGHT_PANEL_APPS_URL },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
builtIn: true,
|
|
21
|
+
icon: { type: 'builtin', name: 'service-apps' },
|
|
22
|
+
id: 'service-apps',
|
|
23
|
+
labelKey: 'serviceAppsTitle',
|
|
24
|
+
removable: false,
|
|
25
|
+
target: { type: 'right-panel-resource', uri: RIGHT_PANEL_SERVICE_APPS_URL },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
builtIn: true,
|
|
29
|
+
icon: { type: 'builtin', name: 'docs' },
|
|
30
|
+
id: 'docs',
|
|
31
|
+
labelKey: 'docBrowserHelp',
|
|
32
|
+
removable: false,
|
|
33
|
+
target: { type: 'right-panel-resource', uri: SIDE_DOCK_DOCS_URL },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
builtIn: true,
|
|
37
|
+
icon: { type: 'builtin', name: 'new-tab' },
|
|
38
|
+
id: 'new-tab',
|
|
39
|
+
labelKey: 'docBrowserHomeTitle',
|
|
40
|
+
removable: false,
|
|
41
|
+
target: { type: 'right-panel-resource', uri: RIGHT_PANEL_HOME_URL },
|
|
42
|
+
},
|
|
43
|
+
];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { SideDock } from '@/features/side-dock/components/side-dock';
|
|
2
|
+
export { SideDockManager } from '@/features/side-dock/managers/side-dock.manager';
|
|
3
|
+
export type {
|
|
4
|
+
SideDockIconName,
|
|
5
|
+
SideDockItem,
|
|
6
|
+
SideDockItemIcon,
|
|
7
|
+
SideDockItemId,
|
|
8
|
+
SideDockItemTarget,
|
|
9
|
+
SideDockPinnedItem,
|
|
10
|
+
} from '@/features/side-dock/types/side-dock.types';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { SideDockManager } from '@/features/side-dock/managers/side-dock.manager';
|
|
3
|
+
import { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
|
|
4
|
+
import type { SideDockItem } from '@/features/side-dock/types/side-dock.types';
|
|
5
|
+
import type { DocBrowserManager } from '@/shared/components/doc-browser/managers/doc-browser.manager';
|
|
6
|
+
|
|
7
|
+
const customItem: SideDockItem = {
|
|
8
|
+
builtIn: false,
|
|
9
|
+
icon: { type: 'builtin', name: 'docs' },
|
|
10
|
+
id: 'custom-docs',
|
|
11
|
+
labelKey: 'docBrowserHelp',
|
|
12
|
+
removable: true,
|
|
13
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://docs/custom' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('SideDockManager', () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
window.localStorage.clear();
|
|
19
|
+
useSideDockStore.getState().setPinnedItems([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('opens right panel resource items through DocBrowserManager', () => {
|
|
23
|
+
const open = vi.fn();
|
|
24
|
+
const manager = new SideDockManager({ open } as unknown as DocBrowserManager);
|
|
25
|
+
|
|
26
|
+
manager.openItem(customItem);
|
|
27
|
+
|
|
28
|
+
expect(open).toHaveBeenCalledWith('nextclaw://docs/custom');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('pins removable items and keeps built-in items immutable', () => {
|
|
32
|
+
const manager = new SideDockManager({ open: vi.fn() } as unknown as DocBrowserManager);
|
|
33
|
+
|
|
34
|
+
manager.pinItem(customItem, '2026-06-02T00:00:00.000Z');
|
|
35
|
+
manager.pinItem({ ...customItem, builtIn: true, id: 'built-in-copy', removable: false });
|
|
36
|
+
|
|
37
|
+
expect(useSideDockStore.getState().pinnedItems).toEqual([
|
|
38
|
+
{
|
|
39
|
+
createdAt: '2026-06-02T00:00:00.000Z',
|
|
40
|
+
icon: { type: 'builtin', name: 'docs' },
|
|
41
|
+
id: 'custom-docs',
|
|
42
|
+
labelKey: 'docBrowserHelp',
|
|
43
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://docs/custom' },
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('unpins and reorders pinned items', () => {
|
|
49
|
+
const manager = new SideDockManager({ open: vi.fn() } as unknown as DocBrowserManager);
|
|
50
|
+
|
|
51
|
+
manager.pinItem(customItem, '2026-06-02T00:00:00.000Z');
|
|
52
|
+
manager.pinItem({
|
|
53
|
+
...customItem,
|
|
54
|
+
id: 'custom-apps',
|
|
55
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://apps/custom' },
|
|
56
|
+
}, '2026-06-02T00:00:01.000Z');
|
|
57
|
+
manager.reorderPinnedItems(['custom-apps', 'custom-docs']);
|
|
58
|
+
manager.unpinItem('custom-docs');
|
|
59
|
+
|
|
60
|
+
expect(useSideDockStore.getState().pinnedItems.map((item) => item.id)).toEqual(['custom-apps']);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { SIDE_DOCK_BUILT_IN_ITEMS } from '@/features/side-dock/configs/side-dock-built-in-items.config';
|
|
2
|
+
import { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
|
|
3
|
+
import type { SideDockItem, SideDockPinnedItem } from '@/features/side-dock/types/side-dock.types';
|
|
4
|
+
import {
|
|
5
|
+
createPinnedSideDockItem,
|
|
6
|
+
mergeSideDockItems,
|
|
7
|
+
} from '@/features/side-dock/utils/side-dock-item.utils';
|
|
8
|
+
import type { DocBrowserManager } from '@/shared/components/doc-browser/managers/doc-browser.manager';
|
|
9
|
+
|
|
10
|
+
export class SideDockManager {
|
|
11
|
+
constructor(private readonly docBrowserManager: DocBrowserManager) {}
|
|
12
|
+
|
|
13
|
+
readonly getItems = (): SideDockItem[] => mergeSideDockItems(
|
|
14
|
+
SIDE_DOCK_BUILT_IN_ITEMS,
|
|
15
|
+
useSideDockStore.getState().pinnedItems,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
readonly openItem = (item: Pick<SideDockItem, 'target'>): void => {
|
|
19
|
+
if (item.target.type === 'right-panel-resource') {
|
|
20
|
+
this.docBrowserManager.open(item.target.uri);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
readonly pinItem = (item: SideDockItem, createdAt: string = new Date().toISOString()): void => {
|
|
25
|
+
if (!item.removable) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
this.setPinnedItems((items) => {
|
|
29
|
+
if (items.some((pinnedItem) => pinnedItem.id === item.id || pinnedItem.target.uri === item.target.uri)) {
|
|
30
|
+
return items;
|
|
31
|
+
}
|
|
32
|
+
return [...items, createPinnedSideDockItem(item, createdAt)];
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
readonly unpinItem = (itemId: string): void => {
|
|
37
|
+
this.setPinnedItems((items) => items.filter((item) => item.id !== itemId));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
readonly reorderPinnedItems = (itemIds: string[]): void => {
|
|
41
|
+
this.setPinnedItems((items) => {
|
|
42
|
+
const itemsById = new Map(items.map((item) => [item.id, item]));
|
|
43
|
+
const orderedItems = itemIds
|
|
44
|
+
.map((id) => itemsById.get(id))
|
|
45
|
+
.filter((item): item is SideDockPinnedItem => item !== undefined);
|
|
46
|
+
const orderedIds = new Set(orderedItems.map((item) => item.id));
|
|
47
|
+
const remainingItems = items.filter((item) => !orderedIds.has(item.id));
|
|
48
|
+
return [...orderedItems, ...remainingItems];
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
private readonly setPinnedItems = (
|
|
53
|
+
updater: (items: SideDockPinnedItem[]) => SideDockPinnedItem[],
|
|
54
|
+
): void => {
|
|
55
|
+
const { pinnedItems, setPinnedItems } = useSideDockStore.getState();
|
|
56
|
+
setPinnedItems(updater(pinnedItems));
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import { createJSONStorage, persist } from 'zustand/middleware';
|
|
3
|
+
import type { SideDockPinnedItem } from '@/features/side-dock/types/side-dock.types';
|
|
4
|
+
import { normalizeSideDockPinnedItems } from '@/features/side-dock/utils/side-dock-item.utils';
|
|
5
|
+
|
|
6
|
+
const SIDE_DOCK_STORAGE_KEY = 'nextclaw.side-dock.state';
|
|
7
|
+
const SIDE_DOCK_STORAGE_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
type SideDockStore = {
|
|
10
|
+
pinnedItems: SideDockPinnedItem[];
|
|
11
|
+
setPinnedItems: (items: SideDockPinnedItem[]) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return !!value && typeof value === 'object';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const useSideDockStore = create<SideDockStore>()(
|
|
19
|
+
persist(
|
|
20
|
+
(set) => ({
|
|
21
|
+
pinnedItems: [],
|
|
22
|
+
setPinnedItems: (items) => set({ pinnedItems: normalizeSideDockPinnedItems(items) }),
|
|
23
|
+
}),
|
|
24
|
+
{
|
|
25
|
+
name: SIDE_DOCK_STORAGE_KEY,
|
|
26
|
+
version: SIDE_DOCK_STORAGE_VERSION,
|
|
27
|
+
storage: createJSONStorage(() => window.localStorage),
|
|
28
|
+
partialize: (state) => ({
|
|
29
|
+
pinnedItems: state.pinnedItems,
|
|
30
|
+
}),
|
|
31
|
+
merge: (persistedState, currentState) => {
|
|
32
|
+
const pinnedItems = isRecord(persistedState)
|
|
33
|
+
? normalizeSideDockPinnedItems(persistedState.pinnedItems)
|
|
34
|
+
: [];
|
|
35
|
+
return {
|
|
36
|
+
...currentState,
|
|
37
|
+
pinnedItems,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type SideDockItemId = string;
|
|
2
|
+
|
|
3
|
+
export type SideDockItemTarget = {
|
|
4
|
+
type: 'right-panel-resource';
|
|
5
|
+
uri: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type SideDockIconName = 'apps' | 'docs' | 'new-tab' | 'service-apps';
|
|
9
|
+
|
|
10
|
+
export type SideDockItemIcon =
|
|
11
|
+
| { type: 'builtin'; name: SideDockIconName }
|
|
12
|
+
| { type: 'url'; url: string };
|
|
13
|
+
|
|
14
|
+
export type SideDockItem = {
|
|
15
|
+
builtIn: boolean;
|
|
16
|
+
icon: SideDockItemIcon;
|
|
17
|
+
id: SideDockItemId;
|
|
18
|
+
labelKey: string;
|
|
19
|
+
removable: boolean;
|
|
20
|
+
target: SideDockItemTarget;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SideDockPinnedItem = {
|
|
24
|
+
createdAt: string;
|
|
25
|
+
icon: SideDockItemIcon;
|
|
26
|
+
id: SideDockItemId;
|
|
27
|
+
labelKey: string;
|
|
28
|
+
target: SideDockItemTarget;
|
|
29
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { SideDockItem } from '@/features/side-dock/types/side-dock.types';
|
|
3
|
+
import {
|
|
4
|
+
mergeSideDockItems,
|
|
5
|
+
normalizeSideDockPinnedItem,
|
|
6
|
+
normalizeSideDockPinnedItems,
|
|
7
|
+
} from '@/features/side-dock/utils/side-dock-item.utils';
|
|
8
|
+
|
|
9
|
+
const builtInItem: SideDockItem = {
|
|
10
|
+
builtIn: true,
|
|
11
|
+
icon: { type: 'builtin', name: 'apps' },
|
|
12
|
+
id: 'apps',
|
|
13
|
+
labelKey: 'appsTitle',
|
|
14
|
+
removable: false,
|
|
15
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://apps' },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('side-dock-item utils', () => {
|
|
19
|
+
it('normalizes persisted pinned items and drops malformed entries', () => {
|
|
20
|
+
const items = normalizeSideDockPinnedItems([
|
|
21
|
+
{
|
|
22
|
+
createdAt: '2026-06-02T00:00:00.000Z',
|
|
23
|
+
icon: { type: 'builtin', name: 'docs' },
|
|
24
|
+
id: 'docs-custom',
|
|
25
|
+
labelKey: 'docBrowserHelp',
|
|
26
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://docs' },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
createdAt: '2026-06-02T00:00:00.000Z',
|
|
30
|
+
icon: { type: 'builtin', name: 'unknown' },
|
|
31
|
+
id: 'bad-icon',
|
|
32
|
+
labelKey: 'bad',
|
|
33
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://bad' },
|
|
34
|
+
},
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
expect(items).toEqual([
|
|
38
|
+
{
|
|
39
|
+
createdAt: '2026-06-02T00:00:00.000Z',
|
|
40
|
+
icon: { type: 'builtin', name: 'docs' },
|
|
41
|
+
id: 'docs-custom',
|
|
42
|
+
labelKey: 'docBrowserHelp',
|
|
43
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://docs' },
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects invalid single pinned item payloads', () => {
|
|
49
|
+
expect(normalizeSideDockPinnedItem({ id: 'missing-fields' })).toBeNull();
|
|
50
|
+
expect(normalizeSideDockPinnedItem(null)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps built-ins first and ignores pinned duplicates', () => {
|
|
54
|
+
const items = mergeSideDockItems(
|
|
55
|
+
[builtInItem],
|
|
56
|
+
[
|
|
57
|
+
{
|
|
58
|
+
createdAt: '2026-06-02T00:00:00.000Z',
|
|
59
|
+
icon: { type: 'builtin', name: 'docs' },
|
|
60
|
+
id: 'docs-custom',
|
|
61
|
+
labelKey: 'docBrowserHelp',
|
|
62
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://docs' },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
createdAt: '2026-06-02T00:00:00.000Z',
|
|
66
|
+
icon: { type: 'builtin', name: 'apps' },
|
|
67
|
+
id: 'apps-copy',
|
|
68
|
+
labelKey: 'appsTitle',
|
|
69
|
+
target: { type: 'right-panel-resource', uri: 'nextclaw://apps' },
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(items.map((item) => item.id)).toEqual(['apps', 'docs-custom']);
|
|
75
|
+
expect(items[1]?.removable).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SideDockIconName,
|
|
3
|
+
SideDockItem,
|
|
4
|
+
SideDockItemIcon,
|
|
5
|
+
SideDockPinnedItem,
|
|
6
|
+
} from '@/features/side-dock/types/side-dock.types';
|
|
7
|
+
|
|
8
|
+
const SIDE_DOCK_BUILTIN_ICON_NAMES: SideDockIconName[] = [
|
|
9
|
+
'apps',
|
|
10
|
+
'docs',
|
|
11
|
+
'new-tab',
|
|
12
|
+
'service-apps',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return !!value && typeof value === 'object';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isSideDockIconName(value: unknown): value is SideDockIconName {
|
|
20
|
+
return SIDE_DOCK_BUILTIN_ICON_NAMES.includes(value as SideDockIconName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeSideDockIcon(value: unknown): SideDockItemIcon | null {
|
|
24
|
+
if (!isRecord(value) || typeof value.type !== 'string') {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (value.type === 'builtin' && isSideDockIconName(value.name)) {
|
|
29
|
+
return {
|
|
30
|
+
type: 'builtin',
|
|
31
|
+
name: value.name,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (value.type === 'url' && typeof value.url === 'string' && value.url.trim().length > 0) {
|
|
36
|
+
return {
|
|
37
|
+
type: 'url',
|
|
38
|
+
url: value.url.trim(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizeSideDockPinnedItem(value: unknown): SideDockPinnedItem | null {
|
|
46
|
+
if (
|
|
47
|
+
!isRecord(value)
|
|
48
|
+
|| typeof value.id !== 'string'
|
|
49
|
+
|| value.id.trim().length === 0
|
|
50
|
+
|| typeof value.labelKey !== 'string'
|
|
51
|
+
|| value.labelKey.trim().length === 0
|
|
52
|
+
|| typeof value.createdAt !== 'string'
|
|
53
|
+
|| value.createdAt.trim().length === 0
|
|
54
|
+
|| !isRecord(value.target)
|
|
55
|
+
|| value.target.type !== 'right-panel-resource'
|
|
56
|
+
|| typeof value.target.uri !== 'string'
|
|
57
|
+
|| value.target.uri.trim().length === 0
|
|
58
|
+
) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const icon = normalizeSideDockIcon(value.icon);
|
|
63
|
+
if (!icon) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
createdAt: value.createdAt,
|
|
69
|
+
icon,
|
|
70
|
+
id: value.id.trim(),
|
|
71
|
+
labelKey: value.labelKey.trim(),
|
|
72
|
+
target: {
|
|
73
|
+
type: 'right-panel-resource',
|
|
74
|
+
uri: value.target.uri.trim(),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function normalizeSideDockPinnedItems(value: unknown): SideDockPinnedItem[] {
|
|
80
|
+
if (!Array.isArray(value)) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const itemsById = new Map<string, SideDockPinnedItem>();
|
|
85
|
+
value.forEach((item) => {
|
|
86
|
+
const normalized = normalizeSideDockPinnedItem(item);
|
|
87
|
+
if (normalized) {
|
|
88
|
+
itemsById.set(normalized.id, normalized);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return Array.from(itemsById.values());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createPinnedSideDockItem(item: SideDockItem, createdAt: string): SideDockPinnedItem {
|
|
95
|
+
return {
|
|
96
|
+
createdAt,
|
|
97
|
+
icon: item.icon,
|
|
98
|
+
id: item.id,
|
|
99
|
+
labelKey: item.labelKey,
|
|
100
|
+
target: item.target,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createSideDockItemFromPinnedItem(item: SideDockPinnedItem): SideDockItem {
|
|
105
|
+
return {
|
|
106
|
+
builtIn: false,
|
|
107
|
+
icon: item.icon,
|
|
108
|
+
id: item.id,
|
|
109
|
+
labelKey: item.labelKey,
|
|
110
|
+
removable: true,
|
|
111
|
+
target: item.target,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function mergeSideDockItems(
|
|
116
|
+
builtInItems: SideDockItem[],
|
|
117
|
+
pinnedItems: SideDockPinnedItem[],
|
|
118
|
+
): SideDockItem[] {
|
|
119
|
+
const seenIds = new Set(builtInItems.map((item) => item.id));
|
|
120
|
+
const seenUris = new Set(builtInItems.map((item) => item.target.uri));
|
|
121
|
+
const customItems = pinnedItems
|
|
122
|
+
.filter((item) => !seenIds.has(item.id) && !seenUris.has(item.target.uri))
|
|
123
|
+
.map(createSideDockItemFromPinnedItem);
|
|
124
|
+
|
|
125
|
+
return [...builtInItems, ...customItems];
|
|
126
|
+
}
|
|
@@ -21,6 +21,7 @@ type DesktopAppShellProps = {
|
|
|
21
21
|
isDocBrowserOpen: boolean;
|
|
22
22
|
docBrowserMode: "floating" | "docked";
|
|
23
23
|
docBrowserRenderers?: DocBrowserCustomTabRenderers;
|
|
24
|
+
sideDock?: React.ReactNode;
|
|
24
25
|
children: React.ReactNode;
|
|
25
26
|
};
|
|
26
27
|
|
|
@@ -30,6 +31,7 @@ export function DesktopAppShell({
|
|
|
30
31
|
isDocBrowserOpen,
|
|
31
32
|
docBrowserMode,
|
|
32
33
|
docBrowserRenderers = {},
|
|
34
|
+
sideDock,
|
|
33
35
|
children,
|
|
34
36
|
}: DesktopAppShellProps) {
|
|
35
37
|
const isMainRoute = isMainWorkspaceRoute(pathname);
|
|
@@ -68,6 +70,7 @@ export function DesktopAppShell({
|
|
|
68
70
|
<DocBrowser customTabRenderers={docBrowserRenderers} />
|
|
69
71
|
</Suspense>
|
|
70
72
|
) : null}
|
|
73
|
+
{sideDock}
|
|
71
74
|
</div>
|
|
72
75
|
</div>
|
|
73
76
|
{showMobileBottomNav ? <MobileBottomNav /> : null}
|