@nextclaw/ui 0.11.23 → 0.12.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/assets/{ChannelsList-DVDu1xvz.js → ChannelsList-NKNKsf1J.js} +1 -1
  3. package/dist/assets/ChatPage-p23OnnEI.js +43 -0
  4. package/dist/assets/DocBrowser-C8b2uPgL.js +1 -0
  5. package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DxdSujSc.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CQ-8jMha.js} +1 -1
  7. package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D-KQIN4U.js} +1 -1
  8. package/dist/assets/{MarketplacePage-Buo9HrOz.js → MarketplacePage-CRNvxtvx.js} +2 -2
  9. package/dist/assets/MarketplacePage-GGkEXowp.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-JnkYwK7p.js → McpMarketplacePage-Cu7GmCcc.js} +2 -2
  11. package/dist/assets/{ModelConfig-BYRhgp0c.js → ModelConfig-CEpx9fro.js} +1 -1
  12. package/dist/assets/{ProvidersList-DmLyyHvX.js → ProvidersList-BWbUb7-2.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-CDSSvH7Z.js → RemoteAccessPage-NsawrZb0.js} +1 -1
  14. package/dist/assets/RuntimeConfig-BJHBsVTd.js +1 -0
  15. package/dist/assets/{SearchConfig-D5f1EkLE.js → SearchConfig-BsaX_WYy.js} +1 -1
  16. package/dist/assets/{SecretsConfig-D61IKcYt.js → SecretsConfig-CgDZOd3w.js} +1 -1
  17. package/dist/assets/{SessionsConfig-BRIxVTEv.js → SessionsConfig-Dd-KM7F7.js} +2 -2
  18. package/dist/assets/{book-open-CXoF5nQC.js → book-open-FnK2xCQd.js} +1 -1
  19. package/dist/assets/chat-session-display-BD_AN71I.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-B5l0hr_u.js} +1 -1
  21. package/dist/assets/{config-DJswxxE8.js → config-JKmXfZ3q.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-o1WWhwhd.js} +1 -1
  23. package/dist/assets/{dist-nqTTbVdA.js → dist-C_moWYv7.js} +1 -1
  24. package/dist/assets/{dist-Cl2QB-2y.js → dist-DazA6Wd_.js} +1 -1
  25. package/dist/assets/{external-link-tIO7zING.js → external-link-BKje3SiD.js} +1 -1
  26. package/dist/assets/{hash-JWUyl1pT.js → hash-DfW4DT8O.js} +1 -1
  27. package/dist/assets/i18n-BK1w-oBy.js +1 -0
  28. package/dist/assets/index-BZaB1TqM.js +6 -0
  29. package/dist/assets/index-DaR9igPC.css +1 -0
  30. package/dist/assets/{label-BIpeNu4r.js → label-BzDWmdOe.js} +1 -1
  31. package/dist/assets/loader-circle-DdZPxBUz.js +1 -0
  32. package/dist/assets/{logos-DThdM9lk.js → logos-CTLlde_T.js} +1 -1
  33. package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-BagR3t59.js} +1 -1
  34. package/dist/assets/plus-DP2PSCPO.js +1 -0
  35. package/dist/assets/{popover-BJRUGA_H.js → popover-5DWhNfd4.js} +1 -1
  36. package/dist/assets/{provider-models-bz5y28rq.js → provider-models-DJ29qHuA.js} +1 -1
  37. package/dist/assets/{react-7ZHqQtEV.js → react-C3yu5yge.js} +1 -1
  38. package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BAJf-h7w.js} +1 -1
  39. package/dist/assets/{save-DJM5RRWW.js → save-aa6z4GJL.js} +1 -1
  40. package/dist/assets/search-pD6ZwQYF.js +1 -0
  41. package/dist/assets/{security-config-DbUyWcQz.js → security-config-DRDxrApx.js} +1 -1
  42. package/dist/assets/{select-DSkTc61S.js → select-BHJPiJWt.js} +1 -1
  43. package/dist/assets/skeleton-D6kCk9Y6.js +1 -0
  44. package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-DUwsTIdv.js} +1 -1
  45. package/dist/assets/{switch-Bo-Y46HZ.js → switch-B6nCfcOB.js} +1 -1
  46. package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-B57SMElx.js} +1 -1
  47. package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-CrjYH5ok.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-COwYXDKm.js → useConfirmDialog-DsxnXB1B.js} +1 -1
  49. package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-oTTWXgLG.js} +1 -1
  50. package/dist/assets/x-CTIQHUuD.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +6 -6
  53. package/src/App.tsx +2 -0
  54. package/src/api/agents.ts +26 -0
  55. package/src/api/types.ts +23 -2
  56. package/src/components/agents/AgentsPage.test.tsx +70 -0
  57. package/src/components/agents/AgentsPage.tsx +353 -0
  58. package/src/components/chat/ChatConversationPanel.test.tsx +141 -13
  59. package/src/components/chat/ChatConversationPanel.tsx +29 -7
  60. package/src/components/chat/ChatSidebar.test.tsx +8 -0
  61. package/src/components/chat/ChatSidebar.tsx +11 -0
  62. package/src/components/chat/ChatWelcome.test.tsx +25 -0
  63. package/src/components/chat/ChatWelcome.tsx +47 -1
  64. package/src/components/chat/adapters/chat-message-part.adapter.ts +5 -0
  65. package/src/components/chat/adapters/chat-message-tool-agent-id.test.ts +102 -0
  66. package/src/components/chat/adapters/chat-message-tool-agent-id.ts +47 -0
  67. package/src/components/chat/adapters/chat-message.adapter.test.ts +6 -0
  68. package/src/components/chat/adapters/chat-message.session-request-tool-card.ts +24 -15
  69. package/src/components/chat/chat-child-session-panel.tsx +115 -49
  70. package/src/components/chat/chat-page-shell.tsx +8 -17
  71. package/src/components/chat/chat-session-route.ts +0 -14
  72. package/src/components/chat/chat-sidebar-session-item.tsx +16 -1
  73. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
  74. package/src/components/chat/containers/chat-message-list.container.tsx +7 -0
  75. package/src/components/chat/ncp/NcpChatPage.tsx +58 -160
  76. package/src/components/chat/ncp/README.md +3 -0
  77. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
  78. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +66 -10
  79. package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
  80. package/src/components/chat/ncp/ncp-session-adapter.ts +1 -0
  81. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
  82. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
  83. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
  84. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
  85. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
  86. package/src/components/chat/stores/chat-thread.store.ts +20 -6
  87. package/src/components/common/AgentAvatar.tsx +63 -0
  88. package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
  89. package/src/components/common/agent-identity/index.ts +3 -0
  90. package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
  91. package/src/components/config/RuntimeConfig.tsx +13 -79
  92. package/src/components/config/runtime-config-agent.utils.ts +95 -0
  93. package/src/components/layout/AppLayout.tsx +3 -1
  94. package/src/components/layout/Sidebar.tsx +6 -1
  95. package/src/components/layout/app-layout.test.tsx +30 -0
  96. package/src/components/ui/tabs.tsx +2 -0
  97. package/src/hooks/README.md +3 -0
  98. package/src/hooks/agents/useAgents.ts +44 -0
  99. package/src/lib/i18n.agents.ts +66 -0
  100. package/src/lib/i18n.chat.ts +5 -0
  101. package/src/lib/i18n.ts +4 -4
  102. package/src/lib/ui-document-title.ts +1 -0
  103. package/dist/assets/ChatPage-Z9tRzm_n.js +0 -43
  104. package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
  105. package/dist/assets/MarketplacePage-D6rVQEQR.js +0 -1
  106. package/dist/assets/RuntimeConfig-v7a7Fe3x.js +0 -1
  107. package/dist/assets/chat-session-display-D0WpnuRZ.js +0 -1
  108. package/dist/assets/i18n-CDHMXlRZ.js +0 -1
  109. package/dist/assets/index-BuwbBgmT.js +0 -6
  110. package/dist/assets/index-bZ8cqQIS.css +0 -1
  111. package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
  112. package/dist/assets/plus-PHf8q-Ct.js +0 -1
  113. package/dist/assets/search-C91yH_6y.js +0 -1
  114. package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
  115. package/dist/assets/x-D7Q1yqSF.js +0 -1
  116. /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
  117. /package/src/lib/{i18n → i18n-runtime}/i18n.path-picker.ts +0 -0
