@nextclaw/ui 0.19.0 → 0.19.1

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/{appearance-settings-page-DsMl931U.js → appearance-settings-page-NlQIHbXh.js} +1 -1
  3. package/dist/assets/{channels-list-page-D_B7JGoZ.js → channels-list-page-DhXfsjxF.js} +1 -1
  4. package/dist/assets/{chat-page-D3ZeULTv.js → chat-page-C4DHLN6T.js} +1 -1
  5. package/dist/assets/{desktop-update-config-DO051-QG.js → desktop-update-config-CpsXBUJK.js} +1 -1
  6. package/dist/assets/{index-j6DBnvlr.js → index-Bfr0I0Ga.js} +19 -19
  7. package/dist/assets/index-DcqrfCMy.css +1 -0
  8. package/dist/assets/{model-config-page-CBoh0DVh.js → model-config-page-BRScCkS-.js} +1 -1
  9. package/dist/assets/{provider-scoped-model-input-C2iUtZi7.js → provider-scoped-model-input-CD93ZOgZ.js} +1 -1
  10. package/dist/assets/{providers-config-page-DX1pISTo.js → providers-config-page-CFdgth2A.js} +1 -1
  11. package/dist/assets/remote-DDN70hyU.js +1 -0
  12. package/dist/assets/{runtime-config-page-BVO9B8Ns.js → runtime-config-page-BxJlm6qT.js} +1 -1
  13. package/dist/assets/{search-config-page-BwyeUjKt.js → search-config-page-2VAarRcG.js} +1 -1
  14. package/dist/assets/{secrets-config-page-2O-TzroJ.js → secrets-config-page-KoFrETds.js} +1 -1
  15. package/dist/index.html +2 -2
  16. package/package.json +4 -4
  17. package/src/features/panel-apps/components/__tests__/panel-app-list-item.test.tsx +24 -12
  18. package/src/features/panel-apps/components/__tests__/panel-app-main-sidebar-nav.test.tsx +51 -0
  19. package/src/features/panel-apps/components/__tests__/panel-app-toolbar.test.tsx +59 -5
  20. package/src/features/panel-apps/components/panel-app-list-item.tsx +5 -18
  21. package/src/features/panel-apps/components/panel-app-main-sidebar-menu-item.tsx +37 -0
  22. package/src/features/panel-apps/components/panel-app-main-sidebar-nav.tsx +71 -20
  23. package/src/features/panel-apps/components/panel-app-toolbar.tsx +18 -38
  24. package/src/features/panel-apps/components/panel-apps-list.tsx +0 -9
  25. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -2
  26. package/src/features/right-panel-resources/index.ts +1 -0
  27. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +12 -0
  28. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +8 -0
  29. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +26 -0
  30. package/dist/assets/index-DR3GLGme.css +0 -1
  31. package/dist/assets/remote-D_7_OGoj.js +0 -1
@@ -1,4 +1,5 @@
1
1
  import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
2
3
  import { MemoryRouter } from "react-router-dom";
