@nextclaw/ui 0.11.23 → 0.12.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 (118) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/assets/{ChannelsList-DVDu1xvz.js → ChannelsList-DekMP4a3.js} +1 -1
  3. package/dist/assets/ChatPage-Dgw4vlDt.js +43 -0
  4. package/dist/assets/DocBrowser-CExjX5is.js +1 -0
  5. package/dist/assets/{DocBrowser-BmtBLFU0.js → DocBrowser-DjcghYGO.js} +1 -1
  6. package/dist/assets/{DocBrowserContext-YIKkPb76.js → DocBrowserContext-CLlq7rZQ.js} +1 -1
  7. package/dist/assets/{LogoBadge-F7ZWdxLT.js → LogoBadge-D_dOy5U3.js} +1 -1
  8. package/dist/assets/{MarketplacePage-Buo9HrOz.js → MarketplacePage-BlIeNn3x.js} +2 -2
  9. package/dist/assets/MarketplacePage-DGfzg1LG.js +1 -0
  10. package/dist/assets/{McpMarketplacePage-JnkYwK7p.js → McpMarketplacePage-mz2_IX1O.js} +2 -2
  11. package/dist/assets/{ModelConfig-BYRhgp0c.js → ModelConfig-C_49_a9v.js} +1 -1
  12. package/dist/assets/{ProvidersList-DmLyyHvX.js → ProvidersList-B0RCb_Vg.js} +1 -1
  13. package/dist/assets/{RemoteAccessPage-CDSSvH7Z.js → RemoteAccessPage-CcfQjLtx.js} +1 -1
  14. package/dist/assets/RuntimeConfig-DBWzwoY-.js +1 -0
  15. package/dist/assets/{SearchConfig-D5f1EkLE.js → SearchConfig-jSdwlH4b.js} +1 -1
  16. package/dist/assets/{SecretsConfig-D61IKcYt.js → SecretsConfig-DbiS3txa.js} +1 -1
  17. package/dist/assets/{SessionsConfig-BRIxVTEv.js → SessionsConfig-CbIOcAp8.js} +2 -2
  18. package/dist/assets/{book-open-CXoF5nQC.js → book-open-BLxSL7Dk.js} +1 -1
  19. package/dist/assets/chat-session-display-8yW6-mtm.js +1 -0
  20. package/dist/assets/{chunk-JZWAC4HX-CvRWvTy5.js → chunk-JZWAC4HX-Bp0t5xoO.js} +1 -1
  21. package/dist/assets/{config-DJswxxE8.js → config-C96FWufn.js} +1 -1
  22. package/dist/assets/{createLucideIcon-CjGHOWb6.js → createLucideIcon-B_U7Nq4F.js} +1 -1
  23. package/dist/assets/{dist-Cl2QB-2y.js → dist-BFY-GyT4.js} +1 -1
  24. package/dist/assets/{dist-nqTTbVdA.js → dist-D9pHzW9z.js} +1 -1
  25. package/dist/assets/{external-link-tIO7zING.js → external-link-BydIQTIH.js} +1 -1
  26. package/dist/assets/{hash-JWUyl1pT.js → hash-Djdf0x1C.js} +1 -1
  27. package/dist/assets/i18n-DAekxt_G.js +1 -0
  28. package/dist/assets/index-CHEgQIiO.css +1 -0
  29. package/dist/assets/index-DqSv8Azv.js +6 -0
  30. package/dist/assets/{label-BIpeNu4r.js → label-Bvv4Mrea.js} +1 -1
  31. package/dist/assets/loader-circle-CGXXikVG.js +1 -0
  32. package/dist/assets/{logos-DThdM9lk.js → logos-CGJJRI5_.js} +1 -1
  33. package/dist/assets/{page-layout-D3Xo605Z.js → page-layout-6Nm4Cnvr.js} +1 -1
  34. package/dist/assets/plus-CrW9BJDy.js +1 -0
  35. package/dist/assets/{popover-BJRUGA_H.js → popover-b9rSYI6X.js} +1 -1
  36. package/dist/assets/{provider-models-bz5y28rq.js → provider-models-IJDA940D.js} +1 -1
  37. package/dist/assets/{react-7ZHqQtEV.js → react-CDZz_StC.js} +1 -1
  38. package/dist/assets/{refresh-ccw-CC6-_QuL.js → refresh-ccw-BvSSnnCw.js} +1 -1
  39. package/dist/assets/{save-DJM5RRWW.js → save-CAf0_-b9.js} +1 -1
  40. package/dist/assets/search-DgoXxocn.js +1 -0
  41. package/dist/assets/{security-config-DbUyWcQz.js → security-config-DF66-l25.js} +1 -1
  42. package/dist/assets/{select-DSkTc61S.js → select-CEIMqc0H.js} +1 -1
  43. package/dist/assets/skeleton-BiPUQkOD.js +1 -0
  44. package/dist/assets/{status-dot-LNBlDu3q.js → status-dot-CmQI5Qq2.js} +1 -1
  45. package/dist/assets/{switch-Bo-Y46HZ.js → switch-B7SxDXyR.js} +1 -1
  46. package/dist/assets/{tabs-custom-DXv507_2.js → tabs-custom-Dxt6EJJW.js} +1 -1
  47. package/dist/assets/{trash-2-DFZmW6Gg.js → trash-2-BnQ1PDTw.js} +1 -1
  48. package/dist/assets/{useConfirmDialog-COwYXDKm.js → useConfirmDialog-B-vMOmhG.js} +1 -1
  49. package/dist/assets/{useMutation-DrZrOgVL.js → useMutation-Bi39Z9_J.js} +1 -1
  50. package/dist/assets/x-PBSiWt3l.js +1 -0
  51. package/dist/index.html +18 -18
  52. package/package.json +7 -7
  53. package/src/App.tsx +2 -0
  54. package/src/api/agents.ts +26 -0
  55. package/src/api/types.ts +23 -5
  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 +172 -13
  59. package/src/components/chat/ChatConversationPanel.tsx +30 -7
  60. package/src/components/chat/ChatSidebar.test.tsx +48 -0
  61. package/src/components/chat/ChatSidebar.tsx +11 -0
  62. package/src/components/chat/ChatWelcome.test.tsx +30 -0
  63. package/src/components/chat/ChatWelcome.tsx +50 -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-runtime.test.ts +30 -0
  71. package/src/components/chat/chat-page-shell.tsx +8 -17
  72. package/src/components/chat/chat-session-route.ts +0 -14
  73. package/src/components/chat/chat-sidebar-session-item.tsx +20 -1
  74. package/src/components/chat/containers/chat-input-bar.container.tsx +2 -1
  75. package/src/components/chat/containers/chat-message-list.container.tsx +7 -0
  76. package/src/components/chat/ncp/NcpChatPage.tsx +77 -158
  77. package/src/components/chat/ncp/README.md +3 -0
  78. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +2 -0
  79. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +66 -10
  80. package/src/components/chat/ncp/ncp-session-adapter.test.ts +2 -0
  81. package/src/components/chat/ncp/ncp-session-adapter.ts +1 -0
  82. package/src/components/chat/ncp/page/ncp-chat-derived-state.ts +128 -0
  83. package/src/components/chat/ncp/session-conversation/use-ncp-child-session-tabs-view.ts +52 -0
  84. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.test.tsx +101 -0
  85. package/src/components/chat/ncp/session-conversation/use-ncp-session-conversation.ts +72 -0
  86. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -0
  87. package/src/components/chat/stores/chat-thread.store.ts +20 -6
  88. package/src/components/common/AgentAvatar.tsx +63 -0
  89. package/src/components/common/agent-identity/agent-identity-avatar.tsx +27 -0
  90. package/src/components/common/agent-identity/index.ts +3 -0
  91. package/src/components/common/agent-identity/use-agent-identity.ts +50 -0
  92. package/src/components/config/RuntimeConfig.tsx +14 -101
  93. package/src/components/config/runtime-config-agent.utils.ts +95 -0
  94. package/src/components/layout/AppLayout.tsx +3 -1
  95. package/src/components/layout/Sidebar.tsx +6 -1
  96. package/src/components/layout/app-layout.test.tsx +30 -0
  97. package/src/components/ui/tabs.tsx +2 -0
  98. package/src/hooks/README.md +3 -0
  99. package/src/hooks/agents/useAgents.ts +44 -0
  100. package/src/lib/i18n.agents.ts +66 -0
  101. package/src/lib/i18n.chat.ts +5 -0
  102. package/src/lib/i18n.ts +4 -6
  103. package/src/lib/ui-document-title.ts +1 -0
  104. package/dist/assets/ChatPage-Z9tRzm_n.js +0 -43
  105. package/dist/assets/DocBrowser-B9OaZjmg.js +0 -1
  106. package/dist/assets/MarketplacePage-D6rVQEQR.js +0 -1
  107. package/dist/assets/RuntimeConfig-v7a7Fe3x.js +0 -1
  108. package/dist/assets/chat-session-display-D0WpnuRZ.js +0 -1
  109. package/dist/assets/i18n-CDHMXlRZ.js +0 -1
  110. package/dist/assets/index-BuwbBgmT.js +0 -6
  111. package/dist/assets/index-bZ8cqQIS.css +0 -1
  112. package/dist/assets/loader-circle-Cs8XVFTw.js +0 -1
  113. package/dist/assets/plus-PHf8q-Ct.js +0 -1
  114. package/dist/assets/search-C91yH_6y.js +0 -1
  115. package/dist/assets/skeleton-Dzg-HOiN.js +0 -1
  116. package/dist/assets/x-D7Q1yqSF.js +0 -1
  117. /package/src/lib/{i18n → i18n-runtime}/i18n-language-owner.ts +0 -0
  118. /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();