@@ -0,0 +1,101 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { fetchNcpSessionConversationSeed, useNcpSessionConversation } from "./use-ncp-session-conversation";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ fetchNcpSessionMessages: vi.fn(),
7
+ hydratedCalls: [] as Array<{ client: unknown }>,
8
+ useHydratedNcpAgent: vi.fn(() => ({
9
+ snapshot: {
10
+ messages: [],
11
+ streamingMessage: null,
12
+ activeRun: null,
13
+ error: null,
14
+ },
15
+ visibleMessages: [],
16
+ activeRunId: null,
17
+ isRunning: false,
18
+ isSending: false,
19
+ send: vi.fn(),
20
+ abort: vi.fn(),
21
+ streamRun: vi.fn(),
22
+ isHydrating: false,
23
+ hydrateError: null,
24
+ })),
25
+ clientInstances: [] as unknown[],
26
+ }));
27
+
28
+ vi.mock("@/api/ncp-session", () => ({
29
+ fetchNcpSessionMessages: mocks.fetchNcpSessionMessages,
30
+ }));
31
+
32
+ vi.mock("@nextclaw/ncp-react", () => ({
33
+ useHydratedNcpAgent: vi.fn((params: { client: unknown }) => {
34
+ mocks.hydratedCalls.push(params);
35
+ return mocks.useHydratedNcpAgent();
36
+ }),
37
+ }));
38
+
39
+ vi.mock("@nextclaw/ncp-http-agent-client", () => ({
40
+ NcpHttpAgentClientEndpoint: vi.fn().mockImplementation(function MockClient(this: object) {
41
+ mocks.clientInstances.push(this);
42
+ }),
43
+ }));
44
+
45
+ describe("useNcpSessionConversation", () => {
46
+ beforeEach(() => {
47
+ mocks.fetchNcpSessionMessages.mockReset();
48
+ mocks.useHydratedNcpAgent.mockClear();
49
+ mocks.hydratedCalls.length = 0;
50
+ mocks.clientInstances.length = 0;
51
+ });
52
+
53
+ it("hydrates seed from the shared session messages endpoint payload", async () => {
54
+ mocks.fetchNcpSessionMessages.mockResolvedValue({
55
+ sessionId: "session-1",
56
+ status: "running",
57
+ total: 1,
58
+ messages: [{ id: "msg-1" }],
59
+ });
60
+
61
+ const result = await fetchNcpSessionConversationSeed(
62
+ "session-1",
63
+ new AbortController().signal,
64
+ 300,
65
+ );
66
+
67
+ expect(mocks.fetchNcpSessionMessages).toHaveBeenCalledWith("session-1", 300);
68
+ expect(result).toEqual({
69
+ messages: [{ id: "msg-1" }],
70
+ status: "running",
71
+ });
72
+ });
73
+
74
+ it("treats a missing session as an empty idle draft seed", async () => {
75
+ mocks.fetchNcpSessionMessages.mockRejectedValue(
76
+ new Error("ncp session not found: draft-session"),
77
+ );
78
+
79
+ const result = await fetchNcpSessionConversationSeed(
80
+ "draft-session",
81
+ new AbortController().signal,
82
+ );
83
+
84
+ expect(result).toEqual({
85
+ messages: [],
86
+ status: "idle",
87
+ });
88
+ });
89
+
90
+ it("creates an isolated endpoint instance per viewer", () => {
91
+ renderHook(() => useNcpSessionConversation("session-a"));
92
+ renderHook(() => useNcpSessionConversation("session-b"));
93
+
94
+ expect(mocks.useHydratedNcpAgent).toHaveBeenCalledTimes(2);
95
+ expect(mocks.clientInstances).toHaveLength(2);
96
+ expect(mocks.hydratedCalls).toHaveLength(2);
97
+ expect(mocks.clientInstances[0]).not.toBe(mocks.clientInstances[1]);
98
+ expect(mocks.hydratedCalls[0]?.client).toBe(mocks.clientInstances[0]);
99
+ expect(mocks.hydratedCalls[1]?.client).toBe(mocks.clientInstances[1]);
100
+ });
101
+ });
@@ -0,0 +1,72 @@
1
+ import { useCallback, useState } from "react";
2
+ import { NcpHttpAgentClientEndpoint } from "@nextclaw/ncp-http-agent-client";
3
+ import { useHydratedNcpAgent, type NcpConversationSeed } from "@nextclaw/ncp-react";
4
+ import { API_BASE } from "@/api/api-base";
5
+ import { fetchNcpSessionMessages } from "@/api/ncp-session";
6
+ import { createNcpAppClientFetch } from "@/components/chat/ncp/ncp-app-client-fetch";
7
+
8
+ const DEFAULT_MESSAGE_LIMIT = 300;
9
+
10
+ type UseNcpSessionConversationOptions = {
11
+ messageLimit?: number;
12
+ };
13
+
14
+ function isMissingNcpSessionError(error: unknown): boolean {
15
+ if (!(error instanceof Error)) {
16
+ return false;
17
+ }
18
+ return error.message.includes("ncp session not found:");
19
+ }
20
+
21
+ export function createNcpSessionConversationClient(): NcpHttpAgentClientEndpoint {
22
+ return new NcpHttpAgentClientEndpoint({
23
+ baseUrl: API_BASE,
24
+ basePath: "/api/ncp/agent",
25
+ fetchImpl: createNcpAppClientFetch(),
26
+ });
27
+ }
28
+
29
+ export async function fetchNcpSessionConversationSeed(
30
+ sessionId: string,
31
+ signal: AbortSignal,
32
+ messageLimit = DEFAULT_MESSAGE_LIMIT,
33
+ ): Promise<NcpConversationSeed> {
34
+ signal.throwIfAborted();
35
+
36
+ try {
37
+ const response = await fetchNcpSessionMessages(sessionId, messageLimit);
38
+ signal.throwIfAborted();
39
+ return {
40
+ messages: response.messages,
41
+ status: response.status ?? "idle",
42
+ };
43
+ } catch (error) {
44
+ signal.throwIfAborted();
45
+ if (!isMissingNcpSessionError(error)) {
46
+ throw error;
47
+ }
48
+ return {
49
+ messages: [],
50
+ status: "idle",
51
+ };
52
+ }
53
+ }
54
+
55
+ export function useNcpSessionConversation(
56
+ sessionId: string,
57
+ options: UseNcpSessionConversationOptions = {},
58
+ ) {
59
+ const [client] = useState(() => createNcpSessionConversationClient());
60
+ const messageLimit = options.messageLimit ?? DEFAULT_MESSAGE_LIMIT;
61
+ const loadSeed = useCallback(
62
+ (targetSessionId: string, signal: AbortSignal) =>
63
+ fetchNcpSessionConversationSeed(targetSessionId, signal, messageLimit),
64
+ [messageLimit],
65
+ );
66
+
67
+ return useHydratedNcpAgent({
68
+ sessionId,
69
+ client,
70
+ loadSeed,
71
+ });
72
+ }
@@ -38,6 +38,7 @@ export type ChatThreadManagerLike = {
38
38
  createSession: () => void;
39
39
  goToProviders: () => void;
40
40
  openSessionFromToolAction: (action: ChatToolActionViewModel) => void;
41
+ selectChildSessionDetail: (sessionKey: string) => void;
41
42
  closeChildSessionDetail: () => void;
42
43
  goToParentSession: () => void;
43
44
  };