3
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
5
  import {
@@ -9,10 +10,15 @@ import type { PanelAppEntryView } from "@/shared/lib/api";
9
10
 
10
11
  const mocks = vi.hoisted(() => ({
11
12
  entries: [] as PanelAppEntryView[],
13
+ mutate: vi.fn(),
12
14
  }));
13
15
 
14
16
  vi.mock("@/features/panel-apps/hooks/use-panel-apps", () => ({
15
17
  usePanelApps: () => ({ data: { entries: mocks.entries } }),
18
+ useUpdatePanelAppPreferences: () => ({
19
+ isPending: false,
20
+ mutate: mocks.mutate,
21
+ }),
16
22
  }));
17
23
 
18
24
  function createEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
@@ -38,6 +44,7 @@ function createEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryV
38
44
  describe("PanelAppMainSidebarNav", () => {
39
45
  beforeEach(() => {
40
46
  mocks.entries = [];
47
+ mocks.mutate.mockReset();
41
48
  });
42
49
 
43
50
  it("projects only manually added apps in persisted order", () => {
@@ -71,6 +78,50 @@ describe("PanelAppMainSidebarNav", () => {
71
78
  expect(screen.queryByText("Hidden")).toBeNull();
72
79
  });
73
80
 
81
+ it("removes an expanded entry from its hover actions without nesting the button in the link", async () => {
82
+ const user = userEvent.setup();
83
+ mocks.entries = [
84
+ createEntry({ appId: "publisher.todo", mainSidebar: true, title: "Todo" }),
85
+ ];
86
+
87
+ render(
88
+ <MemoryRouter initialEntries={["/apps/panel/publisher.todo"]}>
89
+ <PanelAppMainSidebarNav isCollapsed={false} />
90
+ </MemoryRouter>,
91
+ );
92
+
93
+ const link = screen.getByRole("link", { name: "Todo" });
94
+ const menuButton = screen.getByRole("button", {
95
+ name: "More panel app actions: Todo",
96
+ });
97
+ expect(link.contains(menuButton)).toBe(false);
98
+ expect(menuButton.className).toContain("group-hover/panel-app:opacity-100");
99
+
100
+ await user.click(menuButton);
101
+ await user.click(screen.getByRole("button", { name: "Remove from main sidebar" }));
102
+
103
+ expect(mocks.mutate).toHaveBeenCalledWith({
104
+ id: "demo",
105
+ preferences: { mainSidebar: false },
106
+ });
107
+ expect(link.getAttribute("aria-current")).toBe("page");
108
+ });
109
+
110
+ it("keeps the collapsed rail as one navigation target", () => {
111
+ mocks.entries = [
112
+ createEntry({ appId: "publisher.todo", mainSidebar: true, title: "Todo" }),
113
+ ];
114
+
115
+ render(
116
+ <MemoryRouter>
117
+ <PanelAppMainSidebarNav isCollapsed />
118
+ </MemoryRouter>,
119
+ );
120
+
121
+ expect(screen.getByRole("link", { name: "Todo" })).toBeTruthy();
122
+ expect(screen.queryByRole("button", { name: "More panel app actions: Todo" })).toBeNull();
123
+ });
124
+
74
125
  it("renders no section when no app was manually added", () => {
75
126
  const { container } = render(
76
127
  <MemoryRouter>
@@ -1,18 +1,48 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { describe, expect, it, vi } from 'vitest';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { PanelAppToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
5
5
 
6
+ const mainSidebarMutation = vi.hoisted(() => ({
7
+ isPending: false,
8
+ mutate: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@/features/panel-apps/hooks/use-panel-apps', () => ({
12
+ usePanelApps: vi.fn(),
13
+ useUpdatePanelAppPreferences: () => mainSidebarMutation,
14
+ }));
15
+
16
+ const entry = {
17
+ appId: 'ink-assistant',
18
+ clientDeclared: false,
19
+ clientGranted: false,
20
+ contentPath: '/api/panel-apps/ink-assistant/content',
21
+ createdAt: '2026-08-19T00:00:00.000Z',
22
+ favorite: false,
23
+ fileName: 'ink-assistant.panel.html',
24
+ id: 'ink-assistant',
25
+ kind: 'single-file' as const,
26
+ mainSidebar: false,
27
+ openCount: 0,
28
+ sizeBytes: 10,
29
+ title: '墨爪助手',
30
+ updatedAt: '2026-08-19T00:00:00.000Z',
31
+ };
32
+
6
33
  describe('PanelAppToolbar', () => {
34
+ beforeEach(() => {
35
+ mainSidebarMutation.isPending = false;
36
+ mainSidebarMutation.mutate.mockReset();
37
+ });
38
+
7
39
  it('keeps placement in the overflow menu without adding a second back action', async () => {
8
40
  const user = userEvent.setup();
9
- const onToggleMainSidebar = vi.fn();
10
41
  render(
11
42
  <PanelAppToolbar
12
43
  appTitle="墨爪助手"
13
- mainSidebar={false}
44
+ entry={entry}
14
45
  onRefresh={vi.fn()}
15
- onToggleMainSidebar={onToggleMainSidebar}
16
46
  />,
17
47
  );
18
48
 
@@ -25,7 +55,31 @@ describe('PanelAppToolbar', () => {
25
55
  expect(addButton.getAttribute('aria-pressed')).toBe('false');
26
56
  await user.click(addButton);
27
57
 
28
- expect(onToggleMainSidebar).toHaveBeenCalledTimes(1);
58
+ expect(mainSidebarMutation.mutate).toHaveBeenCalledWith({
59
+ id: 'ink-assistant',
60
+ preferences: { mainSidebar: true },
61
+ });
29
62
  expect(screen.queryByRole('button', { name: 'Add to main sidebar' })).toBeNull();
30
63
  });
64
+
65
+ it('uses the same menu item to remove an app already in the main sidebar', async () => {
66
+ const user = userEvent.setup();
67
+ render(
68
+ <PanelAppToolbar
69
+ appTitle="墨爪助手"
70
+ entry={{ ...entry, mainSidebar: true }}
71
+ onRefresh={vi.fn()}
72
+ />,
73
+ );
74
+
75
+ await user.click(screen.getByRole('button', { name: 'More panel app actions' }));
76
+ const removeButton = screen.getByRole('button', { name: 'Remove from main sidebar' });
77
+ expect(removeButton.getAttribute('aria-pressed')).toBe('true');
78
+ await user.click(removeButton);
79
+
80
+ expect(mainSidebarMutation.mutate).toHaveBeenCalledWith({
81
+ id: 'ink-assistant',
82
+ preferences: { mainSidebar: false },
83
+ });
84
+ });
31
85
  });
@@ -1,7 +1,8 @@
1
1
  import { useState, type MouseEvent } from 'react';
2
- import { MoreVertical, PanelLeft, PanelLeftDashed, Star, Trash2, type LucideIcon } from 'lucide-react';
2
+ import { MoreVertical, Star, Trash2, type LucideIcon } from 'lucide-react';
3
3
  import type { PanelAppEntryView } from '@/shared/lib/api';
4
4
  import { PanelAppIcon } from '@/features/panel-apps/components/panel-app-icon';
5
+ import { PanelAppMainSidebarMenuItem } from '@/features/panel-apps/components/panel-app-main-sidebar-menu-item';
5
6
  import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
6
7
  import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
7
8
  import { getLanguage, getLocale, t } from '@/shared/lib/i18n';
@@ -11,20 +12,16 @@ export function PanelAppListItem({
11
12
  deletePending,
12
13
  entry,
13
14
  favoritePending,
14
- mainSidebarPending,
15
15
  onDelete,
16
16
  onOpen,
17
17
  onToggleFavorite,
18
- onToggleMainSidebar,
19
18
  }: {
20
19
  deletePending: boolean;
21
20
  entry: PanelAppEntryView;
22
21
  favoritePending: boolean;
23
- mainSidebarPending: boolean;
24
22
  onDelete: () => void;
25
23
  onOpen: () => void;
26
24
  onToggleFavorite: () => void;
27
- onToggleMainSidebar: () => void;
28
25
  }) {
29
26
  const [isMenuOpen, setIsMenuOpen] = useState(false);
30
27
  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -37,18 +34,10 @@ export function PanelAppListItem({
37
34
  event.stopPropagation();
38
35
  onToggleFavorite();
39
36
  };
40
- const handleMainSidebar = () => {
41
- setIsMenuOpen(false);
42
- onToggleMainSidebar();
43
- };
44
37
  const openDeleteDialog = () => {
45
38
  setIsMenuOpen(false);
46
39
  setIsDeleteDialogOpen(true);
47
40
  };
48
- const mainSidebarLabel = entry.mainSidebar
49
- ? t('panelAppsRemoveFromMainSidebar')
50
- : t('panelAppsAddToMainSidebar');
51
-
52
41
  return (
53
42
  <div className="group w-full min-w-0 rounded-lg border border-border/60 bg-card px-2.5 py-2.5 transition-colors hover:bg-muted/40">
54
43
  <div className="flex min-w-0 items-start gap-2">
@@ -87,11 +76,9 @@ export function PanelAppListItem({
87
76
  </button>
88
77
  </PopoverTrigger>
89
78
  <PopoverContent align="end" className="w-48 rounded-xl p-1.5">
90
- <PanelAppMenuItem
91
- disabled={mainSidebarPending}
92
- icon={entry.mainSidebar ? PanelLeftDashed : PanelLeft}
93
- label={mainSidebarLabel}
94
- onClick={handleMainSidebar}
79
+ <PanelAppMainSidebarMenuItem
80
+ entry={entry}
81
+ onSelect={() => setIsMenuOpen(false)}
95
82
  />
96
83
  <PanelAppMenuItem
97
84
  destructive
@@ -0,0 +1,37 @@
1
+ import { PanelLeft, PanelLeftDashed } from 'lucide-react';
2
+ import { useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
3
+ import type { PanelAppEntryView } from '@/shared/lib/api';
4
+ import { t } from '@/shared/lib/i18n';
5
+
6
+ export function PanelAppMainSidebarMenuItem({
7
+ entry,
8
+ onSelect,
9
+ }: {
10
+ entry: PanelAppEntryView;
11
+ onSelect?: () => void;
12
+ }) {
13
+ const updatePreferences = useUpdatePanelAppPreferences();
14
+ const label = entry.mainSidebar
15
+ ? t('panelAppsRemoveFromMainSidebar')
16
+ : t('panelAppsAddToMainSidebar');
17
+ const Icon = entry.mainSidebar ? PanelLeftDashed : PanelLeft;
18
+
19
+ return (
20
+ <button
21
+ type="button"
22
+ disabled={updatePreferences.isPending}
23
+ aria-pressed={entry.mainSidebar}
24
+ className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
25
+ onClick={() => {
26
+ onSelect?.();
27
+ updatePreferences.mutate({
28
+ id: entry.id,
29
+ preferences: { mainSidebar: !entry.mainSidebar },
30
+ });
31
+ }}
32
+ >
33
+ <Icon className="h-4 w-4 shrink-0" />
34
+ <span>{label}</span>
35
+ </button>
36
+ );
37
+ }
@@ -1,9 +1,13 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, useState } from "react";
2
+ import { MoreVertical } from "lucide-react";
2
3
  import { SidebarNavLinkItem } from "@/app/components/layout/sidebar-items";
3
4
  import { SIDEBAR_RAIL_STACK_CLASS } from "@/app/components/layout/sidebar-rail.styles";
4
5
  import { PanelAppIcon } from "@/features/panel-apps/components/panel-app-icon";
6
+ import { PanelAppMainSidebarMenuItem } from "@/features/panel-apps/components/panel-app-main-sidebar-menu-item";
5
7
  import { usePanelApps } from "@/features/panel-apps/hooks/use-panel-apps";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover";
6
9
  import type { PanelAppEntryView } from "@/shared/lib/api";
10
+ import { t } from "@/shared/lib/i18n";
7
11
  import { cn } from "@/shared/lib/utils";
8
12
 
9
13
  const MAIN_SIDEBAR_FALLBACK_ORDER = Number.MAX_SAFE_INTEGER;
@@ -45,28 +49,75 @@ export function PanelAppMainSidebarNav({
45
49
  >
46
50
  <ul className={isCollapsed ? SIDEBAR_RAIL_STACK_CLASS : "space-y-0.5"}>
47
51
  {entries.map((entry) => (
48
- <li
52
+ <PanelAppMainSidebarNavItem
49
53
  key={entry.appId}
50
- className={isCollapsed ? "flex justify-center" : undefined}
51
- >
52
- <SidebarNavLinkItem
53
- to={`/apps/panel/${encodeURIComponent(entry.appId)}`}
54
- label={entry.title}
55
- iconNode={(
56
- <PanelAppIcon
57
- icon={entry.icon}
58
- title={entry.title}
59
- className="h-full w-full text-[13px]"
60
- imageClassName="rounded-[3px]"
61
- />
62
- )}
63
- density="compact"
64
- collapsed={isCollapsed}
65
- className={isCollapsed ? undefined : "rounded-lg px-2.5 py-1.5"}
66
- />
67
- </li>
54
+ entry={entry}
55
+ isCollapsed={isCollapsed}
56
+ />
68
57
  ))}
69
58
  </ul>
70
59
  </div>
71
60
  );
72
61
  }
62
+
63
+ function PanelAppMainSidebarNavItem({
64
+ entry,
65
+ isCollapsed,
66
+ }: {
67
+ entry: PanelAppEntryView;
68
+ isCollapsed: boolean;
69
+ }) {
70
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
71
+ const link = (
72
+ <SidebarNavLinkItem
73
+ to={`/apps/panel/${encodeURIComponent(entry.appId)}`}
74
+ label={entry.title}
75
+ iconNode={(
76
+ <PanelAppIcon
77
+ icon={entry.icon}
78
+ title={entry.title}
79
+ className="h-full w-full text-[13px]"
80
+ imageClassName="rounded-[3px]"
81
+ />
82
+ )}
83
+ density="compact"
84
+ collapsed={isCollapsed}
85
+ className={isCollapsed ? undefined : "rounded-lg py-1.5 pl-2.5 pr-9"}
86
+ />
87
+ );
88
+
89
+ if (isCollapsed) {
90
+ return <li className="flex justify-center">{link}</li>;
91
+ }
92
+
93
+ const menuLabel = `${t("panelAppsMoreActions")}: ${entry.title}`;
94
+ return (
95
+ <li>
96
+ <div className="group/panel-app relative">
97
+ {link}
98
+ <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
99
+ <PopoverTrigger asChild>
100
+ <button
101
+ type="button"
102
+ aria-label={menuLabel}
103
+ className={cn(
104
+ "absolute right-1 top-1/2 z-[1] -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-[color,background-color,opacity] hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border",
105
+ isMenuOpen
106
+ ? "pointer-events-auto opacity-100"
107
+ : "pointer-events-none opacity-0 group-hover/panel-app:pointer-events-auto group-hover/panel-app:opacity-100 group-focus-within/panel-app:pointer-events-auto group-focus-within/panel-app:opacity-100",
108
+ )}
109
+ >
110
+ <MoreVertical className="h-4 w-4" />
111
+ </button>
112
+ </PopoverTrigger>
113
+ <PopoverContent align="end" className="w-48 rounded-xl p-1.5">
114
+ <PanelAppMainSidebarMenuItem
115
+ entry={entry}
116
+ onSelect={() => setIsMenuOpen(false)}
117
+ />
118
+ </PopoverContent>
119
+ </Popover>
120
+ </div>
121
+ </li>
122
+ );
123
+ }
@@ -1,29 +1,25 @@
1
1
  import { useMemo, useState } from 'react';
2
- import { MoreVertical, PanelLeft, PanelLeftDashed, RefreshCw } from 'lucide-react';
3
- import { usePanelApps, useUpdatePanelAppPreferences } from '@/features/panel-apps/hooks/use-panel-apps';
2
+ import { MoreVertical, RefreshCw } from 'lucide-react';
3
+ import { PanelAppMainSidebarMenuItem } from '@/features/panel-apps/components/panel-app-main-sidebar-menu-item';
4
+ import { usePanelApps } from '@/features/panel-apps/hooks/use-panel-apps';
5
+ import { readPanelAppIdFromTab } from '@/features/right-panel-resources';
4
6
  import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
5
7
  import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
8
+ import type { PanelAppEntryView } from '@/shared/lib/api';
6
9
  import { t } from '@/shared/lib/i18n';
7
10
 
8
11
  type PanelAppToolbarProps = {
9
12
  appTitle: string;
10
- mainSidebar?: boolean;
11
- mainSidebarPending?: boolean;
13
+ entry?: PanelAppEntryView;
12
14
  onRefresh: () => void;
13
- onToggleMainSidebar?: () => void;
14
15
  };
15
16
 
16
17
  export function PanelAppToolbar({
17
18
  appTitle,
18
- mainSidebar = false,
19
- mainSidebarPending = false,
19
+ entry,
20
20
  onRefresh,
21
- onToggleMainSidebar,
22
21
  }: PanelAppToolbarProps) {
23
22
  const [isMenuOpen, setIsMenuOpen] = useState(false);
24
- const mainSidebarLabel = mainSidebar
25
- ? t('panelAppsRemoveFromMainSidebar')
26
- : t('panelAppsAddToMainSidebar');
27
23
 
28
24
  return (
29
25
  <div className="flex items-center justify-between gap-2 border-b border-border/70 bg-card px-3.5 py-2 shrink-0">
@@ -33,7 +29,7 @@ export function PanelAppToolbar({
33
29
  >
34
30
  {appTitle}
35
31
  </span>
36
- {onToggleMainSidebar ? (
32
+ {entry ? (
37
33
  <Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
38
34
  <PopoverTrigger asChild>
39
35
  <IconActionButton
@@ -43,21 +39,10 @@ export function PanelAppToolbar({
43
39
  />
44
40
  </PopoverTrigger>
45
41
  <PopoverContent align="end" className="w-48 rounded-xl p-1.5">
46
- <button
47
- type="button"
48
- disabled={mainSidebarPending}
49
- aria-pressed={mainSidebar}
50
- className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50"
51
- onClick={() => {
52
- setIsMenuOpen(false);
53
- onToggleMainSidebar();
54
- }}
55
- >
56
- {mainSidebar
57
- ? <PanelLeftDashed className="h-4 w-4 shrink-0" />
58
- : <PanelLeft className="h-4 w-4 shrink-0" />}
59
- <span>{mainSidebarLabel}</span>
60
- </button>
42
+ <PanelAppMainSidebarMenuItem
43
+ entry={entry}
44
+ onSelect={() => setIsMenuOpen(false)}
45
+ />
61
46
  </PopoverContent>
62
47
  </Popover>
63
48
  ) : null}
@@ -72,16 +57,18 @@ export function PanelAppToolbar({
72
57
  }
73
58
 
74
59
  export function PanelAppDocBrowserToolbar({
75
- appId,
76
60
  appTitle,
61
+ currentUrl,
77
62
  onRefresh,
63
+ resourceUri,
78
64
  }: {
79
- appId: string | null;
80
65
  appTitle: string;
66
+ currentUrl: string;
81
67
  onRefresh: () => void;
68
+ resourceUri?: string;
82
69
  }) {
83
70
  const panelApps = usePanelApps();
84
- const updatePreferences = useUpdatePanelAppPreferences();
71
+ const appId = readPanelAppIdFromTab({ currentUrl, resourceUri });
85
72
  const entry = useMemo(
86
73
  () => panelApps.data?.entries.find((candidate) => candidate.appId === appId),
87
74
  [appId, panelApps.data?.entries],
@@ -90,15 +77,8 @@ export function PanelAppDocBrowserToolbar({
90
77
  return (
91
78
  <PanelAppToolbar
92
79
  appTitle={entry?.title ?? appTitle}
93
- mainSidebar={entry?.mainSidebar}
94
- mainSidebarPending={updatePreferences.isPending}
80
+ entry={entry}
95
81
  onRefresh={onRefresh}
96
- onToggleMainSidebar={entry
97
- ? () => updatePreferences.mutate({
98
- id: entry.id,
99
- preferences: { mainSidebar: !entry.mainSidebar },
100
- })
101
- : undefined}
102
82
  />
103
83
  );
104
84
  }
@@ -48,13 +48,6 @@ export function PanelAppsList({
48
48
  });
49
49
  };
50
50
 
51
- const toggleMainSidebar = (entry: PanelAppEntryView) => {
52
- updatePreferences.mutate({
53
- id: entry.id,
54
- preferences: { mainSidebar: !entry.mainSidebar },
55
- });
56
- };
57
-
58
51
  const deleteEntry = (entry: PanelAppEntryView) => {
59
52
  deletePanelApp.mutate(entry.id);
60
53
  };
@@ -129,11 +122,9 @@ export function PanelAppsList({
129
122
  entry={entry}
130
123
  deletePending={deletePanelApp.isPending}
131
124
  favoritePending={updatePreferences.isPending}
132
- mainSidebarPending={updatePreferences.isPending}
133
125
  onDelete={() => deleteEntry(entry)}
134
126
  onOpen={() => void openPanelApp(entry)}
135
127
  onToggleFavorite={() => toggleFavorite(entry)}
136
- onToggleMainSidebar={() => toggleMainSidebar(entry)}
137
128
  />
138
129
  ))}
139
130
  </div>
@@ -6,7 +6,6 @@ import { AppsPanel, type AppsPanelTab } from '@/features/apps';
6
6
  import { PanelAppDocBrowserToolbar } from '@/features/panel-apps/components/panel-app-toolbar';
7
7
  import {
8
8
  createPanelAppRightPanelResourceTarget,
9
- readPanelAppIdFromResourceUri,
10
9
  RIGHT_PANEL_APPS_TAB_KIND,
11
10
  RIGHT_PANEL_APPS_URL,
12
11
  RIGHT_PANEL_HOME_TAB_KIND,
@@ -78,9 +77,10 @@ export const PANEL_APPS_DOC_BROWSER_RENDERERS: DocBrowserCustomTabRenderers = {
78
77
  renderIcon: () => <AppWindow className="w-4 h-4 text-primary shrink-0" />,
79
78
  renderToolbar: ({ refreshIframe, tab }) => (
80
79
  <PanelAppDocBrowserToolbar
81
- appId={readPanelAppIdFromResourceUri(tab.resourceUri ?? tab.currentUrl)}
82
80
  appTitle={tab.title || t('panelAppsTitle')}
81
+ currentUrl={tab.currentUrl}
83
82
  onRefresh={refreshIframe}
83
+ resourceUri={tab.resourceUri}
84
84
  />
85
85
  ),
86
86
  supportsScrollRestoration: true,
@@ -11,6 +11,7 @@ export {
11
11
  createPanelAppResourceUri,
12
12
  createPanelAppRightPanelResourceTarget,
13
13
  readPanelAppIdFromResourceUri,
14
+ readPanelAppIdFromTab,
14
15
  RIGHT_PANEL_APPS_TAB_KIND,
15
16
  RIGHT_PANEL_APPS_URL,
16
17
  RIGHT_PANEL_HOME_TAB_KIND,
@@ -6,6 +6,7 @@ import {
6
6
  createPanelAppResourceUri,
7
7
  createPanelAppRightPanelResourceTarget,
8
8
  readPanelAppIdFromResourceUri,
9
+ readPanelAppIdFromTab,
9
10
  } from '@/features/right-panel-resources/utils/right-panel-resource-uri.utils';
10
11
 
11
12
  function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelAppEntryView {
@@ -78,6 +79,17 @@ describe('right-panel-resource uri utils', () => {
78
79
  expect(readPanelAppIdFromResourceUri('nextclaw://apps')).toBeNull();
79
80
  });
80
81
 
82
+ it('falls back to the stable current URL for restored panel app shortcuts', () => {
83
+ expect(readPanelAppIdFromTab({
84
+ currentUrl: '/api/panel-apps/publisher.todo/content',
85
+ resourceUri: 'nextclaw://panel-app',
86
+ })).toBe('publisher.todo');
87
+ expect(readPanelAppIdFromTab({
88
+ currentUrl: '/api/panel-apps/publisher.todo/content?path=%2Ftmp%2Ftodo.panel',
89
+ resourceUri: 'nextclaw://panel-app/publisher.todo?path=%2Ftmp%2Ftodo.panel',
90
+ })).toBeNull();
91
+ });
92
+
81
93
  it('keeps panel app image icons as dock image icons', () => {
82
94
  const target = createPanelAppRightPanelResourceTarget(createPanelAppEntry({
83
95
  icon: '/api/panel-apps/demo/assets/icon.png',
@@ -107,6 +107,14 @@ export function readPanelAppIdFromResourceUri(value: string): string | null {
107
107
  : readPanelAppIdFromParsedResourceUri(uri);
108
108
  }
109
109
 
110
+ export function readPanelAppIdFromTab(
111
+ tab: Pick<DocBrowserTab, 'currentUrl' | 'resourceUri'>,
112
+ ): string | null {
113
+ return (tab.resourceUri
114
+ ? readPanelAppIdFromResourceUri(tab.resourceUri)
115
+ : null) ?? readPanelAppIdFromResourceUri(tab.currentUrl);
116
+ }
117
+
110
118
  function isPanelAppImageIcon(icon: string): boolean {
111
119
  return (
112
120
  icon.startsWith('data:image/') ||
@@ -537,6 +537,32 @@ describe("DocBrowser panel app navigation", () => {
537
537
  preferences: { mainSidebar: true },
538
538
  });
539
539
  });
540
+
541
+ it("keeps placement actions when a restored shortcut only has a stable current URL", async () => {
542
+ const user = userEvent.setup();
543
+ const panelAppTab: DocBrowserTab = {
544
+ id: "piano",
545
+ kind: "panel-app",
546
+ title: "Piano",
547
+ currentUrl: "/api/panel-apps/piano/content",
548
+ resourceUri: "nextclaw://panel-app",
549
+ history: ["/api/panel-apps/piano/content"],
550
+ historyIndex: 0,
551
+ navVersion: 0,
552
+ };
553
+ docBrowserState.tabs = [panelAppTab];
554
+ docBrowserState.activeTabId = panelAppTab.id;
555
+ docBrowserState.currentTab = panelAppTab;
556
+
557
+ render(<DocBrowser customTabRenderers={PANEL_APPS_DOC_BROWSER_RENDERERS} />);
558
+
559
+ await user.click(screen.getByRole("button", { name: "More panel app actions" }));
560
+ await user.click(screen.getByRole("button", { name: "Add to main sidebar" }));
561
+ expect(panelAppHooks.mutate).toHaveBeenCalledWith({
562
+ id: "piano",
563
+ preferences: { mainSidebar: true },
564
+ });
565
+ });
540
566
  });
541
567
 
542
568
  describe("DocBrowser scroll restoration", () => {