@nextclaw/ui 0.6.6 → 0.6.8

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 (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-DH5fzlPu.js +1 -0
  3. package/dist/assets/ChatPage-BrLCnJSb.js +34 -0
  4. package/dist/assets/DocBrowser-DPQHJVsZ.js +1 -0
  5. package/dist/assets/LogoBadge-FEb4_vSq.js +1 -0
  6. package/dist/assets/{MarketplacePage--wFfsNH0.js → MarketplacePage-BAVXYeZA.js} +2 -2
  7. package/dist/assets/ModelConfig-BqPXe7nw.js +1 -0
  8. package/dist/assets/ProvidersList-vpKPuIxV.js +1 -0
  9. package/dist/assets/RuntimeConfig-DTYSU4_d.js +1 -0
  10. package/dist/assets/{SecretsConfig-B25P3J7V.js → SecretsConfig-nNzs3YDm.js} +2 -2
  11. package/dist/assets/SessionsConfig-CHjeyqEQ.js +2 -0
  12. package/dist/assets/{card-CCSDsedj.js → card-73MmEZi7.js} +1 -1
  13. package/dist/assets/index-CTLvVlk8.js +7 -0
  14. package/dist/assets/index-DI6BuShn.css +1 -0
  15. package/dist/assets/input-1MCMs6Yf.js +1 -0
  16. package/dist/assets/{label-BxzAKPzU.js → label-C4Q8RlBJ.js} +1 -1
  17. package/dist/assets/{page-layout-DaLNSFKw.js → page-layout-CK0vcVmV.js} +1 -1
  18. package/dist/assets/session-run-status-BaNlKvi6.js +5 -0
  19. package/dist/assets/{switch-DHOCEi5L.js → switch-Bf8w_cF1.js} +1 -1
  20. package/dist/assets/{tabs-custom-zdFy3fnK.js → tabs-custom-B6Gw8gax.js} +1 -1
  21. package/dist/assets/{useConfirmDialog-D3ZVa92J.js → useConfirmDialog-B5CZ4EDN.js} +1 -1
  22. package/dist/assets/{vendor-Dj2ULvht.js → vendor-C--HHaLf.js} +6 -6
  23. package/dist/index.html +3 -3
  24. package/package.json +1 -1
  25. package/src/api/config.ts +53 -0
  26. package/src/api/types.ts +48 -0
  27. package/src/components/chat/ChatInputBar.tsx +341 -24
  28. package/src/components/chat/ChatPage.tsx +28 -12
  29. package/src/components/chat/ChatSidebar.tsx +12 -7
  30. package/src/components/common/BrandHeader.tsx +23 -0
  31. package/src/components/common/SessionRunBadge.tsx +23 -0
  32. package/src/components/config/ProviderForm.tsx +193 -29
  33. package/src/components/config/ProvidersList.tsx +1 -2
  34. package/src/components/config/SessionsConfig.tsx +22 -2
  35. package/src/components/layout/Sidebar.tsx +2 -6
  36. package/src/hooks/useConfig.ts +31 -0
  37. package/src/lib/i18n.ts +28 -1
  38. package/src/lib/logos.ts +0 -19
  39. package/src/lib/session-run-status.ts +63 -0
  40. package/dist/assets/ChannelsList-VqzbAMCc.js +0 -1
  41. package/dist/assets/ChatPage-CjZqsBmn.js +0 -34
  42. package/dist/assets/DocBrowser-DvU-iUeB.js +0 -1
  43. package/dist/assets/ModelConfig-cY5UsbfA.js +0 -1
  44. package/dist/assets/ProvidersList-qZwaFoFt.js +0 -1
  45. package/dist/assets/RuntimeConfig-BY2Axlte.js +0 -1
  46. package/dist/assets/SessionsConfig-CxA9gIBw.js +0 -2
  47. package/dist/assets/chat-message-pw9oafI4.js +0 -5
  48. package/dist/assets/index-CD8a2KMH.js +0 -2
  49. package/dist/assets/index-DKOXGZc8.css +0 -1
  50. package/dist/assets/logos-C3oHQ9kv.js +0 -1
  51. package/dist/assets/useConfig-CDl9UK5m.js +0 -6
@@ -1,10 +1,13 @@
1
1
  import { useMemo } from 'react';
2
2
  import type { SessionEntryView } from '@/api/types';
3
3
  import { Button } from '@/components/ui/button';
4
+ import { BrandHeader } from '@/components/common/BrandHeader';
4
5
  import { Input } from '@/components/ui/input';
5
6
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
7
+ import { SessionRunBadge } from '@/components/common/SessionRunBadge';
6
8
  import { cn } from '@/lib/utils';
7
9
  import { LANGUAGE_OPTIONS, formatDateTime, t, type I18nLanguage } from '@/lib/i18n';
10
+ import type { SessionRunStatus } from '@/lib/session-run-status';
8
11
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
9
12
  import { useI18n } from '@/components/providers/I18nProvider';
10
13
  import { useTheme } from '@/components/providers/ThemeProvider';
@@ -13,6 +16,7 @@ import { AlarmClock, BrainCircuit, Languages, MessageSquareText, Palette, Plus,
13
16
 
14
17
  type ChatSidebarProps = {
15
18
  sessions: SessionEntryView[];
19
+ sessionRunStatusByKey: ReadonlyMap<string, SessionRunStatus>;
16
20
  selectedSessionKey: string | null;
17
21
  onSelectSession: (key: string) => void;
18
22
  onCreateSession: () => void;
@@ -82,12 +86,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
82
86
  <aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
83
87
  {/* Logo */}
84
88
  <div className="px-5 pt-5 pb-3">
85
- <div className="flex items-center gap-2.5">
86
- <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
87
- <img src="/logo.svg" alt="NextClaw" className="h-full w-full object-contain" />
88
- </div>
89
- <span className="text-[15px] font-semibold text-gray-800 tracking-[-0.01em]">NextClaw</span>
90
- </div>
89
+ <BrandHeader />
91
90
  </div>
92
91
 
93
92
  {/* New Task button */}
@@ -165,6 +164,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
165
164
  <div className="space-y-0.5">
166
165
  {group.sessions.map((session) => {
167
166
  const active = props.selectedSessionKey === session.key;
167
+ const runStatus = props.sessionRunStatusByKey.get(session.key);
168
168
  return (
169
169
  <button
170
170
  key={session.key}
@@ -176,7 +176,12 @@ export function ChatSidebar(props: ChatSidebarProps) {
176
176
  : 'text-gray-700 hover:bg-gray-200/60 hover:text-gray-900'
177
177
  )}
178
178
  >
179
- <div className="truncate font-medium">{props.sessionTitle(session)}</div>
179
+ <div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5">
180
+ <span className="truncate font-medium">{props.sessionTitle(session)}</span>
181
+ <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
182
+ {runStatus ? <SessionRunBadge status={runStatus} /> : null}
183
+ </span>
184
+ </div>
180
185
  <div className="mt-0.5 text-[11px] text-gray-400 truncate">
181
186
  {session.messageCount} · {formatDateTime(session.updatedAt)}
182
187
  </div>
@@ -0,0 +1,23 @@
1
+ import { useAppMeta } from '@/hooks/useConfig';
2
+
3
+ type BrandHeaderProps = {
4
+ className?: string;
5
+ };
6
+
7
+ export function BrandHeader({ className }: BrandHeaderProps) {
8
+ const { data } = useAppMeta();
9
+ const productName = data?.name ?? 'NextClaw';
10
+ const productVersion = data?.productVersion?.trim();
11
+
12
+ return (
13
+ <div className={className ?? 'flex items-center gap-2.5'}>
14
+ <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
15
+ <img src="/logo.svg" alt={productName} className="h-full w-full object-contain" />
16
+ </div>
17
+ <div className="flex items-baseline gap-2 min-w-0">
18
+ <span className="truncate text-[15px] font-semibold tracking-[-0.01em] text-gray-800">{productName}</span>
19
+ {productVersion ? <span className="text-[13px] font-medium text-gray-500">v{productVersion}</span> : null}
20
+ </div>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,23 @@
1
+ import { Loader2 } from 'lucide-react';
2
+ import { cn } from '@/lib/utils';
3
+ import { t } from '@/lib/i18n';
4
+ import type { SessionRunStatus } from '@/lib/session-run-status';
5
+
6
+ type SessionRunBadgeProps = {
7
+ status: SessionRunStatus;
8
+ className?: string;
9
+ };
10
+
11
+ export function SessionRunBadge({ status, className }: SessionRunBadgeProps) {
12
+ const label = status === 'running' ? t('sessionsRunStatusRunning') : t('sessionsRunStatusQueued');
13
+ return (
14
+ <span
15
+ className={cn('inline-flex h-3.5 w-3.5 items-center justify-center text-gray-400', className)}
16
+ title={label}
17
+ aria-label={label}
18
+ >
19
+ <Loader2 className="h-3 w-3 animate-spin" />
20
+ <span className="sr-only">{label}</span>
21
+ </span>
22
+ );
23
+ }
@@ -1,9 +1,13 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useQueryClient } from '@tanstack/react-query';
2
3
  import {
3
4
  useConfig,
4
5
  useConfigMeta,
5
6
  useConfigSchema,
6
7
  useDeleteProvider,
8
+ useImportProviderAuthFromCli,
9
+ usePollProviderAuth,
10
+ useStartProviderAuth,
7
11
  useTestProviderConnection,
8
12
  useUpdateProvider
9
13
  } from '@/hooks/useConfig';
@@ -14,9 +18,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
14
18
  import { MaskedInput } from '@/components/common/MaskedInput';
15
19
  import { KeyValueEditor } from '@/components/common/KeyValueEditor';
16
20
  import { StatusDot } from '@/components/ui/status-dot';
17
- import { t } from '@/lib/i18n';
21
+ import { getLanguage, t } from '@/lib/i18n';
18
22
  import { hintForPath } from '@/lib/config-hints';
19
- import type { ProviderConfigUpdate, ProviderConnectionTestRequest } from '@/api/types';
23
+ import type { ProviderConfigView, ProviderConfigUpdate, ProviderConnectionTestRequest } from '@/api/types';
20
24
  import { CircleDotDashed, Plus, X, Trash2, ChevronDown, Settings2 } from 'lucide-react';
21
25
  import { toast } from 'sonner';
22
26
  import { CONFIG_DETAIL_CARD_CLASS, CONFIG_EMPTY_DETAIL_CARD_CLASS } from './config-layout';
@@ -28,6 +32,16 @@ type ProviderFormProps = {
28
32
  onProviderDeleted?: (providerName: string) => void;
29
33
  };
30
34
 
35
+ const EMPTY_PROVIDER_CONFIG: ProviderConfigView = {
36
+ displayName: '',
37
+ apiKeySet: false,
38
+ apiKeyMasked: undefined,
39
+ apiBase: null,
40
+ extraHeaders: null,
41
+ wireApi: null,
42
+ models: []
43
+ };
44
+
31
45
  function normalizeHeaders(input: Record<string, string> | null | undefined): Record<string, string> | null {
32
46
  if (!input) {
33
47
  return null;
@@ -138,12 +152,16 @@ function serializeModelsForSave(models: string[], defaultModels: string[]): stri
138
152
  }
139
153
 
140
154
  export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormProps) {
155
+ const queryClient = useQueryClient();
141
156
  const { data: config } = useConfig();
142
157
  const { data: meta } = useConfigMeta();
143
158
  const { data: schema } = useConfigSchema();
144
159
  const updateProvider = useUpdateProvider();
145
160
  const deleteProvider = useDeleteProvider();
146
161
  const testProviderConnection = useTestProviderConnection();
162
+ const startProviderAuth = useStartProviderAuth();
163
+ const pollProviderAuth = usePollProviderAuth();
164
+ const importProviderAuthFromCli = useImportProviderAuthFromCli();
147
165
 
148
166
  const [apiKey, setApiKey] = useState('');
149
167
  const [apiBase, setApiBase] = useState('');
@@ -154,9 +172,13 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
154
172
  const [providerDisplayName, setProviderDisplayName] = useState('');
155
173
  const [showAdvanced, setShowAdvanced] = useState(false);
156
174
  const [showModelInput, setShowModelInput] = useState(false);
175
+ const [authSessionId, setAuthSessionId] = useState<string | null>(null);
176
+ const [authStatusMessage, setAuthStatusMessage] = useState('');
177
+ const authPollTimerRef = useRef<number | null>(null);
157
178
 
158
179
  const providerSpec = meta?.providers.find((p) => p.name === providerName);
159
180
  const providerConfig = providerName ? config?.providers[providerName] : null;
181
+ const resolvedProviderConfig = providerConfig ?? EMPTY_PROVIDER_CONFIG;
160
182
  const uiHints = schema?.uiHints;
161
183
  const isCustomProvider = Boolean(providerSpec?.isCustom);
162
184
 
@@ -165,7 +187,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
165
187
  const extraHeadersHint = providerName ? hintForPath(`providers.${providerName}.extraHeaders`, uiHints) : undefined;
166
188
  const wireApiHint = providerName ? hintForPath(`providers.${providerName}.wireApi`, uiHints) : undefined;
167
189
  const defaultDisplayName = providerSpec?.displayName || providerName || '';
168
- const currentDisplayName = (providerConfig?.displayName || '').trim();
190
+ const currentDisplayName = (resolvedProviderConfig.displayName || '').trim();
169
191
  const effectiveDisplayName = currentDisplayName || defaultDisplayName;
170
192
 
171
193
  const providerTitle = providerDisplayName.trim() || effectiveDisplayName || providerName || t('providersSelectPlaceholder');
@@ -175,9 +197,9 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
175
197
  [providerModelPrefix, providerName]
176
198
  );
177
199
  const defaultApiBase = providerSpec?.defaultApiBase || '';
178
- const currentApiBase = providerConfig?.apiBase || defaultApiBase;
179
- const currentHeaders = normalizeHeaders(providerConfig?.extraHeaders || null);
180
- const currentWireApi = (providerConfig?.wireApi || providerSpec?.defaultWireApi || 'auto') as WireApiType;
200
+ const currentApiBase = resolvedProviderConfig.apiBase || defaultApiBase;
201
+ const currentHeaders = normalizeHeaders(resolvedProviderConfig.extraHeaders || null);
202
+ const currentWireApi = (resolvedProviderConfig.wireApi || providerSpec?.defaultWireApi || 'auto') as WireApiType;
181
203
  const defaultModels = useMemo(
182
204
  () =>
183
205
  normalizeModelList(
@@ -188,17 +210,74 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
188
210
  const currentModels = useMemo(
189
211
  () =>
190
212
  normalizeModelList(
191
- (providerConfig?.models ?? []).map((model) => toProviderLocalModelId(model, providerModelAliases))
213
+ (resolvedProviderConfig.models ?? []).map((model) => toProviderLocalModelId(model, providerModelAliases))
192
214
  ),
193
- [providerConfig?.models, providerModelAliases]
215
+ [resolvedProviderConfig.models, providerModelAliases]
194
216
  );
195
217
  const currentEditableModels = useMemo(
196
218
  () => resolveEditableModels(defaultModels, currentModels),
197
219
  [defaultModels, currentModels]
198
220
  );
199
- const apiBaseHelpText = providerName === 'minimax'
200
- ? t('providerApiBaseHelpMinimax')
201
- : (apiBaseHint?.help || t('providerApiBaseHelp'));
221
+ const language = getLanguage();
222
+ const apiBaseHelpText =
223
+ providerSpec?.apiBaseHelp?.[language] ||
224
+ providerSpec?.apiBaseHelp?.en ||
225
+ apiBaseHint?.help ||
226
+ t('providerApiBaseHelp');
227
+ const providerAuth = providerSpec?.auth;
228
+ const providerAuthNote =
229
+ providerAuth?.note?.[language] ||
230
+ providerAuth?.note?.en ||
231
+ providerAuth?.displayName ||
232
+ '';
233
+
234
+ const clearAuthPollTimer = useCallback(() => {
235
+ if (authPollTimerRef.current !== null) {
236
+ window.clearTimeout(authPollTimerRef.current);
237
+ authPollTimerRef.current = null;
238
+ }
239
+ }, []);
240
+
241
+ const scheduleProviderAuthPoll = useCallback((sessionId: string, delayMs: number) => {
242
+ clearAuthPollTimer();
243
+ authPollTimerRef.current = window.setTimeout(() => {
244
+ void (async () => {
245
+ if (!providerName) {
246
+ return;
247
+ }
248
+ try {
249
+ const result = await pollProviderAuth.mutateAsync({
250
+ provider: providerName,
251
+ data: { sessionId }
252
+ });
253
+ if (result.status === 'pending') {
254
+ setAuthStatusMessage(t('providerAuthWaitingBrowser'));
255
+ scheduleProviderAuthPoll(sessionId, result.nextPollMs ?? delayMs);
256
+ return;
257
+ }
258
+ if (result.status === 'authorized') {
259
+ setAuthSessionId(null);
260
+ clearAuthPollTimer();
261
+ setAuthStatusMessage(t('providerAuthCompleted'));
262
+ toast.success(t('providerAuthCompleted'));
263
+ queryClient.invalidateQueries({ queryKey: ['config'] });
264
+ queryClient.invalidateQueries({ queryKey: ['config-meta'] });
265
+ return;
266
+ }
267
+ setAuthSessionId(null);
268
+ clearAuthPollTimer();
269
+ setAuthStatusMessage(result.message || `Authorization ${result.status}.`);
270
+ toast.error(result.message || `Authorization ${result.status}.`);
271
+ } catch (error) {
272
+ setAuthSessionId(null);
273
+ clearAuthPollTimer();
274
+ const message = error instanceof Error ? error.message : String(error);
275
+ setAuthStatusMessage(message);
276
+ toast.error(`Authorization failed: ${message}`);
277
+ }
278
+ })();
279
+ }, Math.max(1000, delayMs));
280
+ }, [clearAuthPollTimer, pollProviderAuth, providerName, queryClient]);
202
281
 
203
282
  useEffect(() => {
204
283
  if (!providerName) {
@@ -209,17 +288,25 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
209
288
  setModels([]);
210
289
  setModelDraft('');
211
290
  setProviderDisplayName('');
291
+ setAuthSessionId(null);
292
+ setAuthStatusMessage('');
293
+ clearAuthPollTimer();
212
294
  return;
213
295
  }
214
296
 
215
297
  setApiKey('');
216
298
  setApiBase(currentApiBase);
217
- setExtraHeaders(providerConfig?.extraHeaders || null);
299
+ setExtraHeaders(resolvedProviderConfig.extraHeaders || null);
218
300
  setWireApi(currentWireApi);
219
301
  setModels(currentEditableModels);
220
302
  setModelDraft('');
221
303
  setProviderDisplayName(effectiveDisplayName);
222
- }, [providerName, currentApiBase, providerConfig?.extraHeaders, currentWireApi, currentEditableModels, effectiveDisplayName]);
304
+ setAuthSessionId(null);
305
+ setAuthStatusMessage('');
306
+ clearAuthPollTimer();
307
+ }, [providerName, currentApiBase, resolvedProviderConfig.extraHeaders, currentWireApi, currentEditableModels, effectiveDisplayName, clearAuthPollTimer]);
308
+
309
+ useEffect(() => () => clearAuthPollTimer(), [clearAuthPollTimer]);
223
310
 
224
311
  const hasChanges = useMemo(() => {
225
312
  if (!providerName) {
@@ -252,16 +339,6 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
252
339
  currentEditableModels
253
340
  ]);
254
341
 
255
- const resetToDefault = () => {
256
- setApiKey('');
257
- setApiBase(defaultApiBase);
258
- setExtraHeaders(null);
259
- setWireApi((providerSpec?.defaultWireApi || 'auto') as WireApiType);
260
- setModels(defaultModels);
261
- setModelDraft('');
262
- setProviderDisplayName(defaultDisplayName);
263
- };
264
-
265
342
  const handleAddModel = () => {
266
343
  const next = toProviderLocalModelId(modelDraft, providerModelAliases);
267
344
  if (!next) {
@@ -369,7 +446,51 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
369
446
  }
370
447
  };
371
448
 
372
- if (!providerName || !providerSpec || !providerConfig) {
449
+ const handleStartProviderAuth = async () => {
450
+ if (!providerName || providerAuth?.kind !== 'device_code') {
451
+ return;
452
+ }
453
+
454
+ try {
455
+ setAuthStatusMessage('');
456
+ const result = await startProviderAuth.mutateAsync({ provider: providerName });
457
+ if (!result.sessionId || !result.verificationUri) {
458
+ throw new Error(t('providerAuthStartFailed'));
459
+ }
460
+ setAuthSessionId(result.sessionId);
461
+ setAuthStatusMessage(`${t('providerAuthOpenPrompt')}${result.userCode}${t('providerAuthOpenPromptSuffix')}`);
462
+ window.open(result.verificationUri, '_blank', 'noopener,noreferrer');
463
+ scheduleProviderAuthPoll(result.sessionId, result.intervalMs);
464
+ } catch (error) {
465
+ const message = error instanceof Error ? error.message : String(error);
466
+ setAuthSessionId(null);
467
+ clearAuthPollTimer();
468
+ setAuthStatusMessage(message);
469
+ toast.error(`${t('providerAuthStartFailed')}: ${message}`);
470
+ }
471
+ };
472
+
473
+ const handleImportProviderAuthFromCli = async () => {
474
+ if (!providerName || providerAuth?.kind !== 'device_code') {
475
+ return;
476
+ }
477
+ try {
478
+ clearAuthPollTimer();
479
+ setAuthSessionId(null);
480
+ const result = await importProviderAuthFromCli.mutateAsync({ provider: providerName });
481
+ const expiresText = result.expiresAt ? ` (expires: ${result.expiresAt})` : '';
482
+ setAuthStatusMessage(`${t('providerAuthImportStatusPrefix')}${expiresText}`);
483
+ toast.success(t('providerAuthImportSuccess'));
484
+ queryClient.invalidateQueries({ queryKey: ['config'] });
485
+ queryClient.invalidateQueries({ queryKey: ['config-meta'] });
486
+ } catch (error) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ setAuthStatusMessage(message);
489
+ toast.error(`${t('providerAuthImportFailed')}: ${message}`);
490
+ }
491
+ };
492
+
493
+ if (!providerName || !providerSpec) {
373
494
  return (
374
495
  <div className={CONFIG_EMPTY_DETAIL_CARD_CLASS}>
375
496
  <div>
@@ -380,7 +501,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
380
501
  );
381
502
  }
382
503
 
383
- const statusLabel = providerConfig.apiKeySet ? t('statusReady') : t('statusSetup');
504
+ const statusLabel = resolvedProviderConfig.apiKeySet ? t('statusReady') : t('statusSetup');
384
505
 
385
506
  return (
386
507
  <div className={CONFIG_DETAIL_CARD_CLASS}>
@@ -399,7 +520,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
399
520
  <Trash2 className="h-4 w-4" />
400
521
  </button>
401
522
  )}
402
- <StatusDot status={providerConfig.apiKeySet ? 'ready' : 'setup'} label={statusLabel} />
523
+ <StatusDot status={resolvedProviderConfig.apiKeySet ? 'ready' : 'setup'} label={statusLabel} />
403
524
  </div>
404
525
  </div>
405
526
  </div>
@@ -430,7 +551,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
430
551
  <MaskedInput
431
552
  id="apiKey"
432
553
  value={apiKey}
433
- isSet={providerConfig.apiKeySet}
554
+ isSet={resolvedProviderConfig.apiKeySet}
434
555
  onChange={(e) => setApiKey(e.target.value)}
435
556
  placeholder={apiKeyHint?.placeholder ?? t('enterApiKey')}
436
557
  className="rounded-xl"
@@ -438,6 +559,49 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
438
559
  <p className="text-xs text-gray-500">{t('leaveBlankToKeepUnchanged')}</p>
439
560
  </div>
440
561
 
562
+ {providerAuth?.kind === 'device_code' && (
563
+ <div className="space-y-2 rounded-xl border border-primary/20 bg-primary-50/50 p-3">
564
+ <Label className="text-sm font-medium text-gray-900">
565
+ {providerAuth.displayName || t('providerAuthSectionTitle')}
566
+ </Label>
567
+ {providerAuthNote ? (
568
+ <p className="text-xs text-gray-600">{providerAuthNote}</p>
569
+ ) : null}
570
+ <div className="flex flex-wrap items-center gap-2">
571
+ <Button
572
+ type="button"
573
+ variant="outline"
574
+ size="sm"
575
+ onClick={handleStartProviderAuth}
576
+ disabled={startProviderAuth.isPending || Boolean(authSessionId)}
577
+ >
578
+ {startProviderAuth.isPending
579
+ ? t('providerAuthStarting')
580
+ : authSessionId
581
+ ? t('providerAuthAuthorizing')
582
+ : t('providerAuthAuthorizeInBrowser')}
583
+ </Button>
584
+ {providerAuth.supportsCliImport ? (
585
+ <Button
586
+ type="button"
587
+ variant="outline"
588
+ size="sm"
589
+ onClick={handleImportProviderAuthFromCli}
590
+ disabled={importProviderAuthFromCli.isPending}
591
+ >
592
+ {importProviderAuthFromCli.isPending ? t('providerAuthImporting') : t('providerAuthImportFromCli')}
593
+ </Button>
594
+ ) : null}
595
+ {authSessionId ? (
596
+ <span className="text-xs text-gray-500">{t('providerAuthSessionLabel')}: {authSessionId.slice(0, 8)}…</span>
597
+ ) : null}
598
+ </div>
599
+ {authStatusMessage ? (
600
+ <p className="text-xs text-gray-600">{authStatusMessage}</p>
601
+ ) : null}
602
+ </div>
603
+ )}
604
+
441
605
  <div className="space-y-2">
442
606
  <Label htmlFor="apiBase" className="text-sm font-medium text-gray-900">
443
607
  {apiBaseHint?.label ?? t('apiBase')}
@@ -450,7 +614,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
450
614
  placeholder={defaultApiBase || apiBaseHint?.placeholder || 'https://api.example.com'}
451
615
  className="rounded-xl"
452
616
  />
453
- <p className="text-xs text-gray-500">{t('providerApiBaseHelpShort')}</p>
617
+ <p className="text-xs text-gray-500">{apiBaseHelpText}</p>
454
618
  </div>
455
619
 
456
620
  <div className="space-y-2">
@@ -5,7 +5,6 @@ import { ProviderForm } from './ProviderForm';
5
5
  import { cn } from '@/lib/utils';
6
6
  import { Tabs } from '@/components/ui/tabs-custom';
7
7
  import { LogoBadge } from '@/components/common/LogoBadge';
8
- import { getProviderLogo } from '@/lib/logos';
9
8
  import { hintForPath } from '@/lib/config-hints';
10
9
  import { StatusDot } from '@/components/ui/status-dot';
11
10
  import { t } from '@/lib/i18n';
@@ -156,7 +155,7 @@ export function ProvidersList() {
156
155
  <div className="flex min-w-0 items-center gap-3">
157
156
  <LogoBadge
158
157
  name={provider.name}
159
- src={getProviderLogo(provider.name)}
158
+ src={provider.logo ? `/logos/${provider.logo}` : null}
160
159
  className={cn(
161
160
  'h-10 w-10 rounded-lg border',
162
161
  isReady ? 'border-primary/30 bg-white' : 'border-gray-200/70 bg-white'
@@ -1,13 +1,19 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import type { SessionEntryView, SessionMessageView } from '@/api/types';
3
3
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
4
- import { useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
4
+ import { useChatRuns, useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import { Input } from '@/components/ui/input';
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
+ import { SessionRunBadge } from '@/components/common/SessionRunBadge';
8
9
  import { cn } from '@/lib/utils';
9
10
  import { formatDateShort, formatDateTime, t } from '@/lib/i18n';
10
11
  import { extractMessageText } from '@/lib/chat-message';
12
+ import {
13
+ buildActiveRunBySessionKey,
14
+ buildSessionRunStatusByKey,
15
+ type SessionRunStatus
16
+ } from '@/lib/session-run-status';
11
17
  import { PageLayout, PageHeader } from '@/components/layout/page-layout';
12
18
  import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
13
19
 
@@ -40,11 +46,12 @@ function displayChannelName(channel: string): string {
40
46
  type SessionListItemProps = {
41
47
  session: SessionEntryView;
42
48
  channel: string;
49
+ runStatus?: SessionRunStatus;
43
50
  isSelected: boolean;
44
51
  onSelect: () => void;
45
52
  };
46
53
 
47
- function SessionListItem({ session, channel, isSelected, onSelect }: SessionListItemProps) {
54
+ function SessionListItem({ session, channel, runStatus, isSelected, onSelect }: SessionListItemProps) {
48
55
  const channelDisplay = displayChannelName(channel);
49
56
  const displayName = session.label || session.key.split(':').pop() || session.key;
50
57
 
@@ -69,6 +76,9 @@ function SessionListItem({ session, channel, isSelected, onSelect }: SessionList
69
76
 
70
77
  <div className={cn("flex items-center text-xs justify-between mt-2 font-medium", isSelected ? "text-brand-600/80" : "text-gray-400")}>
71
78
  <div className="flex items-center gap-1.5">
79
+ <span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
80
+ {runStatus ? <SessionRunBadge status={runStatus} /> : null}
81
+ </span>
72
82
  <Clock className="w-3.5 h-3.5 opacity-70" />
73
83
  <span className="truncate max-w-[100px]">{formatDateShort(session.updatedAt)}</span>
74
84
  </div>
@@ -136,6 +146,7 @@ export function SessionsConfig() {
136
146
 
137
147
  const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
138
148
  const sessionsQuery = useSessions(sessionsParams);
149
+ const activeRunsQuery = useChatRuns({ states: ['queued', 'running'], limit: 200 });
139
150
  const historyQuery = useSessionHistory(selectedKey, 200);
140
151
 
141
152
  const updateSession = useUpdateSession();
@@ -143,6 +154,14 @@ export function SessionsConfig() {
143
154
  const { confirm, ConfirmDialog } = useConfirmDialog();
144
155
 
145
156
  const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
157
+ const activeRunBySessionKey = useMemo(
158
+ () => buildActiveRunBySessionKey(activeRunsQuery.data?.runs ?? []),
159
+ [activeRunsQuery.data?.runs]
160
+ );
161
+ const sessionRunStatusByKey = useMemo(
162
+ () => buildSessionRunStatusByKey(activeRunBySessionKey),
163
+ [activeRunBySessionKey]
164
+ );
146
165
  const selectedSession = useMemo(() => sessions.find(s => s.key === selectedKey), [sessions, selectedKey]);
147
166
 
148
167
  const channels = useMemo(() => {
@@ -273,6 +292,7 @@ export function SessionsConfig() {
273
292
  key={session.key}
274
293
  session={session}
275
294
  channel={resolveChannelFromSessionKey(session.key)}
295
+ runStatus={sessionRunStatusByKey.get(session.key)}
276
296
  isSelected={selectedKey === session.key}
277
297
  onSelect={() => setSelectedKey(session.key)}
278
298
  />
@@ -4,6 +4,7 @@ import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
4
  import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound, Settings, ArrowLeft } from 'lucide-react';
5
5
  import { NavLink } from 'react-router-dom';
6
6
  import { useDocBrowser } from '@/components/doc-browser';
7
+ import { BrandHeader } from '@/components/common/BrandHeader';
7
8
  import { useI18n } from '@/components/providers/I18nProvider';
8
9
  import { useTheme } from '@/components/providers/ThemeProvider';
9
10
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
@@ -114,12 +115,7 @@ export function Sidebar({ mode }: SidebarProps) {
114
115
  </div>
115
116
  ) : (
116
117
  <div className="px-2 mb-8">
117
- <div className="flex items-center gap-2.5 cursor-pointer">
118
- <div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
119
- <img src="/logo.svg" alt="NextClaw" className="h-full w-full object-contain" />
120
- </div>
121
- <span className="text-[15px] font-semibold text-gray-800 tracking-[-0.01em]">NextClaw</span>
122
- </div>
118
+ <BrandHeader className="flex items-center gap-2.5 cursor-pointer" />
123
119
  </div>
124
120
  )}
125
121
 
@@ -1,5 +1,6 @@
1
1
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
2
  import {
3
+ fetchAppMeta,
3
4
  fetchConfig,
4
5
  fetchConfigMeta,
5
6
  fetchConfigSchema,
@@ -8,6 +9,9 @@ import {
8
9
  deleteProvider,
9
10
  updateProvider,
10
11
  testProviderConnection,
12
+ startProviderAuth,
13
+ pollProviderAuth,
14
+ importProviderAuthFromCli,
11
15
  updateChannel,
12
16
  updateRuntime,
13
17
  updateSecrets,
@@ -37,6 +41,14 @@ export function useConfig() {
37
41
  });
38
42
  }
39
43
 
44
+ export function useAppMeta() {
45
+ return useQuery({
46
+ queryKey: ['app-meta'],
47
+ queryFn: fetchAppMeta,
48
+ staleTime: Infinity
49
+ });
50
+ }
51
+
40
52
  export function useConfigMeta() {
41
53
  return useQuery({
42
54
  queryKey: ['config-meta'],
@@ -125,6 +137,25 @@ export function useTestProviderConnection() {
125
137
  });
126
138
  }
127
139
 
140
+ export function useStartProviderAuth() {
141
+ return useMutation({
142
+ mutationFn: ({ provider }: { provider: string }) => startProviderAuth(provider)
143
+ });
144
+ }
145
+
146
+ export function usePollProviderAuth() {
147
+ return useMutation({
148
+ mutationFn: ({ provider, data }: { provider: string; data: unknown }) =>
149
+ pollProviderAuth(provider, data as Parameters<typeof pollProviderAuth>[1])
150
+ });
151
+ }
152
+
153
+ export function useImportProviderAuthFromCli() {
154
+ return useMutation({
155
+ mutationFn: ({ provider }: { provider: string }) => importProviderAuthFromCli(provider)
156
+ });
157
+ }
158
+
128
159
  export function useUpdateChannel() {
129
160
  const queryClient = useQueryClient();
130
161