@@ -2,6 +2,14 @@ import { create } from 'zustand';
2
2
  import type { MutableRefObject } from 'react';
3
3
  import type { NcpMessage } from '@nextclaw/ncp';
4
4
  import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
+ import type { AgentProfileView } from '@/api/types';
6
+
7
+ export type ChatChildSessionTab = {
8
+ sessionKey: string;
9
+ parentSessionKey: string | null;
10
+ label?: string | null;
11
+ agentId?: string | null;
12
+ };
5
13
 
6
14
  export type ChatThreadSnapshot = {
7
15
  isProviderStateResolved: boolean;
@@ -10,6 +18,10 @@ export type ChatThreadSnapshot = {
10
18
  sessionTypeUnavailableMessage?: string | null;
11
19
  sessionTypeLabel?: string | null;
12
20
  sessionKey: string | null;
21
+ agentId?: string | null;
22
+ agentDisplayName?: string | null;
23
+ agentAvatarUrl?: string | null;
24
+ availableAgents?: AgentProfileView[];
13
25
  sessionDisplayName?: string;
14
26
  sessionProjectRoot?: string | null;
15
27
  sessionProjectName?: string | null;
@@ -22,9 +34,8 @@ export type ChatThreadSnapshot = {
22
34
  isAwaitingAssistantOutput: boolean;
23
35
  parentSessionKey?: string | null;
24
36
  parentSessionLabel?: string | null;
25
- childSessionDetailSessionKey?: string | null;
26
- childSessionDetailParentSessionKey?: string | null;
27
- childSessionDetailLabel?: string | null;
37
+ childSessionTabs: ChatChildSessionTab[];
38
+ activeChildSessionKey?: string | null;
28
39
  };
29
40
 
30
41
  type ChatThreadStore = {
@@ -39,6 +50,10 @@ const initialSnapshot: ChatThreadSnapshot = {
39
50
  sessionTypeUnavailableMessage: null,
40
51
  sessionTypeLabel: null,
41
52
  sessionKey: null,
53
+ agentId: null,
54
+ agentDisplayName: null,
55
+ agentAvatarUrl: null,
56
+ availableAgents: [],
42
57
  sessionDisplayName: undefined,
43
58
  sessionProjectRoot: null,
44
59
  sessionProjectName: null,
@@ -51,9 +66,8 @@ const initialSnapshot: ChatThreadSnapshot = {
51
66
  isAwaitingAssistantOutput: false,
52
67
  parentSessionKey: null,
53
68
  parentSessionLabel: null,
54
- childSessionDetailSessionKey: null,
55
- childSessionDetailParentSessionKey: null,
56
- childSessionDetailLabel: null,
69
+ childSessionTabs: [],
70
+ activeChildSessionKey: null,
57
71
  };
58
72
 
59
73
  export const useChatThreadStore = create<ChatThreadStore>((set) => ({
@@ -0,0 +1,63 @@
1
+ import { cn } from '@/lib/utils';
2
+
3
+ type AgentAvatarProps = {
4
+ agentId: string;
5
+ displayName?: string | null;
6
+ avatarUrl?: string | null;
7
+ className?: string;
8
+ };
9
+
10
+ const PALETTE = [
11
+ ['bg-amber-100', 'text-amber-700'],
12
+ ['bg-emerald-100', 'text-emerald-700'],
13
+ ['bg-blue-100', 'text-blue-700'],
14
+ ['bg-rose-100', 'text-rose-700'],
15
+ ['bg-cyan-100', 'text-cyan-700'],
16
+ ['bg-violet-100', 'text-violet-700']
17
+ ] as const;
18
+
19
+ function hashText(value: string): number {
20
+ let hash = 0;
21
+ for (let index = 0; index < value.length; index += 1) {
22
+ hash = (hash << 5) - hash + value.charCodeAt(index);
23
+ hash |= 0;
24
+ }
25
+ return Math.abs(hash);
26
+ }
27
+
28
+ function resolveLetter(value: string): string {
29
+ const trimmed = value.trim();
30
+ if (!trimmed) {
31
+ return 'A';
32
+ }
33
+ return trimmed.slice(0, 1).toUpperCase();
34
+ }
35
+
36
+ export function AgentAvatar({ agentId, displayName, avatarUrl, className }: AgentAvatarProps) {
37
+ const seed = displayName?.trim() || agentId;
38
+ const [bgClass, textClass] = PALETTE[hashText(agentId) % PALETTE.length] ?? PALETTE[0];
39
+
40
+ if (avatarUrl?.trim()) {
41
+ return (
42
+ <img
43
+ src={avatarUrl}
44
+ alt={displayName?.trim() || agentId}
45
+ className={cn('rounded-full object-cover', className)}
46
+ />
47
+ );
48
+ }
49
+
50
+ return (
51
+ <div
52
+ className={cn(
53
+ 'rounded-full flex items-center justify-center font-semibold',
54
+ bgClass,
55
+ textClass,
56
+ className
57
+ )}
58
+ aria-label={displayName?.trim() || agentId}
59
+ >
60
+ {resolveLetter(seed)}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,27 @@
1
+ import { AgentAvatar } from '@/components/common/AgentAvatar';
2
+ import { useAgentIdentity } from '@/components/common/agent-identity/use-agent-identity';
3
+
4
+ type AgentIdentityAvatarProps = {
5
+ agentId?: string | null;
6
+ className?: string;
7
+ };
8
+
9
+ export function AgentIdentityAvatar({
10
+ agentId,
11
+ className,
12
+ }: AgentIdentityAvatarProps) {
13
+ const identity = useAgentIdentity(agentId);
14
+
15
+ if (!identity) {
16
+ return null;
17
+ }
18
+
19
+ return (
20
+ <AgentAvatar
21
+ agentId={identity.agentId}
22
+ displayName={identity.displayName}
23
+ avatarUrl={identity.avatarUrl}
24
+ className={className}
25
+ />
26
+ );
27
+ }
@@ -0,0 +1,3 @@
1
+ export { AgentIdentityAvatar } from '@/components/common/agent-identity/agent-identity-avatar';
2
+ export { useAgentIdentity } from '@/components/common/agent-identity/use-agent-identity';
3
+ export type { ResolvedAgentIdentity } from '@/components/common/agent-identity/use-agent-identity';
@@ -0,0 +1,50 @@
1
+ import { useMemo } from 'react';
2
+ import type { AgentProfileView } from '@/api/types';
3
+ import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
4
+ import { useAgents } from '@/hooks/agents/useAgents';
5
+
6
+ export type ResolvedAgentIdentity = {
7
+ agentId: string;
8
+ profile: AgentProfileView | null;
9
+ displayName: string;
10
+ avatarUrl: string | null;
11
+ };
12
+
13
+ function buildAgentProfileMap(
14
+ scopedAgents: readonly AgentProfileView[],
15
+ queryAgents: readonly AgentProfileView[],
16
+ ): Map<string, AgentProfileView> {
17
+ return new Map(
18
+ [...queryAgents, ...scopedAgents]
19
+ .filter((agent) => typeof agent.id === 'string' && agent.id.trim().length > 0)
20
+ .map((agent) => [agent.id, agent]),
21
+ );
22
+ }
23
+
24
+ export function useAgentIdentity(
25
+ agentId?: string | null,
26
+ ): ResolvedAgentIdentity | null {
27
+ const normalizedAgentId = agentId?.trim() ?? '';
28
+ const scopedAgents = useChatThreadStore(
29
+ (state) => state.snapshot.availableAgents ?? [],
30
+ );
31
+ const agentsQuery = useAgents();
32
+
33
+ const agentById = useMemo(
34
+ () => buildAgentProfileMap(scopedAgents, agentsQuery.data?.agents ?? []),
35
+ [agentsQuery.data?.agents, scopedAgents],
36
+ );
37
+
38
+ return useMemo(() => {
39
+ if (!normalizedAgentId) {
40
+ return null;
41
+ }
42
+ const profile = agentById.get(normalizedAgentId) ?? null;
43
+ return {
44
+ agentId: normalizedAgentId,
45
+ profile,
46
+ displayName: profile?.displayName?.trim() || normalizedAgentId,
47
+ avatarUrl: profile?.avatarUrl?.trim() || null,
48
+ };
49
+ }, [agentById, normalizedAgentId]);
50
+ }
@@ -6,6 +6,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
6
6
  import { Input } from '@/components/ui/input';
7
7
  import { Switch } from '@/components/ui/switch';
8
8
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
9
+ import {
10
+ createEmptyRuntimeAgent,
11
+ createEmptyRuntimeBinding,
12
+ hydrateRuntimeAgent,
13
+ hydrateRuntimeBinding,
14
+ parseOptionalInt,
15
+ toPersistedRuntimeAgent
16
+ } from '@/components/config/runtime-config-agent.utils';
9
17
  import { hintForPath } from '@/lib/config-hints';
10
18
  import { t } from '@/lib/i18n';
11
19
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
@@ -22,37 +30,6 @@ const DM_SCOPE_OPTIONS: Array<{ value: DmScope; label: string }> = [
22
30
  { value: 'per-account-channel-peer', label: 'per-account-channel-peer' }
23
31
  ];
24
32
 
25
- function createEmptyAgent(): AgentProfileView {
26
- return {
27
- id: '',
28
- default: false,
29
- workspace: '',
30
- model: '',
31
- engine: '',
32
- contextTokens: undefined,
33
- maxToolIterations: undefined
34
- };
35
- }
36
-
37
- function createEmptyBinding(): AgentBindingView {
38
- return {
39
- agentId: '',
40
- match: {
41
- channel: '',
42
- accountId: ''
43
- }
44
- };
45
- }
46
-
47
- function parseOptionalInt(value: string): number | undefined {
48
- const trimmed = value.trim();
49
- if (!trimmed) {
50
- return undefined;
51
- }
52
- const parsed = Number.parseInt(trimmed, 10);
53
- return Number.isFinite(parsed) ? parsed : undefined;
54
- }
55
-
56
33
  export function RuntimeConfig() {
57
34
  const { data: config, isLoading } = useConfig();
58
35
  const { data: schema } = useConfigSchema();
@@ -69,32 +46,8 @@ export function RuntimeConfig() {
69
46
  if (!config) {
70
47
  return;
71
48
  }
72
- setAgents(
73
- (config.agents.list ?? []).map((agent) => ({
74
- id: agent.id ?? '',
75
- default: Boolean(agent.default),
76
- workspace: agent.workspace ?? '',
77
- model: agent.model ?? '',
78
- engine: agent.engine ?? '',
79
- contextTokens: agent.contextTokens,
80
- maxToolIterations: agent.maxToolIterations
81
- }))
82
- );
83
- setBindings(
84
- (config.bindings ?? []).map((binding) => ({
85
- agentId: binding.agentId ?? '',
86
- match: {
87
- channel: binding.match?.channel ?? '',
88
- accountId: binding.match?.accountId ?? '',
89
- peer: binding.match?.peer
90
- ? {
91
- kind: binding.match.peer.kind,
92
- id: binding.match.peer.id
93
- }
94
- : undefined
95
- }
96
- }))
97
- );
49
+ setAgents((config.agents.list ?? []).map(hydrateRuntimeAgent));
50
+ setBindings((config.bindings ?? []).map(hydrateRuntimeBinding));
98
51
  setDmScope((config.session?.dmScope as DmScope) ?? 'per-channel-peer');
99
52
  setMaxPingPongTurns(config.session?.agentToAgent?.maxPingPongTurns ?? 0);
100
53
  setDefaultContextTokens(config.agents.defaults.contextTokens ?? 200000);
@@ -137,26 +90,7 @@ export function RuntimeConfig() {
137
90
  if (!id) {
138
91
  throw new Error(t('agentIdRequiredError').replace('{index}', String(index)));
139
92
  }
140
- const normalized: AgentProfileView = { id };
141
- if (agent.default) {
142
- normalized.default = true;
143
- }
144
- if (agent.workspace?.trim()) {
145
- normalized.workspace = agent.workspace.trim();
146
- }
147
- if (agent.model?.trim()) {
148
- normalized.model = agent.model.trim();
149
- }
150
- if (agent.engine?.trim()) {
151
- normalized.engine = agent.engine.trim();
152
- }
153
- if (typeof agent.contextTokens === 'number') {
154
- normalized.contextTokens = Math.max(1000, agent.contextTokens);
155
- }
156
- if (typeof agent.maxToolIterations === 'number') {
157
- normalized.maxToolIterations = agent.maxToolIterations;
158
- }
159
- return normalized;
93
+ return toPersistedRuntimeAgent(agent);
160
94
  });
161
95
 
162
96
  const duplicates = normalizedAgents
@@ -389,7 +323,7 @@ export function RuntimeConfig() {
389
323
  </div>
390
324
  ))}
391
325
 
392
- <Button type="button" variant="outline" onClick={() => setAgents((prev) => [...prev, createEmptyAgent()])}>
326
+ <Button type="button" variant="outline" onClick={() => setAgents((prev) => [...prev, createEmptyRuntimeAgent()])}>
393
327
  <Plus className="h-4 w-4 mr-2" />
394
328
  {t('addAgent')}
395
329
  </Button>
@@ -510,7 +444,7 @@ export function RuntimeConfig() {
510
444
  );
511
445
  })}
512
446
 
513
- <Button type="button" variant="outline" onClick={() => setBindings((prev) => [...prev, createEmptyBinding()])}>
447
+ <Button type="button" variant="outline" onClick={() => setBindings((prev) => [...prev, createEmptyRuntimeBinding()])}>
514
448
  <Plus className="h-4 w-4 mr-2" />
515
449
  {t('addBinding')}
516
450
  </Button>
@@ -0,0 +1,95 @@
1
+ import type { AgentBindingView, AgentProfileView } from '@/api/types';
2
+
3
+ export function createEmptyRuntimeAgent(): AgentProfileView {
4
+ return {
5
+ id: '',
6
+ default: false,
7
+ workspace: '',
8
+ model: '',
9
+ engine: '',
10
+ contextTokens: undefined,
11
+ maxToolIterations: undefined
12
+ };
13
+ }
14
+
15
+ export function createEmptyRuntimeBinding(): AgentBindingView {
16
+ return {
17
+ agentId: '',
18
+ match: {
19
+ channel: '',
20
+ accountId: ''
21
+ }
22
+ };
23
+ }
24
+
25
+ export function hydrateRuntimeAgent(agent: AgentProfileView): AgentProfileView {
26
+ return {
27
+ id: agent.id ?? '',
28
+ default: Boolean(agent.default),
29
+ displayName: agent.displayName ?? '',
30
+ description: agent.description ?? '',
31
+ avatar: agent.avatar ?? '',
32
+ workspace: agent.workspace ?? '',
33
+ model: agent.model ?? '',
34
+ engine: agent.engine ?? '',
35
+ contextTokens: agent.contextTokens,
36
+ maxToolIterations: agent.maxToolIterations
37
+ };
38
+ }
39
+
40
+ export function hydrateRuntimeBinding(binding: AgentBindingView): AgentBindingView {
41
+ return {
42
+ agentId: binding.agentId ?? '',
43
+ match: {
44
+ channel: binding.match?.channel ?? '',
45
+ accountId: binding.match?.accountId ?? '',
46
+ peer: binding.match?.peer
47
+ ? {
48
+ kind: binding.match.peer.kind,
49
+ id: binding.match.peer.id
50
+ }
51
+ : undefined
52
+ }
53
+ };
54
+ }
55
+
56
+ export function parseOptionalInt(value: string): number | undefined {
57
+ const trimmed = value.trim();
58
+ if (!trimmed) {
59
+ return undefined;
60
+ }
61
+ const parsed = Number.parseInt(trimmed, 10);
62
+ return Number.isFinite(parsed) ? parsed : undefined;
63
+ }
64
+
65
+ export function toPersistedRuntimeAgent(agent: AgentProfileView): AgentProfileView {
66
+ const normalized: AgentProfileView = { id: agent.id.trim() };
67
+ if (agent.default) {
68
+ normalized.default = true;
69
+ }
70
+ if (agent.displayName?.trim()) {
71
+ normalized.displayName = agent.displayName.trim();
72
+ }
73
+ if (agent.description?.trim()) {
74
+ normalized.description = agent.description.trim();
75
+ }
76
+ if (agent.avatar?.trim()) {
77
+ normalized.avatar = agent.avatar.trim();
78
+ }
79
+ if (agent.workspace?.trim()) {
80
+ normalized.workspace = agent.workspace.trim();
81
+ }
82
+ if (agent.model?.trim()) {
83
+ normalized.model = agent.model.trim();
84
+ }
85
+ if (agent.engine?.trim()) {
86
+ normalized.engine = agent.engine.trim();
87
+ }
88
+ if (typeof agent.contextTokens === 'number') {
89
+ normalized.contextTokens = Math.max(1000, agent.contextTokens);
90
+ }
91
+ if (typeof agent.maxToolIterations === 'number') {
92
+ normalized.maxToolIterations = agent.maxToolIterations;
93
+ }
94
+ return normalized;
95
+ }
@@ -21,7 +21,9 @@ function isMainWorkspaceRoute(pathname: string): boolean {
21
21
  normalized === '/skills' ||
22
22
  normalized.startsWith('/skills/') ||
23
23
  normalized === '/cron' ||
24
- normalized.startsWith('/cron/')
24
+ normalized.startsWith('/cron/') ||
25
+ normalized === '/agents' ||
26
+ normalized.startsWith('/agents/')
25
27
  );
26
28
  }
27
29
 
@@ -1,7 +1,7 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
3
3
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
- import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi } from 'lucide-react';
4
+ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft, Search, Shield, Wrench, Wifi, Bot } from 'lucide-react';
5
5
  import { NavLink } from 'react-router-dom';
6
6
  import { useDocBrowser } from '@/components/doc-browser';
7
7
  import { BrandHeader } from '@/components/common/BrandHeader';
@@ -61,6 +61,11 @@ export function Sidebar({ mode }: SidebarProps) {
61
61
  target: '/chat/skills',
62
62
  label: t('marketplaceFilterSkills'),
63
63
  icon: BrainCircuit,
64
+ },
65
+ {
66
+ target: '/agents',
67
+ label: t('agentsPageTitle'),
68
+ icon: Bot,
64
69
  }
65
70
  ];
66
71
 
@@ -0,0 +1,30 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { AppLayout } from '@/components/layout/AppLayout';
5
+ import { I18nProvider } from '@/components/providers/I18nProvider';
6
+
7
+ describe('AppLayout', () => {
8
+ it('treats /agents as a main workspace route instead of the settings shell', () => {
9
+ const { container } = render(
10
+ <I18nProvider>
11
+ <MemoryRouter initialEntries={['/agents']}>
12
+ <Routes>
13
+ <Route
14
+ path="*"
15
+ element={(
16
+ <AppLayout>
17
+ <div data-testid="agents-content">Agents Content</div>
18
+ </AppLayout>
19
+ )}
20
+ />
21
+ </Routes>
22
+ </MemoryRouter>
23
+ </I18nProvider>
24
+ );
25
+
26
+ expect(screen.getByTestId('agents-content')).toBeTruthy();
27
+ expect(screen.queryByTestId('settings-sidebar-header')).toBeNull();
28
+ expect(container.querySelector('aside')).toBeNull();
29
+ });
30
+ });
@@ -61,6 +61,8 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
61
61
  <button
62
62
  type="button"
63
63
  onClick={() => context.onValueChange(value)}
64
+ aria-pressed={isActive}
65
+ data-state={isActive ? 'active' : 'inactive'}
64
66
  className={cn(
65
67
  'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-[13px] font-medium ring-offset-white transition-all duration-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
66
68
  isActive