@@ -61,7 +38,6 @@ export function RuntimeConfig() {
61
38
  const [agents, setAgents] = useState<AgentProfileView[]>([]);
62
39
  const [bindings, setBindings] = useState<AgentBindingView[]>([]);
63
40
  const [dmScope, setDmScope] = useState<DmScope>('per-channel-peer');
64
- const [maxPingPongTurns, setMaxPingPongTurns] = useState(0);
65
41
  const [defaultContextTokens, setDefaultContextTokens] = useState(200000);
66
42
  const [defaultEngine, setDefaultEngine] = useState('native');
67
43
 
@@ -69,41 +45,15 @@ export function RuntimeConfig() {
69
45
  if (!config) {
70
46
  return;
71
47
  }
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
- );
48
+ setAgents((config.agents.list ?? []).map(hydrateRuntimeAgent));
49
+ setBindings((config.bindings ?? []).map(hydrateRuntimeBinding));
98
50
  setDmScope((config.session?.dmScope as DmScope) ?? 'per-channel-peer');
99
- setMaxPingPongTurns(config.session?.agentToAgent?.maxPingPongTurns ?? 0);
100
51
  setDefaultContextTokens(config.agents.defaults.contextTokens ?? 200000);
101
52
  setDefaultEngine(config.agents.defaults.engine ?? 'native');
102
53
  }, [config]);
