@nextclaw/ui 0.12.9 → 0.12.10
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 +61 -0
- package/dist/assets/ChannelsList-M9FTK1Ak.js +8 -0
- package/dist/assets/DocBrowser-CH7-GxlL.js +1 -0
- package/dist/assets/{DocBrowser-6ReNjvzF.js → DocBrowser-DMfr0Oow.js} +1 -1
- package/dist/assets/{DocBrowserContext-B6SpA7Qs.js → DocBrowserContext-BXydqby-.js} +1 -1
- package/dist/assets/{LogoBadge-ByNLYg65.js → LogoBadge-hO7tY7hE.js} +1 -1
- package/dist/assets/ModelConfig-CNIgLf0e.js +1 -0
- package/dist/assets/{ProviderScopedModelInput-Da7khnBA.js → ProviderScopedModelInput-B3HWP4oz.js} +1 -1
- package/dist/assets/ProvidersList-CHjMnRhX.js +1 -0
- package/dist/assets/RuntimeConfig-psp8nMSG.js +1 -0
- package/dist/assets/SearchConfig-CSoKip1f.js +1 -0
- package/dist/assets/{SecretsConfig-D281Rotl.js → SecretsConfig-MEt6MjuD.js} +2 -2
- package/dist/assets/SessionsConfig-DifCiXwR.js +2 -0
- package/dist/assets/{app-query-client-VnFElj4E.js → app-query-client-9jNewezV.js} +1 -1
- package/dist/assets/{book-open-BdcxxoQu.js → book-open-DzdUViDm.js} +1 -1
- package/dist/assets/chat-page-CLp0UV0Y.js +58 -0
- package/dist/assets/chat-session-display-DsYHx0RZ.js +1 -0
- package/dist/assets/{chunk-JZWAC4HX-DK5HPmIK.js → chunk-JZWAC4HX-C5dEc8hV.js} +1 -1
- package/dist/assets/{client-_i4MU2bB.js → client-C-8fH7-c.js} +1 -1
- package/dist/assets/{config-DtIQwrHF.js → config-CBScxsdV.js} +1 -1
- package/dist/assets/config-split-page-BUout_Ak.js +1 -0
- package/dist/assets/{createLucideIcon-BSeTgkZW.js → createLucideIcon-dy5ie7Ox.js} +1 -1
- package/dist/assets/desktop-update-config-2BS6BMkW.js +1 -0
- package/dist/assets/{dist-ccBFUi-o.js → dist-BruyLa92.js} +1 -1
- package/dist/assets/{dist-6TrrnPCR.js → dist-Cy7_j6hA.js} +1 -1
- package/dist/assets/{download-BhDxnyvU.js → download-BD0ETkB-.js} +1 -1
- package/dist/assets/{external-link-BgErLCNT.js → external-link-kZSAO8nT.js} +1 -1
- package/dist/assets/{hash-Bl7dr_UG.js → hash-BHJC2Ovu.js} +1 -1
- package/dist/assets/{i18n-eDHeDY0n.js → i18n-CpTZLchQ.js} +1 -1
- package/dist/assets/index-mW8W2FUu.css +1 -0
- package/dist/assets/index-zDZfXoI4.js +6 -0
- package/dist/assets/{infiniteQueryBehavior-ZDS92Qpp.js → infiniteQueryBehavior-CyER9hv0.js} +1 -1
- package/dist/assets/loader-circle-Bc2gCU33.js +1 -0
- package/dist/assets/{logos-x89HbrZ4.js → logos-B7gRObP8.js} +1 -1
- package/dist/assets/marketplace-page-3qVMnF3d.js +1 -0
- package/dist/assets/marketplace-page-BhFIeQzI.js +49 -0
- package/dist/assets/mcp-marketplace-page-DYfteJ1D.js +40 -0
- package/dist/assets/{page-layout-vZnghcFy.js → page-layout-0UcO9H9Z.js} +1 -1
- package/dist/assets/play-CKDjSQFL.js +1 -0
- package/dist/assets/plus-CG0QrVY_.js +1 -0
- package/dist/assets/{refresh-ccw-DT98i__E.js → refresh-ccw-COVhNHtN.js} +1 -1
- package/dist/assets/{refresh-cw-C47QSEwg.js → refresh-cw-Bcv40SXy.js} +1 -1
- package/dist/assets/remote-access-page-CWHG-sug.js +1 -0
- package/dist/assets/{rotate-cw-JtFzpNn6.js → rotate-cw-oHMKJMC8.js} +1 -1
- package/dist/assets/{save-3S6-H3Xw.js → save-EqJPOF0G.js} +1 -1
- package/dist/assets/search-BCAlB8nz.js +1 -0
- package/dist/assets/security-config-Slh0Mayz.js +1 -0
- package/dist/assets/select-CVz0t7MF.js +41 -0
- package/dist/assets/setting-row-CbVHAuQt.js +1 -0
- package/dist/assets/skeleton-D5rdKvzy.js +1 -0
- package/dist/assets/{status-dot-vbanNPFU.js → status-dot-DpPtVzQT.js} +1 -1
- package/dist/assets/{switch-BsLtHOH-.js → switch-CM29eCAR.js} +1 -1
- package/dist/assets/{tabs-custom-D3HYMt6k.js → tabs-custom-YcZUWn3o.js} +1 -1
- package/dist/assets/tag-chip-DMXdnLcj.js +1 -0
- package/dist/assets/{trash-2-G48scll7.js → trash-2-mJT6oWa2.js} +1 -1
- package/dist/assets/{use-infinite-scroll-loader-DkNhD-42.js → use-infinite-scroll-loader-DJ1L81Dz.js} +1 -1
- package/dist/assets/{useConfirmDialog-BkvTN-vd.js → useConfirmDialog-BsVuqu1x.js} +1 -1
- package/dist/assets/{useMutation-CBWjE2uj.js → useMutation-CNcz2fgt.js} +1 -1
- package/dist/assets/x-Czwxm82I.js +1 -0
- package/dist/index.html +22 -22
- package/dist/runtime-icons/claude.ico +0 -0
- package/dist/runtime-icons/codex-openai.svg +6 -0
- package/dist/runtime-icons/hermes-agent.png +0 -0
- package/package.json +6 -6
- package/public/runtime-icons/claude.ico +0 -0
- package/public/runtime-icons/codex-openai.svg +6 -0
- package/public/runtime-icons/hermes-agent.png +0 -0
- package/src/account/components/account-panel.tsx +217 -97
- package/src/account/managers/account.manager.ts +3 -2
- package/src/api/chat-session-type.types.ts +7 -0
- package/src/api/runtime-control.types.ts +8 -0
- package/src/api/types.ts +8 -0
- package/src/app.tsx +221 -57
- package/src/components/agents/agent-dialogs.tsx +499 -0
- package/src/components/agents/agents-page.test.tsx +238 -0
- package/src/components/agents/agents-page.tsx +435 -0
- package/src/components/chat/ChatSidebar.tsx +11 -35
- package/src/components/chat/chat-conversation-panel.test.tsx +20 -0
- package/src/components/chat/chat-conversation-panel.tsx +83 -13
- package/src/components/chat/chat-page-shell.tsx +19 -13
- package/src/components/chat/chat-session-type-option-item.test.tsx +46 -0
- package/src/components/chat/chat-session-type-option-item.tsx +68 -0
- package/src/components/chat/chat-session-workspace-file-preview.test.tsx +87 -0
- package/src/components/chat/chat-session-workspace-file-preview.tsx +14 -43
- package/src/components/chat/chat-session-workspace-panel-nav.tsx +8 -2
- package/src/components/chat/chat-sidebar-project-groups.tsx +11 -36
- package/src/components/chat/ncp/__tests__/ncp-session-adapter.cancelled-tool.test.ts +77 -0
- package/src/components/chat/ncp/ncp-chat-page.tsx +2 -0
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +1 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +3 -0
- package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +10 -4
- package/src/components/chat/stores/chat-input.store.ts +2 -1
- package/src/components/chat/stores/chat-thread.store.ts +3 -1
- package/src/components/chat/useChatSessionTypeState.ts +10 -1
- package/src/components/chat/workspace/chat-session-workspace-file-breadcrumbs.tsx +86 -0
- package/src/components/common/BrandHeader.tsx +3 -1
- package/src/components/common/session-context-icon.tsx +15 -2
- package/src/components/common/{TagInput.tsx → tag-input.tsx} +25 -17
- package/src/components/config/ChannelForm.test.tsx +89 -3
- package/src/components/config/ChannelForm.tsx +157 -188
- package/src/components/config/ChannelsList.test.tsx +163 -119
- package/src/components/config/ChannelsList.tsx +90 -101
- package/src/components/config/ProviderForm.tsx +108 -146
- package/src/components/config/ProvidersList.tsx +100 -123
- package/src/components/config/SearchConfig.tsx +423 -393
- package/src/components/config/channel-form-fields-section.tsx +70 -37
- package/src/components/config/config-split-page.tsx +109 -0
- package/src/components/config/provider-enabled-field.tsx +17 -10
- package/src/components/config/runtime-control-card.test.tsx +56 -0
- package/src/components/config/runtime-control-card.tsx +25 -0
- package/src/components/config/runtime-presence-card.tsx +93 -79
- package/src/components/layout/AppLayout.tsx +25 -37
- package/src/components/layout/app-layout.test.tsx +46 -14
- package/src/components/layout/runtime-status-entry.test.tsx +157 -0
- package/src/components/layout/runtime-status-entry.tsx +143 -0
- package/src/components/marketplace/marketplace-detail-doc.ts +93 -0
- package/src/components/marketplace/marketplace-list-card.tsx +288 -0
- package/src/components/marketplace/marketplace-page-data.ts +129 -0
- package/src/components/marketplace/marketplace-page.test.tsx +339 -0
- package/src/components/marketplace/marketplace-page.tsx +596 -0
- package/src/components/marketplace/mcp/mcp-marketplace-card.tsx +128 -0
- package/src/components/marketplace/mcp/mcp-marketplace-dialogs.tsx +191 -0
- package/src/components/marketplace/mcp/mcp-marketplace-doc.ts +152 -0
- package/src/components/marketplace/mcp/mcp-marketplace-page.test.tsx +223 -0
- package/src/components/marketplace/mcp/mcp-marketplace-page.tsx +414 -0
- package/src/components/remote/remote-access-page.test.tsx +105 -0
- package/src/components/remote/remote-access-page.tsx +248 -0
- package/src/components/ui/notice-card.tsx +129 -0
- package/src/components/ui/setting-row.tsx +51 -0
- package/src/components/ui/tag-chip.tsx +39 -0
- package/src/components/ui/textarea.tsx +19 -0
- package/src/hooks/useConfig.ts +2 -1
- package/src/index.css +24 -0
- package/src/lib/app-resource-uri.test.ts +20 -0
- package/src/lib/app-resource-uri.ts +29 -0
- package/src/lib/i18n.remote.ts +1 -1
- package/src/lib/i18n.runtime-control.ts +31 -0
- package/src/lib/i18n.ts +5 -8
- package/src/lib/session-context.utils.test.ts +71 -0
- package/src/lib/session-context.utils.ts +28 -3
- package/src/lib/session-project/workspace-file-breadcrumb.test.ts +83 -0
- package/src/lib/session-project/workspace-file-breadcrumb.ts +188 -0
- package/dist/assets/ChannelsList-Ita2Zm1_.js +0 -8
- package/dist/assets/DocBrowser-BNwbPHf4.js +0 -1
- package/dist/assets/MarketplacePage-CjX2MWww.js +0 -1
- package/dist/assets/MarketplacePage-D0sDlYX4.js +0 -49
- package/dist/assets/McpMarketplacePage-BGKJm1sJ.js +0 -40
- package/dist/assets/ModelConfig-BzZenCH-.js +0 -1
- package/dist/assets/ProvidersList-BbVzRxjY.js +0 -1
- package/dist/assets/RemoteAccessPage-BaDH_X1Q.js +0 -1
- package/dist/assets/RuntimeConfig-F_XKGgLm.js +0 -1
- package/dist/assets/SearchConfig-BGkzXQP-.js +0 -1
- package/dist/assets/SessionsConfig-ChHQ7M5c.js +0 -2
- package/dist/assets/chat-page-Doe0yTtB.js +0 -58
- package/dist/assets/chat-session-display-cw78aiI_.js +0 -1
- package/dist/assets/config-layout-CHs0mAaR.js +0 -1
- package/dist/assets/desktop-update-config-Dpcf4BKG.js +0 -1
- package/dist/assets/index-CF9xve0E.js +0 -6
- package/dist/assets/index-FgA52VBt.css +0 -1
- package/dist/assets/loader-circle-ACM1s51e.js +0 -1
- package/dist/assets/play-CFUwCA2E.js +0 -1
- package/dist/assets/plus-rYsv72JG.js +0 -1
- package/dist/assets/popover-Bg1VoTZ6.js +0 -1
- package/dist/assets/search-3kFR_zh9.js +0 -1
- package/dist/assets/security-config-BWaiARNk.js +0 -1
- package/dist/assets/select-DJ2MUjBB.js +0 -41
- package/dist/assets/skeleton-ByQepn0M.js +0 -1
- package/dist/assets/x-ByDbItbq.js +0 -1
- package/src/components/agents/AgentDialogs.tsx +0 -400
- package/src/components/agents/AgentsPage.test.tsx +0 -217
- package/src/components/agents/AgentsPage.tsx +0 -352
- package/src/components/config/config-layout.ts +0 -10
- package/src/components/marketplace/MarketplacePage.test.tsx +0 -322
- package/src/components/marketplace/MarketplacePage.tsx +0 -827
- package/src/components/marketplace/mcp/McpMarketplacePage.test.tsx +0 -208
- package/src/components/marketplace/mcp/McpMarketplacePage.tsx +0 -580
- package/src/components/remote/RemoteAccessPage.test.tsx +0 -103
- package/src/components/remote/RemoteAccessPage.tsx +0 -144
|
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
|
|
|
7
7
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
8
8
|
import { SelectItem } from '@/components/ui/select';
|
|
9
9
|
import { ChatSidebarSessionItem } from '@/components/chat/chat-sidebar-session-item';
|
|
10
|
+
import { ChatSessionTypeOptionItem } from '@/components/chat/chat-session-type-option-item';
|
|
10
11
|
import { ChatSidebarListModeSwitch } from '@/components/chat/chat-sidebar-list-mode-switch';
|
|
11
12
|
import {
|
|
12
13
|
ChatSidebarProjectGroups,
|
|
@@ -130,16 +131,6 @@ function sessionTitle(session: SessionEntryView): string {
|
|
|
130
131
|
return chunks[chunks.length - 1] || session.key;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
function resolveSessionTypeStatusText(option: {
|
|
134
|
-
ready?: boolean;
|
|
135
|
-
reasonMessage?: string | null;
|
|
136
|
-
}): string {
|
|
137
|
-
if (option.ready === false) {
|
|
138
|
-
return option.reasonMessage?.trim() || t('statusSetup');
|
|
139
|
-
}
|
|
140
|
-
return t('statusReady');
|
|
141
|
-
}
|
|
142
|
-
|
|
143
134
|
const navItems = [
|
|
144
135
|
{ target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
|
|
145
136
|
{ target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
|
|
@@ -347,38 +338,23 @@ export function ChatSidebar() {
|
|
|
347
338
|
<ChevronDown className="h-4 w-4" />
|
|
348
339
|
</Button>
|
|
349
340
|
</PopoverTrigger>
|
|
350
|
-
<PopoverContent
|
|
351
|
-
|
|
341
|
+
<PopoverContent
|
|
342
|
+
align="end"
|
|
343
|
+
className="w-56 rounded-2xl border border-gray-200/80 bg-white p-1.5 shadow-[0_24px_60px_-28px_rgba(15,23,42,0.38)]"
|
|
344
|
+
>
|
|
345
|
+
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400">
|
|
352
346
|
{t('chatSessionTypeLabel')}
|
|
353
347
|
</div>
|
|
354
|
-
<div className="
|
|
348
|
+
<div className="space-y-1">
|
|
355
349
|
{nonDefaultSessionTypeOptions.map((option) => (
|
|
356
|
-
<
|
|
350
|
+
<ChatSessionTypeOptionItem
|
|
357
351
|
key={option.value}
|
|
358
|
-
|
|
359
|
-
|
|
352
|
+
option={option}
|
|
353
|
+
onSelect={() => {
|
|
360
354
|
presenter.chatSessionListManager.createSession(option.value);
|
|
361
355
|
setIsCreateMenuOpen(false);
|
|
362
356
|
}}
|
|
363
|
-
|
|
364
|
-
>
|
|
365
|
-
<div className="flex items-center justify-between gap-3">
|
|
366
|
-
<div className="text-[13px] font-medium text-gray-900">{option.label}</div>
|
|
367
|
-
<span
|
|
368
|
-
className={cn(
|
|
369
|
-
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
|
370
|
-
option.ready === false
|
|
371
|
-
? 'bg-amber-100 text-amber-800'
|
|
372
|
-
: 'bg-emerald-100 text-emerald-700'
|
|
373
|
-
)}
|
|
374
|
-
>
|
|
375
|
-
{option.ready === false ? t('statusSetup') : t('statusReady')}
|
|
376
|
-
</span>
|
|
377
|
-
</div>
|
|
378
|
-
<div className="mt-0.5 text-[11px] text-gray-500">
|
|
379
|
-
{resolveSessionTypeStatusText(option)}
|
|
380
|
-
</div>
|
|
381
|
-
</button>
|
|
357
|
+
/>
|
|
382
358
|
))}
|
|
383
359
|
</div>
|
|
384
360
|
</PopoverContent>
|
|
@@ -269,6 +269,23 @@ describe("ChatConversationPanel", () => {
|
|
|
269
269
|
expect(screen.queryByText("Engineer")).toBeNull();
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
it("renders a fuller loading skeleton before provider state settles", () => {
|
|
273
|
+
useChatThreadStore.setState({
|
|
274
|
+
snapshot: {
|
|
275
|
+
...useChatThreadStore.getState().snapshot,
|
|
276
|
+
isProviderStateResolved: false,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
render(<ChatConversationPanel />);
|
|
281
|
+
|
|
282
|
+
expect(screen.getByTestId("chat-conversation-skeleton")).toBeTruthy();
|
|
283
|
+
expect(
|
|
284
|
+
screen.getAllByTestId("chat-conversation-skeleton-bubble"),
|
|
285
|
+
).toHaveLength(4);
|
|
286
|
+
expect(screen.queryByTestId("chat-input-bar")).toBeNull();
|
|
287
|
+
});
|
|
288
|
+
|
|
272
289
|
it("keeps the message area clean while a session history is hydrating", () => {
|
|
273
290
|
useChatThreadStore.setState({
|
|
274
291
|
snapshot: {
|
|
@@ -495,5 +512,8 @@ describe("ChatSessionWorkspacePanel", () => {
|
|
|
495
512
|
expect(screen.getByTestId("workspace-file-preview").textContent).toBe(
|
|
496
513
|
"README.md",
|
|
497
514
|
);
|
|
515
|
+
expect(screen.getByTestId("workspace-tabs-bar").className).toContain(
|
|
516
|
+
"workspace-horizontal-scrollbar",
|
|
517
|
+
);
|
|
498
518
|
});
|
|
499
519
|
});
|
|
@@ -9,35 +9,93 @@ import {
|
|
|
9
9
|
import { ChatWelcome } from "@/components/chat/ChatWelcome";
|
|
10
10
|
import { ChatSessionWorkspacePanel } from "@/components/chat/chat-session-workspace-panel";
|
|
11
11
|
import { AgentAvatar } from "@/components/common/AgentAvatar";
|
|
12
|
+
import { SessionContextIconNode } from "@/components/common/session-context-icon";
|
|
12
13
|
import { usePresenter } from "@/components/chat/presenter/chat-presenter-context";
|
|
13
14
|
import { ChatSessionHeaderActions } from "@/components/chat/session-header/chat-session-header-actions";
|
|
14
15
|
import { ChatSessionProjectBadge } from "@/components/chat/session-header/chat-session-project-badge";
|
|
15
16
|
import { useChatInputStore } from "@/components/chat/stores/chat-input.store";
|
|
16
17
|
import { useChatThreadStore } from "@/components/chat/stores/chat-thread.store";
|
|
18
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
17
19
|
import { resolveAgentRuntimeSessionType } from "@/components/chat/useChatSessionTypeState";
|
|
18
20
|
import { t } from "@/lib/i18n";
|
|
19
21
|
import { cn } from "@/lib/utils";
|
|
20
22
|
|
|
23
|
+
const CHAT_CONVERSATION_SKELETON_BUBBLES = [
|
|
24
|
+
{
|
|
25
|
+
key: "hero",
|
|
26
|
+
alignmentClassName: "justify-start",
|
|
27
|
+
bubbleClassName: "max-w-[78%] h-32 rounded-[30px]",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: "follow-up",
|
|
31
|
+
alignmentClassName: "justify-start",
|
|
32
|
+
bubbleClassName: "max-w-[62%] h-24 rounded-[28px]",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "reply",
|
|
36
|
+
alignmentClassName: "justify-end",
|
|
37
|
+
bubbleClassName: "max-w-[70%] h-24 rounded-[28px]",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "detail",
|
|
41
|
+
alignmentClassName: "justify-start",
|
|
42
|
+
bubbleClassName: "max-w-[88%] h-36 rounded-[30px]",
|
|
43
|
+
},
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
21
46
|
function ChatConversationSkeleton() {
|
|
22
47
|
return (
|
|
23
|
-
<section
|
|
48
|
+
<section
|
|
49
|
+
data-testid="chat-conversation-skeleton"
|
|
50
|
+
className="flex-1 min-h-0 flex flex-col overflow-hidden bg-gradient-to-b from-gray-50/60 to-white"
|
|
51
|
+
>
|
|
24
52
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
|
|
25
|
-
<div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
|
|
26
|
-
<div className="
|
|
27
|
-
<div className="
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
<div className="mx-auto flex min-h-full w-full max-w-[min(1120px,100%)] flex-col px-6 py-5">
|
|
54
|
+
<div className="flex flex-1 flex-col gap-8">
|
|
55
|
+
<div className="space-y-6">
|
|
56
|
+
<Skeleton className="h-6 w-52 rounded-lg bg-gray-200/90" />
|
|
57
|
+
<div className="space-y-5">
|
|
58
|
+
{CHAT_CONVERSATION_SKELETON_BUBBLES.map((bubble) => (
|
|
59
|
+
<div
|
|
60
|
+
key={bubble.key}
|
|
61
|
+
className={cn("flex w-full", bubble.alignmentClassName)}
|
|
62
|
+
>
|
|
63
|
+
<Skeleton
|
|
64
|
+
data-testid="chat-conversation-skeleton-bubble"
|
|
65
|
+
className={cn(
|
|
66
|
+
"w-full bg-gray-200/80",
|
|
67
|
+
bubble.bubbleClassName,
|
|
68
|
+
)}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="mt-auto grid gap-4 pb-2 sm:grid-cols-[minmax(0,1fr)_minmax(180px,240px)] sm:items-end">
|
|
75
|
+
<div className="space-y-3">
|
|
76
|
+
<Skeleton className="h-4 w-40 rounded-full bg-gray-200/70" />
|
|
77
|
+
<Skeleton className="h-[112px] w-full rounded-[30px] bg-gray-200/70" />
|
|
78
|
+
</div>
|
|
79
|
+
<div className="hidden justify-end sm:flex">
|
|
80
|
+
<Skeleton className="h-10 w-36 rounded-full bg-gray-200/75" />
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
31
83
|
</div>
|
|
32
84
|
</div>
|
|
33
85
|
</div>
|
|
34
86
|
<div className="border-t border-gray-200/80 bg-white p-4">
|
|
35
87
|
<div className="mx-auto w-full max-w-[min(1120px,100%)]">
|
|
36
|
-
<div className="rounded-2xl border border-gray-200 bg-white shadow-card
|
|
37
|
-
<div className="
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
88
|
+
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-card">
|
|
89
|
+
<div className="px-4 py-2.5">
|
|
90
|
+
<Skeleton className="h-[84px] w-full rounded-[28px] bg-gray-200/80" />
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex items-center justify-between gap-3 px-3 pb-3">
|
|
93
|
+
<div className="flex items-center gap-2">
|
|
94
|
+
<Skeleton className="h-8 w-20 rounded-full bg-gray-200/75" />
|
|
95
|
+
<Skeleton className="h-8 w-28 rounded-full bg-gray-200/75" />
|
|
96
|
+
<Skeleton className="hidden h-8 w-24 rounded-full bg-gray-200/70 sm:block" />
|
|
97
|
+
</div>
|
|
98
|
+
<Skeleton className="h-8 w-8 rounded-full bg-gray-200/85" />
|
|
41
99
|
</div>
|
|
42
100
|
</div>
|
|
43
101
|
</div>
|
|
@@ -130,7 +188,19 @@ function ChatConversationHeader({
|
|
|
130
188
|
{sessionHeaderTitle}
|
|
131
189
|
</span>
|
|
132
190
|
{snapshot.sessionTypeLabel ? (
|
|
133
|
-
<span className="shrink-0 rounded-full border border-gray-200 bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
|
|
191
|
+
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-gray-200 bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
|
|
192
|
+
{snapshot.sessionTypeIcon?.src ? (
|
|
193
|
+
<span className="inline-flex h-[1.125rem] w-[1.125rem] items-center justify-center">
|
|
194
|
+
<SessionContextIconNode
|
|
195
|
+
icon={{
|
|
196
|
+
kind: "runtime-image",
|
|
197
|
+
src: snapshot.sessionTypeIcon.src,
|
|
198
|
+
alt: snapshot.sessionTypeIcon.alt ?? null,
|
|
199
|
+
name: snapshot.sessionTypeLabel
|
|
200
|
+
}}
|
|
201
|
+
/>
|
|
202
|
+
</span>
|
|
203
|
+
) : null}
|
|
134
204
|
{snapshot.sessionTypeLabel}
|
|
135
205
|
</span>
|
|
136
206
|
) : null}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { useEffect } from
|
|
2
|
-
import type { Dispatch, MutableRefObject, SetStateAction } from
|
|
3
|
-
import { ChatSidebar } from
|
|
4
|
-
import { ChatConversationPanel } from
|
|
5
|
-
import { AgentsPage } from
|
|
6
|
-
import { CronConfig } from
|
|
7
|
-
import { MarketplacePage } from
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
3
|
+
import { ChatSidebar } from "@/components/chat/ChatSidebar";
|
|
4
|
+
import { ChatConversationPanel } from "@/components/chat/chat-conversation-panel";
|
|
5
|
+
import { AgentsPage } from "@/components/agents/agents-page";
|
|
6
|
+
import { CronConfig } from "@/components/config/CronConfig";
|
|
7
|
+
import { MarketplacePage } from "@/components/marketplace/marketplace-page";
|
|
8
8
|
|
|
9
|
-
export type MainPanelView =
|
|
9
|
+
export type MainPanelView = "chat" | "cron" | "skills" | "agents";
|
|
10
10
|
|
|
11
11
|
export type ChatPageProps = {
|
|
12
12
|
view: MainPanelView;
|
|
@@ -32,7 +32,7 @@ export function useChatSessionSync(params: UseChatSessionSyncParams): void {
|
|
|
32
32
|
} = params;
|
|
33
33
|
|
|
34
34
|
useEffect(() => {
|
|
35
|
-
if (view !==
|
|
35
|
+
if (view !== "chat") {
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
if (routeSessionKey) {
|
|
@@ -45,7 +45,13 @@ export function useChatSessionSync(params: UseChatSessionSyncParams): void {
|
|
|
45
45
|
setSelectedSessionKey(null);
|
|
46
46
|
resetStreamState();
|
|
47
47
|
}
|
|
48
|
-
}, [
|
|
48
|
+
}, [
|
|
49
|
+
resetStreamState,
|
|
50
|
+
routeSessionKey,
|
|
51
|
+
selectedSessionKey,
|
|
52
|
+
setSelectedSessionKey,
|
|
53
|
+
view,
|
|
54
|
+
]);
|
|
49
55
|
|
|
50
56
|
useEffect(() => {
|
|
51
57
|
selectedSessionKeyRef.current = selectedSessionKey;
|
|
@@ -62,17 +68,17 @@ export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
|
|
|
62
68
|
<div className="h-full flex">
|
|
63
69
|
<ChatSidebar />
|
|
64
70
|
|
|
65
|
-
{view ===
|
|
71
|
+
{view === "chat" ? (
|
|
66
72
|
<ChatConversationPanel />
|
|
67
73
|
) : (
|
|
68
74
|
<section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
|
|
69
|
-
{view ===
|
|
75
|
+
{view === "cron" ? (
|
|
70
76
|
<div className="h-full overflow-auto custom-scrollbar">
|
|
71
77
|
<div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
|
|
72
78
|
<CronConfig />
|
|
73
79
|
</div>
|
|
74
80
|
</div>
|
|
75
|
-
) : view ===
|
|
81
|
+
) : view === "agents" ? (
|
|
76
82
|
<div className="h-full overflow-auto custom-scrollbar">
|
|
77
83
|
<div className="mx-auto w-full max-w-[min(1180px,100%)] px-6 py-5">
|
|
78
84
|
<AgentsPage />
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ChatSessionTypeOptionItem } from "@/components/chat/chat-session-type-option-item";
|
|
4
|
+
|
|
5
|
+
describe("ChatSessionTypeOptionItem", () => {
|
|
6
|
+
it("renders a runtime icon image when the session type option provides an app resource URI", () => {
|
|
7
|
+
render(
|
|
8
|
+
<ChatSessionTypeOptionItem
|
|
9
|
+
option={{
|
|
10
|
+
value: "codex",
|
|
11
|
+
label: "Codex",
|
|
12
|
+
icon: {
|
|
13
|
+
kind: "image",
|
|
14
|
+
src: "app://runtime-icons/codex-openai.svg",
|
|
15
|
+
alt: "Codex",
|
|
16
|
+
},
|
|
17
|
+
ready: true,
|
|
18
|
+
}}
|
|
19
|
+
onSelect={vi.fn()}
|
|
20
|
+
/>,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const runtimeIcon = screen.getByRole("img", { name: "Codex logo" });
|
|
24
|
+
expect(runtimeIcon.getAttribute("src")).toBe("/runtime-icons/codex-openai.svg");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps ready options visually compact without repeating helper copy", () => {
|
|
28
|
+
render(
|
|
29
|
+
<ChatSessionTypeOptionItem
|
|
30
|
+
option={{
|
|
31
|
+
value: "claude",
|
|
32
|
+
label: "Claude",
|
|
33
|
+
icon: {
|
|
34
|
+
kind: "image",
|
|
35
|
+
src: "app://runtime-icons/claude.ico",
|
|
36
|
+
alt: "Claude",
|
|
37
|
+
},
|
|
38
|
+
ready: true,
|
|
39
|
+
}}
|
|
40
|
+
onSelect={vi.fn()}
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(screen.getAllByText("Ready")).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ChatInputSnapshot } from "@/components/chat/stores/chat-input.store";
|
|
2
|
+
import { SessionContextIconNode } from "@/components/common/session-context-icon";
|
|
3
|
+
import { t } from "@/lib/i18n";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
type SessionTypeOption = ChatInputSnapshot["sessionTypeOptions"][number];
|
|
7
|
+
|
|
8
|
+
export function ChatSessionTypeOptionItem(props: {
|
|
9
|
+
option: SessionTypeOption;
|
|
10
|
+
onSelect: () => void;
|
|
11
|
+
}) {
|
|
12
|
+
const { option, onSelect } = props;
|
|
13
|
+
const helperText =
|
|
14
|
+
option.ready === false
|
|
15
|
+
? option.reasonMessage?.trim() || t("statusSetup")
|
|
16
|
+
: null;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
onClick={onSelect}
|
|
22
|
+
className="w-full rounded-2xl px-3 py-2.5 text-left transition-colors hover:bg-gray-50"
|
|
23
|
+
>
|
|
24
|
+
<div className="flex items-start gap-3">
|
|
25
|
+
<div className="flex min-w-0 flex-1 items-start gap-2.5">
|
|
26
|
+
{option.icon?.src ? (
|
|
27
|
+
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center pt-0.5">
|
|
28
|
+
<SessionContextIconNode
|
|
29
|
+
icon={{
|
|
30
|
+
kind: "runtime-image",
|
|
31
|
+
src: option.icon.src,
|
|
32
|
+
alt: option.icon.alt ?? null,
|
|
33
|
+
name: option.label,
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
</span>
|
|
37
|
+
) : null}
|
|
38
|
+
<div className="min-w-0 flex-1">
|
|
39
|
+
<div className="flex items-center justify-between gap-3">
|
|
40
|
+
<div className="truncate text-[13px] font-semibold text-gray-900">
|
|
41
|
+
{option.label}
|
|
42
|
+
</div>
|
|
43
|
+
<span
|
|
44
|
+
className={cn(
|
|
45
|
+
"inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium",
|
|
46
|
+
option.ready === false ? "text-amber-700" : "text-emerald-600",
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
<span
|
|
50
|
+
className={cn(
|
|
51
|
+
"h-1.5 w-1.5 rounded-full",
|
|
52
|
+
option.ready === false ? "bg-amber-500" : "bg-emerald-500",
|
|
53
|
+
)}
|
|
54
|
+
/>
|
|
55
|
+
{option.ready === false ? t("statusSetup") : t("statusReady")}
|
|
56
|
+
</span>
|
|
57
|
+
</div>
|
|
58
|
+
{helperText ? (
|
|
59
|
+
<div className="mt-1 pr-4 text-[11px] leading-4 text-gray-500">
|
|
60
|
+
{helperText}
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</button>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
|
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { ChatSessionWorkspaceFilePreview } from "@/components/chat/chat-session-workspace-file-preview";
|
|
4
4
|
import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
|
|
5
|
+
import { t } from "@/lib/i18n";
|
|
5
6
|
|
|
6
7
|
const serverPathReadMock = vi.fn();
|
|
7
8
|
|
|
@@ -88,4 +89,90 @@ describe("ChatSessionWorkspaceFilePreview", () => {
|
|
|
88
89
|
"workspace",
|
|
89
90
|
);
|
|
90
91
|
});
|
|
92
|
+
|
|
93
|
+
it("does not repeat the preview badge inside the workspace header", () => {
|
|
94
|
+
serverPathReadMock.mockReturnValue({
|
|
95
|
+
isLoading: false,
|
|
96
|
+
error: null,
|
|
97
|
+
data: {
|
|
98
|
+
kind: "text",
|
|
99
|
+
resolvedPath: "/tmp/example.ts",
|
|
100
|
+
text: "const answer = 42;\n",
|
|
101
|
+
truncated: false,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
render(
|
|
106
|
+
<ChatSessionWorkspaceFilePreview
|
|
107
|
+
file={buildWorkspaceFile({ viewMode: "preview" })}
|
|
108
|
+
sessionProjectRoot="/tmp"
|
|
109
|
+
onFileOpen={vi.fn()}
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(screen.queryByText(t("chatWorkspacePreview"))).toBeNull();
|
|
114
|
+
expect(screen.getByTitle("/tmp/example.ts")).toBeTruthy();
|
|
115
|
+
expect(screen.getByText("tmp")).toBeTruthy();
|
|
116
|
+
expect(screen.getByText("example.ts")).toBeTruthy();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("renders project-relative breadcrumbs when the file is inside the workspace", () => {
|
|
120
|
+
serverPathReadMock.mockReturnValue({
|
|
121
|
+
isLoading: false,
|
|
122
|
+
error: null,
|
|
123
|
+
data: {
|
|
124
|
+
kind: "text",
|
|
125
|
+
resolvedPath: "/tmp/workspace/src/example.ts",
|
|
126
|
+
text: "const answer = 42;\n",
|
|
127
|
+
truncated: false,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<ChatSessionWorkspaceFilePreview
|
|
133
|
+
file={buildWorkspaceFile({ viewMode: "preview" })}
|
|
134
|
+
sessionProjectRoot="/tmp/workspace"
|
|
135
|
+
onFileOpen={vi.fn()}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(screen.getByText("workspace")).toBeTruthy();
|
|
140
|
+
expect(screen.getByText("src")).toBeTruthy();
|
|
141
|
+
expect(screen.getByText("example.ts")).toBeTruthy();
|
|
142
|
+
expect(
|
|
143
|
+
screen.getByTestId("workspace-file-breadcrumb-scroll").className,
|
|
144
|
+
).toContain("py-1.5");
|
|
145
|
+
expect(screen.getByTestId("workspace-file-breadcrumbs").className).toContain(
|
|
146
|
+
"workspace-horizontal-scrollbar",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("keeps line and truncation metadata without the duplicated type badge", () => {
|
|
151
|
+
serverPathReadMock.mockReturnValue({
|
|
152
|
+
isLoading: false,
|
|
153
|
+
error: null,
|
|
154
|
+
data: {
|
|
155
|
+
kind: "text",
|
|
156
|
+
resolvedPath: "/tmp/example.ts",
|
|
157
|
+
text: "const answer = 42;\n",
|
|
158
|
+
truncated: true,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
render(
|
|
163
|
+
<ChatSessionWorkspaceFilePreview
|
|
164
|
+
file={buildWorkspaceFile({
|
|
165
|
+
viewMode: "preview",
|
|
166
|
+
line: 12,
|
|
167
|
+
column: 4,
|
|
168
|
+
})}
|
|
169
|
+
sessionProjectRoot="/tmp"
|
|
170
|
+
onFileOpen={vi.fn()}
|
|
171
|
+
/>,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(screen.getByText("L12:4")).toBeTruthy();
|
|
175
|
+
expect(screen.getByText(t("chatWorkspacePreviewTruncated"))).toBeTruthy();
|
|
176
|
+
expect(screen.queryByText(t("chatWorkspacePreview"))).toBeNull();
|
|
177
|
+
});
|
|
91
178
|
});
|
|
@@ -8,12 +8,14 @@ import {
|
|
|
8
8
|
FileOperationCodeSurface,
|
|
9
9
|
} from "@nextclaw/agent-chat-ui";
|
|
10
10
|
import type { ChatWorkspaceFileTab } from "@/components/chat/stores/chat-thread.store";
|
|
11
|
+
import { ChatSessionWorkspaceFileBreadcrumbs } from "@/components/chat/workspace/chat-session-workspace-file-breadcrumbs";
|
|
11
12
|
import { useServerPathRead } from "@/hooks/server-path/use-server-path-read";
|
|
12
13
|
import {
|
|
13
14
|
buildLineDiff,
|
|
14
15
|
buildPreviewLines,
|
|
15
16
|
} from "@/components/chat/adapters/file-operation/line-builder";
|
|
16
17
|
import { t } from "@/lib/i18n";
|
|
18
|
+
import { buildWorkspaceFileBreadcrumb } from "@/lib/session-project/workspace-file-breadcrumb";
|
|
17
19
|
import { cn } from "@/lib/utils";
|
|
18
20
|
|
|
19
21
|
function inferPreviewKind(params: {
|
|
@@ -118,44 +120,6 @@ function WorkspaceFilePreviewStatus({
|
|
|
118
120
|
);
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
function WorkspaceFileHeader({
|
|
122
|
-
file,
|
|
123
|
-
resolvedPath,
|
|
124
|
-
truncated,
|
|
125
|
-
}: {
|
|
126
|
-
file: ChatWorkspaceFileTab;
|
|
127
|
-
resolvedPath: string;
|
|
128
|
-
truncated: boolean;
|
|
129
|
-
}) {
|
|
130
|
-
return (
|
|
131
|
-
<div className="border-b border-gray-200/80 px-4 py-3">
|
|
132
|
-
<div
|
|
133
|
-
title={resolvedPath}
|
|
134
|
-
className="truncate font-mono text-[12px] font-medium text-gray-700"
|
|
135
|
-
>
|
|
136
|
-
{resolvedPath}
|
|
137
|
-
</div>
|
|
138
|
-
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
139
|
-
<span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-gray-500">
|
|
140
|
-
{file.viewMode === "diff"
|
|
141
|
-
? t("chatWorkspaceDiff")
|
|
142
|
-
: t("chatWorkspacePreview")}
|
|
143
|
-
</span>
|
|
144
|
-
{typeof file.line === "number" ? (
|
|
145
|
-
<span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-500">
|
|
146
|
-
{`L${file.line}${typeof file.column === "number" ? `:${file.column}` : ""}`}
|
|
147
|
-
</span>
|
|
148
|
-
) : null}
|
|
149
|
-
{truncated ? (
|
|
150
|
-
<span className="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
|
151
|
-
{t("chatWorkspacePreviewTruncated")}
|
|
152
|
-
</span>
|
|
153
|
-
) : null}
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
123
|
function WorkspaceDiffBody({
|
|
160
124
|
diffBlock,
|
|
161
125
|
}: {
|
|
@@ -280,14 +244,21 @@ export function ChatSessionWorkspaceFilePreview({
|
|
|
280
244
|
}, [file.line, file.path, isPreviewMode, previewQuery.data?.resolvedPath, previewText]);
|
|
281
245
|
const resolvedPath = previewQuery.data?.resolvedPath ?? file.path;
|
|
282
246
|
const isTruncated = Boolean(previewQuery.data?.truncated);
|
|
247
|
+
const breadcrumb = useMemo(
|
|
248
|
+
() =>
|
|
249
|
+
buildWorkspaceFileBreadcrumb({
|
|
250
|
+
path: resolvedPath,
|
|
251
|
+
sessionProjectRoot,
|
|
252
|
+
line: file.line,
|
|
253
|
+
column: file.column,
|
|
254
|
+
truncated: isTruncated,
|
|
255
|
+
}),
|
|
256
|
+
[file.column, file.line, isTruncated, resolvedPath, sessionProjectRoot],
|
|
257
|
+
);
|
|
283
258
|
|
|
284
259
|
return (
|
|
285
260
|
<div className="flex h-full min-h-0 flex-col bg-white">
|
|
286
|
-
<
|
|
287
|
-
file={file}
|
|
288
|
-
resolvedPath={resolvedPath}
|
|
289
|
-
truncated={isTruncated}
|
|
290
|
-
/>
|
|
261
|
+
<ChatSessionWorkspaceFileBreadcrumbs breadcrumb={breadcrumb} />
|
|
291
262
|
|
|
292
263
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
293
264
|
{file.viewMode === "diff" ? (
|
|
@@ -186,8 +186,14 @@ export function WorkspaceTabsBar({
|
|
|
186
186
|
tabs: readonly WorkspaceTabViewModel[];
|
|
187
187
|
}) {
|
|
188
188
|
return (
|
|
189
|
-
<div
|
|
190
|
-
|
|
189
|
+
<div
|
|
190
|
+
data-testid="workspace-tabs-bar"
|
|
191
|
+
className="workspace-horizontal-scrollbar overflow-x-auto overflow-y-hidden border-b border-gray-200/70 bg-gray-50/85"
|
|
192
|
+
>
|
|
193
|
+
<div
|
|
194
|
+
data-testid="workspace-tabs-scroll"
|
|
195
|
+
className="flex min-w-max items-stretch"
|
|
196
|
+
>
|
|
191
197
|
{tabs.map((tab) => (
|
|
192
198
|
<WorkspaceTabItem key={tab.key} tab={tab} />
|
|
193
199
|
))}
|