@nextclaw/ui 0.10.0 → 0.10.2
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 +24 -1
- package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-DSMuOmMG.js} +4 -4
- package/dist/assets/ChatPage-do9TwNxj.js +38 -0
- package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-BjoTblYl.js} +1 -1
- package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-2yDaYdxw.js} +1 -1
- package/dist/assets/MarketplacePage-DVVk4dlH.js +49 -0
- package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-B4WUzuLw.js} +1 -1
- package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-Dr0eI9nN.js} +1 -1
- package/dist/assets/ProvidersList-C7A-mIbe.js +1 -0
- package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-CI3Am3w1.js} +1 -1
- package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-DvSNVSs8.js} +1 -1
- package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-B6TGIZow.js} +1 -1
- package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-CpxaKU1j.js} +1 -1
- package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-B-VHnv4G.js} +1 -1
- package/dist/assets/{chat-message-BOdA4h43.js → chat-message-BMqngrjp.js} +1 -1
- package/dist/assets/index-C6MeoecJ.js +8 -0
- package/dist/assets/index-DdXzLuNG.css +1 -0
- package/dist/assets/{label-BYZ62ajO.js → label-s2ILtQeP.js} +1 -1
- package/dist/assets/{page-layout-UC-h92sU.js → page-layout-BX5Ro4Sj.js} +1 -1
- package/dist/assets/{popover-DASCEr3G.js → popover-qmNpQSIy.js} +1 -1
- package/dist/assets/{security-config-Cvujq4fH.js → security-config--F-f-nDl.js} +1 -1
- package/dist/assets/skeleton-DthPOKSc.js +1 -0
- package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DWj7aUy8.js} +1 -1
- package/dist/assets/{switch-D3wVuCSh.js → switch-62r7L4Lj.js} +1 -1
- package/dist/assets/tabs-custom-DEmoGMsc.js +1 -0
- package/dist/assets/useConfirmDialog-DzT94nC_.js +1 -0
- package/dist/assets/{vendor-DJt0Azq5.js → vendor-CNhxtHCf.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +5 -5
- package/src/App.test.tsx +41 -0
- package/src/App.tsx +37 -0
- package/src/api/client.test.ts +12 -0
- package/src/api/client.ts +4 -2
- package/src/api/config.ts +1 -1
- package/src/components/chat/ChatSidebar.tsx +41 -69
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
- package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
- package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
- package/src/components/chat/chat-composer-state.ts +38 -0
- package/src/components/chat/chat-stream/types.ts +2 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
- package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
- package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
- package/src/components/chat/managers/chat-thread.manager.ts +0 -1
- package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
- package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
- package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
- package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
- package/src/components/chat/stores/chat-input.store.ts +3 -0
- package/src/components/config/ChannelsList.test.tsx +2 -1
- package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
- package/src/components/layout/Sidebar.tsx +62 -102
- package/src/components/layout/sidebar-items.tsx +172 -0
- package/src/components/layout/sidebar.layout.test.tsx +11 -4
- package/src/hooks/use-auth.ts +1 -2
- package/src/lib/i18n.chat.ts +117 -0
- package/src/lib/i18n.remote.ts +1 -1
- package/src/lib/i18n.ts +2 -112
- package/src/transport/local.transport.ts +28 -7
- package/src/transport/remote.transport.test.ts +135 -0
- package/src/transport/remote.transport.ts +14 -1
- package/src/transport/transport.types.ts +1 -0
- package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
- package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
- package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
- package/dist/assets/index-C63mHRbE.css +0 -1
- package/dist/assets/index-DS7D1-KS.js +0 -8
- package/dist/assets/skeleton-DlYEKkkj.js +0 -1
- package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
- package/dist/assets/useConfirmDialog-BYbFEIbQ.js +0 -1
|
@@ -1,127 +1,9 @@
|
|
|
1
|
-
import { API_BASE } from '@/api/api-base';
|
|
2
|
-
import { appClient } from '@/transport';
|
|
3
|
-
|
|
4
1
|
type FetchLike = typeof fetch;
|
|
5
2
|
|
|
6
3
|
export function createNcpAppClientFetch(): FetchLike {
|
|
7
|
-
return
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
const data = await appClient.request<unknown>({
|
|
15
|
-
method: request.method,
|
|
16
|
-
path: request.path,
|
|
17
|
-
...(request.body !== undefined ? { body: request.body } : {})
|
|
18
|
-
});
|
|
19
|
-
return new Response(JSON.stringify(data ?? {}), {
|
|
20
|
-
status: 200,
|
|
21
|
-
headers: {
|
|
22
|
-
'content-type': 'application/json'
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
} catch (error) {
|
|
26
|
-
return new Response(error instanceof Error ? error.message : String(error), {
|
|
27
|
-
status: 500,
|
|
28
|
-
headers: {
|
|
29
|
-
'content-type': 'text/plain; charset=utf-8'
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
type RequestSnapshot = {
|
|
37
|
-
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
38
|
-
path: string;
|
|
39
|
-
body?: unknown;
|
|
40
|
-
signal?: AbortSignal;
|
|
41
|
-
headers: Headers;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
function toRequestSnapshot(input: URL | string | Request, init?: RequestInit): RequestSnapshot {
|
|
45
|
-
const request = input instanceof Request ? input : null;
|
|
46
|
-
const url = new URL(
|
|
47
|
-
typeof input === 'string'
|
|
48
|
-
? input
|
|
49
|
-
: input instanceof URL
|
|
50
|
-
? input.toString()
|
|
51
|
-
: input.url,
|
|
52
|
-
API_BASE
|
|
53
|
-
);
|
|
54
|
-
const headers = new Headers(init?.headers ?? request?.headers);
|
|
55
|
-
const method = ((init?.method ?? request?.method ?? 'GET').toUpperCase()) as RequestSnapshot['method'];
|
|
56
|
-
return {
|
|
57
|
-
method,
|
|
58
|
-
path: `${url.pathname}${url.search}`,
|
|
59
|
-
body: parseRequestBody(init?.body),
|
|
60
|
-
signal: init?.signal ?? request?.signal ?? undefined,
|
|
61
|
-
headers
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function parseRequestBody(body: BodyInit | null | undefined): unknown {
|
|
66
|
-
if (body === undefined || body === null) {
|
|
67
|
-
return undefined;
|
|
68
|
-
}
|
|
69
|
-
if (typeof body === 'string') {
|
|
70
|
-
try {
|
|
71
|
-
return JSON.parse(body);
|
|
72
|
-
} catch {
|
|
73
|
-
return body;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return body;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function isSseRequest(request: RequestSnapshot): boolean {
|
|
80
|
-
const accept = request.headers.get('accept')?.toLowerCase() ?? '';
|
|
81
|
-
return accept.includes('text/event-stream');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function createSseResponse(request: RequestSnapshot): Response {
|
|
85
|
-
const encoder = new TextEncoder();
|
|
86
|
-
let session: ReturnType<typeof appClient.openStream<unknown>> | null = null;
|
|
87
|
-
|
|
88
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
89
|
-
start(controller) {
|
|
90
|
-
session = appClient.openStream<unknown>({
|
|
91
|
-
method: request.method === 'GET' ? 'GET' : 'POST',
|
|
92
|
-
path: request.path,
|
|
93
|
-
...(request.body !== undefined ? { body: request.body } : {}),
|
|
94
|
-
signal: request.signal,
|
|
95
|
-
onEvent: (event) => {
|
|
96
|
-
controller.enqueue(encoder.encode(encodeSseFrame(event.name, event.payload)));
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
void session.finished
|
|
101
|
-
.then(() => {
|
|
102
|
-
controller.close();
|
|
103
|
-
})
|
|
104
|
-
.catch((error) => {
|
|
105
|
-
controller.enqueue(encoder.encode(encodeSseFrame('error', {
|
|
106
|
-
message: error instanceof Error ? error.message : String(error)
|
|
107
|
-
})));
|
|
108
|
-
controller.close();
|
|
109
|
-
});
|
|
110
|
-
},
|
|
111
|
-
cancel() {
|
|
112
|
-
session?.cancel();
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
return new Response(stream, {
|
|
117
|
-
status: 200,
|
|
118
|
-
headers: {
|
|
119
|
-
'content-type': 'text/event-stream'
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function encodeSseFrame(event: string, payload: unknown): string {
|
|
125
|
-
const data = payload === undefined ? '' : JSON.stringify(payload);
|
|
126
|
-
return `event: ${event}\ndata: ${data}\n\n`;
|
|
4
|
+
return (input, init) =>
|
|
5
|
+
fetch(input, {
|
|
6
|
+
credentials: 'include',
|
|
7
|
+
...init
|
|
8
|
+
});
|
|
127
9
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
2
3
|
import type { SetStateAction } from 'react';
|
|
3
4
|
import type { ThinkingLevel } from '@/api/types';
|
|
4
5
|
import { updateNcpSession } from '@/api/ncp-session';
|
|
@@ -7,6 +8,8 @@ import {
|
|
|
7
8
|
createInitialChatComposerNodes,
|
|
8
9
|
deriveChatComposerDraft,
|
|
9
10
|
deriveSelectedSkillsFromComposer,
|
|
11
|
+
pruneComposerAttachments,
|
|
12
|
+
syncComposerAttachments,
|
|
10
13
|
syncComposerSkills
|
|
11
14
|
} from '@/components/chat/chat-composer-state';
|
|
12
15
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
@@ -48,13 +51,47 @@ export class NcpChatInputManager {
|
|
|
48
51
|
left.length === right.length && left.every((value, index) => value === right[index]);
|
|
49
52
|
|
|
50
53
|
private syncComposerSnapshot = (nodes: ChatComposerNode[]) => {
|
|
54
|
+
const currentAttachments = useChatInputStore.getState().snapshot.attachments;
|
|
55
|
+
const attachments = pruneComposerAttachments(nodes, currentAttachments);
|
|
51
56
|
useChatInputStore.getState().setSnapshot({
|
|
52
57
|
composerNodes: nodes,
|
|
58
|
+
attachments,
|
|
53
59
|
draft: deriveChatComposerDraft(nodes),
|
|
54
60
|
selectedSkills: deriveSelectedSkillsFromComposer(nodes)
|
|
55
61
|
});
|
|
56
62
|
};
|
|
57
63
|
|
|
64
|
+
private syncComposerSnapshotWithAttachments = (
|
|
65
|
+
nodes: ChatComposerNode[],
|
|
66
|
+
attachments: NcpDraftAttachment[]
|
|
67
|
+
) => {
|
|
68
|
+
useChatInputStore.getState().setSnapshot({
|
|
69
|
+
composerNodes: nodes,
|
|
70
|
+
attachments,
|
|
71
|
+
draft: deriveChatComposerDraft(nodes),
|
|
72
|
+
selectedSkills: deriveSelectedSkillsFromComposer(nodes)
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
private dedupeAttachments = (attachments: NcpDraftAttachment[]): NcpDraftAttachment[] => {
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
const output: NcpDraftAttachment[] = [];
|
|
79
|
+
for (const attachment of attachments) {
|
|
80
|
+
const signature = [
|
|
81
|
+
attachment.name,
|
|
82
|
+
attachment.mimeType,
|
|
83
|
+
String(attachment.sizeBytes),
|
|
84
|
+
attachment.contentBase64,
|
|
85
|
+
].join(':');
|
|
86
|
+
if (seen.has(signature)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
seen.add(signature);
|
|
90
|
+
output.push(attachment);
|
|
91
|
+
}
|
|
92
|
+
return output;
|
|
93
|
+
};
|
|
94
|
+
|
|
58
95
|
syncSnapshot = (patch: Partial<ChatInputSnapshot>) => {
|
|
59
96
|
if (!this.hasSnapshotChanges(patch)) {
|
|
60
97
|
return;
|
|
@@ -88,6 +125,22 @@ export class NcpChatInputManager {
|
|
|
88
125
|
this.syncComposerSnapshot(value);
|
|
89
126
|
};
|
|
90
127
|
|
|
128
|
+
addAttachments = (attachments: NcpDraftAttachment[]) => {
|
|
129
|
+
if (attachments.length === 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const snapshot = useChatInputStore.getState().snapshot;
|
|
133
|
+
const nextAttachments = this.dedupeAttachments([...snapshot.attachments, ...attachments]);
|
|
134
|
+
const nextNodes = syncComposerAttachments(snapshot.composerNodes, nextAttachments);
|
|
135
|
+
this.syncComposerSnapshotWithAttachments(nextNodes, nextAttachments);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
restoreComposerState = (nodes: ChatComposerNode[], attachments: NcpDraftAttachment[]) => {
|
|
139
|
+
const nextAttachments = this.dedupeAttachments(attachments);
|
|
140
|
+
const nextNodes = syncComposerAttachments(nodes, nextAttachments);
|
|
141
|
+
this.syncComposerSnapshotWithAttachments(nextNodes, nextAttachments);
|
|
142
|
+
};
|
|
143
|
+
|
|
91
144
|
setPendingSessionType = (next: SetStateAction<string>) => {
|
|
92
145
|
const prev = useChatInputStore.getState().snapshot.pendingSessionType;
|
|
93
146
|
const value = this.resolveUpdateValue(prev, next);
|
|
@@ -101,7 +154,8 @@ export class NcpChatInputManager {
|
|
|
101
154
|
const inputSnapshot = useChatInputStore.getState().snapshot;
|
|
102
155
|
const sessionSnapshot = useChatSessionListStore.getState().snapshot;
|
|
103
156
|
const message = inputSnapshot.draft.trim();
|
|
104
|
-
|
|
157
|
+
const attachments = inputSnapshot.attachments;
|
|
158
|
+
if (!message && attachments.length === 0) {
|
|
105
159
|
return;
|
|
106
160
|
}
|
|
107
161
|
const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
|
|
@@ -119,6 +173,7 @@ export class NcpChatInputManager {
|
|
|
119
173
|
thinkingLevel: inputSnapshot.selectedThinkingLevel ?? undefined,
|
|
120
174
|
stopSupported: true,
|
|
121
175
|
requestedSkills,
|
|
176
|
+
attachments,
|
|
122
177
|
restoreDraftOnError: true,
|
|
123
178
|
composerNodes
|
|
124
179
|
});
|
|
@@ -16,6 +16,14 @@ const modelOptions: ChatModelOption[] = [
|
|
|
16
16
|
];
|
|
17
17
|
|
|
18
18
|
describe('filterModelOptionsBySessionType', () => {
|
|
19
|
+
it('keeps the full model catalog when the session type does not publish a supportedModels whitelist', () => {
|
|
20
|
+
expect(
|
|
21
|
+
filterModelOptionsBySessionType({
|
|
22
|
+
modelOptions
|
|
23
|
+
})
|
|
24
|
+
).toEqual(modelOptions);
|
|
25
|
+
});
|
|
26
|
+
|
|
19
27
|
it('keeps only session-type-supported models when the runtime publishes a filtered list', () => {
|
|
20
28
|
expect(
|
|
21
29
|
filterModelOptionsBySessionType({
|
|
@@ -79,7 +79,6 @@ export class NcpChatThreadManager {
|
|
|
79
79
|
try {
|
|
80
80
|
await deleteNcpSessionApi(selectedSessionKey);
|
|
81
81
|
this.streamActionsManager.resetStreamState();
|
|
82
|
-
useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
|
|
83
82
|
this.uiManager.goToChatRoot({ replace: true });
|
|
84
83
|
await this.actions.refetchSessions();
|
|
85
84
|
} finally {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
2
3
|
import { createContext, useContext } from 'react';
|
|
3
4
|
import type { ReactNode } from 'react';
|
|
4
5
|
import type { SetStateAction } from 'react';
|
|
@@ -13,6 +14,11 @@ export type ChatInputManagerLike = {
|
|
|
13
14
|
syncSnapshot: (patch: Record<string, unknown>) => void;
|
|
14
15
|
setDraft: (next: SetStateAction<string>) => void;
|
|
15
16
|
setComposerNodes: (next: SetStateAction<ChatComposerNode[]>) => void;
|
|
17
|
+
addAttachments?: (attachments: NcpDraftAttachment[]) => void;
|
|
18
|
+
restoreComposerState?: (
|
|
19
|
+
nodes: ChatComposerNode[],
|
|
20
|
+
attachments: NcpDraftAttachment[]
|
|
21
|
+
) => void;
|
|
16
22
|
setPendingSessionType: (next: SetStateAction<string>) => void;
|
|
17
23
|
send: () => Promise<void>;
|
|
18
24
|
stop: () => Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
3
|
+
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
3
4
|
import type { MarketplaceInstalledRecord } from '@/api/types';
|
|
4
5
|
import type { ThinkingLevel } from '@/api/types';
|
|
5
6
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
@@ -8,6 +9,7 @@ import { createInitialChatComposerNodes } from '@/components/chat/chat-composer-
|
|
|
8
9
|
export type ChatInputSnapshot = {
|
|
9
10
|
isProviderStateResolved: boolean;
|
|
10
11
|
composerNodes: ChatComposerNode[];
|
|
12
|
+
attachments: NcpDraftAttachment[];
|
|
11
13
|
draft: string;
|
|
12
14
|
pendingSessionType: string;
|
|
13
15
|
defaultSessionType: string;
|
|
@@ -50,6 +52,7 @@ type ChatInputStore = {
|
|
|
50
52
|
const initialSnapshot: ChatInputSnapshot = {
|
|
51
53
|
isProviderStateResolved: false,
|
|
52
54
|
composerNodes: createInitialChatComposerNodes(),
|
|
55
|
+
attachments: [],
|
|
53
56
|
draft: '',
|
|
54
57
|
pendingSessionType: 'native',
|
|
55
58
|
defaultSessionType: 'native',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import type * as ReactQueryModule from '@tanstack/react-query';
|
|
3
4
|
import { ChannelsList } from '@/components/config/ChannelsList';
|
|
4
5
|
|
|
5
6
|
const mocks = vi.hoisted(() => ({
|
|
@@ -58,7 +59,7 @@ vi.mock('qrcode', () => ({
|
|
|
58
59
|
}));
|
|
59
60
|
|
|
60
61
|
vi.mock('@tanstack/react-query', async () => {
|
|
61
|
-
const actual = await vi.importActual<typeof
|
|
62
|
+
const actual = await vi.importActual<typeof ReactQueryModule>('@tanstack/react-query');
|
|
62
63
|
return {
|
|
63
64
|
...actual,
|
|
64
65
|
useQueryClient: () => ({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import type * as ReactQueryModule from '@tanstack/react-query';
|
|
3
4
|
import { WeixinChannelAuthSection } from './weixin-channel-auth-section';
|
|
4
5
|
|
|
5
6
|
const mocks = vi.hoisted(() => ({
|
|
@@ -9,7 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
9
10
|
}));
|
|
10
11
|
|
|
11
12
|
vi.mock('@tanstack/react-query', async () => {
|
|
12
|
-
const actual = await vi.importActual<typeof
|
|
13
|
+
const actual = await vi.importActual<typeof ReactQueryModule>('@tanstack/react-query');
|
|
13
14
|
return {
|
|
14
15
|
...actual,
|
|
15
16
|
useQueryClient: () => ({
|
|
@@ -5,9 +5,10 @@ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOp
|
|
|
5
5
|
import { NavLink } from 'react-router-dom';
|
|
6
6
|
import { useDocBrowser } from '@/components/doc-browser';
|
|
7
7
|
import { BrandHeader } from '@/components/common/BrandHeader';
|
|
8
|
+
import { SidebarActionItem, SidebarNavLinkItem, SidebarSelectItem } from '@/components/layout/sidebar-items';
|
|
8
9
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
9
10
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
|
10
|
-
import {
|
|
11
|
+
import { SelectItem } from '@/components/ui/select';
|
|
11
12
|
import { useRemoteStatus } from '@/hooks/useRemoteAccess';
|
|
12
13
|
import { useAppPresenter } from '@/presenter/app-presenter-context';
|
|
13
14
|
|
|
@@ -23,6 +24,7 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
23
24
|
const remoteStatus = useRemoteStatus();
|
|
24
25
|
const { language, setLanguage } = useI18n();
|
|
25
26
|
const { theme, setTheme } = useTheme();
|
|
27
|
+
const isSettingsMode = mode === 'settings';
|
|
26
28
|
const currentLanguageLabel = LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? language;
|
|
27
29
|
const currentThemeLabel = t(THEME_OPTIONS.find((option) => option.value === theme)?.labelKey ?? 'themeWarm');
|
|
28
30
|
const accountEmail = remoteStatus.data?.account.email?.trim();
|
|
@@ -119,11 +121,12 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
119
121
|
icon: Wrench,
|
|
120
122
|
}
|
|
121
123
|
];
|
|
122
|
-
const navItems =
|
|
124
|
+
const navItems = isSettingsMode ? settingsNavItems : mainNavItems;
|
|
125
|
+
const sidebarDensity = isSettingsMode ? 'compact' : 'default';
|
|
123
126
|
|
|
124
127
|
return (
|
|
125
128
|
<aside className="w-[240px] shrink-0 flex h-full min-h-0 flex-col overflow-hidden bg-secondary px-4 py-6">
|
|
126
|
-
{
|
|
129
|
+
{isSettingsMode ? (
|
|
127
130
|
<div className="shrink-0 px-2 pb-3">
|
|
128
131
|
<div
|
|
129
132
|
className="flex items-center gap-2 px-1 py-1"
|
|
@@ -131,7 +134,7 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
131
134
|
>
|
|
132
135
|
<NavLink
|
|
133
136
|
to="/chat"
|
|
134
|
-
className="group inline-flex min-w-0 items-center gap-1.5 rounded-lg px-1 py-1 text-[12px] font-medium text-gray-500 transition-colors hover:text-gray-900"
|
|
137
|
+
className="group inline-flex min-w-0 items-center gap-1.5 rounded-lg px-1 py-1 text-[12px] font-medium text-gray-500 transition-colors hover:bg-gray-200/60 hover:text-gray-900"
|
|
135
138
|
>
|
|
136
139
|
<ArrowLeft className="h-3.5 w-3.5 shrink-0 text-gray-400 group-hover:text-gray-700" />
|
|
137
140
|
<span className="truncate">{t('backToMain')}</span>
|
|
@@ -149,35 +152,16 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
149
152
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
150
153
|
{/* Navigation */}
|
|
151
154
|
<nav className="custom-scrollbar min-h-0 flex-1 overflow-y-auto pr-1">
|
|
152
|
-
<ul className=
|
|
155
|
+
<ul className={cn(isSettingsMode ? 'space-y-0.5 pb-3' : 'space-y-1 pb-4')}>
|
|
153
156
|
{navItems.map((item) => {
|
|
154
|
-
const Icon = item.icon;
|
|
155
|
-
|
|
156
157
|
return (
|
|
157
158
|
<li key={item.target}>
|
|
158
|
-
<
|
|
159
|
+
<SidebarNavLinkItem
|
|
159
160
|
to={item.target}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
|
|
165
|
-
: 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
>
|
|
169
|
-
{({ isActive }) => (
|
|
170
|
-
<>
|
|
171
|
-
<Icon
|
|
172
|
-
className={cn(
|
|
173
|
-
'h-[17px] w-[17px] transition-colors',
|
|
174
|
-
isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
|
|
175
|
-
)}
|
|
176
|
-
/>
|
|
177
|
-
<span className="flex-1 text-left">{item.label}</span>
|
|
178
|
-
</>
|
|
179
|
-
)}
|
|
180
|
-
</NavLink>
|
|
161
|
+
label={item.label}
|
|
162
|
+
icon={item.icon}
|
|
163
|
+
density={sidebarDensity}
|
|
164
|
+
/>
|
|
181
165
|
</li>
|
|
182
166
|
);
|
|
183
167
|
})}
|
|
@@ -185,91 +169,67 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
185
169
|
</nav>
|
|
186
170
|
|
|
187
171
|
{/* Footer actions stay reachable while the nav scrolls independently. */}
|
|
188
|
-
<div className=
|
|
189
|
-
{
|
|
190
|
-
<
|
|
172
|
+
<div className={cn('shrink-0 border-t border-[#dde0ea] bg-secondary', isSettingsMode ? 'mt-2 pt-3' : 'mt-3 pt-3')}>
|
|
173
|
+
{isSettingsMode ? (
|
|
174
|
+
<SidebarActionItem
|
|
191
175
|
onClick={() => presenter.accountManager.openAccountPanel()}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<p className="mt-1 truncate text-xs text-gray-500">
|
|
202
|
-
{accountConnected ? accountEmail || t('remoteAccountEntryConnected') : t('remoteAccountEntryDisconnected')}
|
|
203
|
-
</p>
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
</button>
|
|
176
|
+
icon={KeyRound}
|
|
177
|
+
label={t('remoteAccountEntryManage')}
|
|
178
|
+
density="compact"
|
|
179
|
+
className="mb-1.5"
|
|
180
|
+
trailing={accountConnected ? accountEmail || t('remoteAccountEntryConnected') : t('remoteAccountEntryDisconnected')}
|
|
181
|
+
trailingClassName="max-w-[92px] truncate text-right"
|
|
182
|
+
testId="settings-sidebar-account-entry"
|
|
183
|
+
trailingTestId="settings-sidebar-account-status"
|
|
184
|
+
/>
|
|
207
185
|
) : null}
|
|
208
186
|
{mode === 'main' && (
|
|
209
187
|
<div className="mb-2">
|
|
210
|
-
<
|
|
188
|
+
<SidebarNavLinkItem
|
|
211
189
|
to="/settings"
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
isActive
|
|
216
|
-
? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
|
|
217
|
-
: 'text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-900'
|
|
218
|
-
)
|
|
219
|
-
}
|
|
220
|
-
>
|
|
221
|
-
{({ isActive }) => (
|
|
222
|
-
<>
|
|
223
|
-
<Settings className={cn('h-[17px] w-[17px] transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
|
|
224
|
-
<span className="flex-1 text-left">{t('settings')}</span>
|
|
225
|
-
</>
|
|
226
|
-
)}
|
|
227
|
-
</NavLink>
|
|
190
|
+
label={t('settings')}
|
|
191
|
+
icon={Settings}
|
|
192
|
+
/>
|
|
228
193
|
</div>
|
|
229
194
|
)}
|
|
230
195
|
<div className="mb-2">
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
</SelectContent>
|
|
246
|
-
</Select>
|
|
196
|
+
<SidebarSelectItem
|
|
197
|
+
value={theme}
|
|
198
|
+
onValueChange={(value) => handleThemeSwitch(value as UiTheme)}
|
|
199
|
+
icon={Palette}
|
|
200
|
+
label={t('theme')}
|
|
201
|
+
valueLabel={currentThemeLabel}
|
|
202
|
+
density={sidebarDensity}
|
|
203
|
+
>
|
|
204
|
+
{THEME_OPTIONS.map((option) => (
|
|
205
|
+
<SelectItem key={option.value} value={option.value} className="text-xs">
|
|
206
|
+
{t(option.labelKey)}
|
|
207
|
+
</SelectItem>
|
|
208
|
+
))}
|
|
209
|
+
</SidebarSelectItem>
|
|
247
210
|
</div>
|
|
248
211
|
<div className="mb-2">
|
|
249
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
{
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
</SelectContent>
|
|
264
|
-
</Select>
|
|
212
|
+
<SidebarSelectItem
|
|
213
|
+
value={language}
|
|
214
|
+
onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}
|
|
215
|
+
icon={Languages}
|
|
216
|
+
label={t('language')}
|
|
217
|
+
valueLabel={currentLanguageLabel}
|
|
218
|
+
density={sidebarDensity}
|
|
219
|
+
>
|
|
220
|
+
{LANGUAGE_OPTIONS.map((option) => (
|
|
221
|
+
<SelectItem key={option.value} value={option.value} className="text-xs">
|
|
222
|
+
{option.label}
|
|
223
|
+
</SelectItem>
|
|
224
|
+
))}
|
|
225
|
+
</SidebarSelectItem>
|
|
265
226
|
</div>
|
|
266
|
-
<
|
|
227
|
+
<SidebarActionItem
|
|
267
228
|
onClick={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
</button>
|
|
229
|
+
icon={BookOpen}
|
|
230
|
+
label={t('docBrowserHelp')}
|
|
231
|
+
density={sidebarDensity}
|
|
232
|
+
/>
|
|
273
233
|
</div>
|
|
274
234
|
</div>
|
|
275
235
|
</aside>
|