103
54
 
104
55
  const uiHints = schema?.uiHints;
105
56
  const dmScopeHint = hintForPath('session.dmScope', uiHints);
106
- const maxPingHint = hintForPath('session.agentToAgent.maxPingPongTurns', uiHints);
107
57
  const defaultContextTokensHint = hintForPath('agents.defaults.contextTokens', uiHints);
108
58
  const defaultEngineHint = hintForPath('agents.defaults.engine', uiHints);
109
59
  const agentContextTokensHint = hintForPath('agents.list.*.contextTokens', uiHints);
@@ -137,26 +87,7 @@ export function RuntimeConfig() {
137
87
  if (!id) {
138
88
  throw new Error(t('agentIdRequiredError').replace('{index}', String(index)));
139
89
  }
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;
90
+ return toPersistedRuntimeAgent(agent);
160
91
  });
161
92
 
162
93
  const duplicates = normalizedAgents
@@ -219,10 +150,7 @@ export function RuntimeConfig() {
219
150
  },
220
151
  bindings: normalizedBindings,
221
152
  session: {
222
- dmScope,
223
- agentToAgent: {
224
- maxPingPongTurns: Math.min(5, Math.max(0, maxPingPongTurns))
225
- }
153
+ dmScope
226
154
  }
227
155
  }
228
156
  });
@@ -289,21 +217,6 @@ export function RuntimeConfig() {
289
217
  </SelectContent>
290
218
  </Select>
291
219
  </div>
292
- <div className="space-y-2">
293
- <label className="text-sm font-medium text-gray-800">
294
- {maxPingHint?.label ?? t('maxPingPongTurns')}
295
- </label>
296
- <Input
297
- type="number"
298
- min={0}
299
- max={5}
300
- value={maxPingPongTurns}
301
- onChange={(event) => setMaxPingPongTurns(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
302
- />
303
- <p className="text-xs text-gray-500">
304
- {maxPingHint?.help ?? t('maxPingPongTurnsHelp')}
305
- </p>
306
- </div>
307
220
  </CardContent>
308
221
  </Card>
309
222
 
@@ -389,7 +302,7 @@ export function RuntimeConfig() {
389
302
  </div>
390
303
  ))}
391
304
 
392
- <Button type="button" variant="outline" onClick={() => setAgents((prev) => [...prev, createEmptyAgent()])}>
305
+ <Button type="button" variant="outline" onClick={() => setAgents((prev) => [...prev, createEmptyRuntimeAgent()])}>
393
306
  <Plus className="h-4 w-4 mr-2" />
394
307
  {t('addAgent')}
395
308
  </Button>
@@ -510,7 +423,7 @@ export function RuntimeConfig() {
510
423
  );
511
424
  })}
512
425
 
513
- <Button type="button" variant="outline" onClick={() => setBindings((prev) => [...prev, createEmptyBinding()])}>
426
+ <Button type="button" variant="outline" onClick={() => setBindings((prev) => [...prev, createEmptyRuntimeBinding()])}>
514
427
  <Plus className="h-4 w-4 mr-2" />
515
428
  {t('addBinding')}
516
429
  </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