@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/api/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { api, API_BASE } from './client';
2
2
  import type {
3
+ AppMetaView,
3
4
  ConfigView,
4
5
  ConfigMetaView,
5
6
  ConfigSchemaResponse,
@@ -8,6 +9,10 @@ import type {
8
9
  ProviderConfigUpdate,
9
10
  ProviderConnectionTestRequest,
10
11
  ProviderConnectionTestResult,
12
+ ProviderAuthStartResult,
13
+ ProviderAuthPollRequest,
14
+ ProviderAuthPollResult,
15
+ ProviderAuthImportResult,
11
16
  ProviderCreateRequest,
12
17
  ProviderCreateResult,
13
18
  ProviderDeleteResult,
@@ -36,6 +41,15 @@ import type {
36
41
  ChatTurnStreamSessionEvent
37
42
  } from './types';
38
43
 
44
+ // GET /api/app/meta
45
+ export async function fetchAppMeta(): Promise<AppMetaView> {
46
+ const response = await api.get<AppMetaView>('/api/app/meta');
47
+ if (!response.ok) {
48
+ throw new Error(response.error.message);
49
+ }
50
+ return response.data;
51
+ }
52
+
39
53
  // GET /api/config
40
54
  export async function fetchConfig(): Promise<ConfigView> {
41
55
  const response = await api.get<ConfigView>('/api/config');
@@ -129,6 +143,45 @@ export async function testProviderConnection(
129
143
  return response.data;
130
144
  }
131
145
 
146
+ // POST /api/config/providers/:provider/auth/start
147
+ export async function startProviderAuth(provider: string): Promise<ProviderAuthStartResult> {
148
+ const response = await api.post<ProviderAuthStartResult>(
149
+ `/api/config/providers/${provider}/auth/start`,
150
+ {}
151
+ );
152
+ if (!response.ok) {
153
+ throw new Error(response.error.message);
154
+ }
155
+ return response.data;
156
+ }
157
+
158
+ // POST /api/config/providers/:provider/auth/poll
159
+ export async function pollProviderAuth(
160
+ provider: string,
161
+ data: ProviderAuthPollRequest
162
+ ): Promise<ProviderAuthPollResult> {
163
+ const response = await api.post<ProviderAuthPollResult>(
164
+ `/api/config/providers/${provider}/auth/poll`,
165
+ data
166
+ );
167
+ if (!response.ok) {
168
+ throw new Error(response.error.message);
169
+ }
170
+ return response.data;
171
+ }
172
+
173
+ // POST /api/config/providers/:provider/auth/import-cli
174
+ export async function importProviderAuthFromCli(provider: string): Promise<ProviderAuthImportResult> {
175
+ const response = await api.post<ProviderAuthImportResult>(
176
+ `/api/config/providers/${provider}/auth/import-cli`,
177
+ {}
178
+ );
179
+ if (!response.ok) {
180
+ throw new Error(response.error.message);
181
+ }
182
+ return response.data;
183
+ }
184
+
132
185
  // PUT /api/config/channels/:channel
133
186
  export async function updateChannel(
134
187
  channel: string,
package/src/api/types.ts CHANGED
@@ -9,6 +9,11 @@ export type ApiResponse<T> =
9
9
  | { ok: true; data: T }
10
10
  | { ok: false; error: ApiError };
11
11
 
12
+ export type AppMetaView = {
13
+ name: string;
14
+ productVersion: string;
15
+ };
16
+
12
17
  export type ProviderConfigView = {
13
18
  displayName?: string;
14
19
  apiKeySet: boolean;
@@ -69,6 +74,35 @@ export type ProviderConnectionTestResult = {
69
74
  hint?: string;
70
75
  };
71
76
 
77
+ export type ProviderAuthStartResult = {
78
+ provider: string;
79
+ kind: "device_code";
80
+ sessionId: string;
81
+ verificationUri: string;
82
+ userCode: string;
83
+ expiresAt: string;
84
+ intervalMs: number;
85
+ note?: string;
86
+ };
87
+
88
+ export type ProviderAuthPollRequest = {
89
+ sessionId: string;
90
+ };
91
+
92
+ export type ProviderAuthPollResult = {
93
+ provider: string;
94
+ status: "pending" | "authorized" | "denied" | "expired" | "error";
95
+ message?: string;
96
+ nextPollMs?: number;
97
+ };
98
+
99
+ export type ProviderAuthImportResult = {
100
+ provider: string;
101
+ status: "imported";
102
+ source: "cli";
103
+ expiresAt?: string;
104
+ };
105
+
72
106
  export type AgentProfileView = {
73
107
  id: string;
74
108
  default?: boolean;
@@ -394,6 +428,20 @@ export type ProviderSpecView = {
394
428
  isGateway?: boolean;
395
429
  isLocal?: boolean;
396
430
  defaultApiBase?: string;
431
+ logo?: string;
432
+ apiBaseHelp?: {
433
+ en?: string;
434
+ zh?: string;
435
+ };
436
+ auth?: {
437
+ kind: "device_code";
438
+ displayName?: string;
439
+ note?: {
440
+ en?: string;
441
+ zh?: string;
442
+ };
443
+ supportsCliImport?: boolean;
444
+ };
397
445
  defaultModels?: string[];
398
446
  supportsWireApi?: boolean;
399
447
  wireApiOptions?: Array<"auto" | "chat" | "responses">;
@@ -1,4 +1,6 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
2
  import { Button } from '@/components/ui/button';
3
+ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
2
4
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
3
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
4
6
  import { SkillsPicker } from '@/components/chat/SkillsPicker';
@@ -6,6 +8,8 @@ import type { MarketplaceInstalledRecord } from '@/api/types';
6
8
  import { t } from '@/lib/i18n';
7
9
  import { Paperclip, Send, Sparkles, Square, X } from 'lucide-react';
8
10
 
11
+ const SLASH_PANEL_MAX_WIDTH = 920;
12
+
9
13
  export type ChatModelOption = {
10
14
  value: string;
11
15
  modelLabel: string;
@@ -33,6 +37,91 @@ type ChatInputBarProps = {
33
37
  onSelectedSkillsChange: (next: string[]) => void;
34
38
  };
35
39
 
40
+ type SlashPanelItem = {
41
+ kind: 'skill';
42
+ key: string;
43
+ title: string;
44
+ subtitle: string;
45
+ description: string;
46
+ detailLines: string[];
47
+ skillSpec?: string;
48
+ };
49
+
50
+ type RankedSkill = {
51
+ record: MarketplaceInstalledRecord;
52
+ score: number;
53
+ order: number;
54
+ };
55
+
56
+ function resolveSlashQuery(draft: string): string | null {
57
+ const match = /^\/([^\s]*)$/.exec(draft);
58
+ if (!match) {
59
+ return null;
60
+ }
61
+ return (match[1] ?? '').trim().toLowerCase();
62
+ }
63
+
64
+ function normalizeSearchText(value: string | null | undefined): string {
65
+ return (value ?? '').trim().toLowerCase();
66
+ }
67
+
68
+ function isSubsequenceMatch(query: string, target: string): boolean {
69
+ if (!query || !target) {
70
+ return false;
71
+ }
72
+ let pointer = 0;
73
+ for (const char of target) {
74
+ if (char === query[pointer]) {
75
+ pointer += 1;
76
+ if (pointer >= query.length) {
77
+ return true;
78
+ }
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+
84
+ function scoreSkillRecord(record: MarketplaceInstalledRecord, query: string): number {
85
+ const normalizedQuery = normalizeSearchText(query);
86
+ if (!normalizedQuery) {
87
+ return 1;
88
+ }
89
+
90
+ const spec = normalizeSearchText(record.spec);
91
+ const label = normalizeSearchText(record.label || record.spec);
92
+ const description = normalizeSearchText(`${record.descriptionZh ?? ''} ${record.description ?? ''}`);
93
+ const labelTokens = label.split(/[\s/_-]+/).filter(Boolean);
94
+
95
+ if (spec === normalizedQuery) {
96
+ return 1200;
97
+ }
98
+ if (label === normalizedQuery) {
99
+ return 1150;
100
+ }
101
+ if (spec.startsWith(normalizedQuery)) {
102
+ return 1000;
103
+ }
104
+ if (label.startsWith(normalizedQuery)) {
105
+ return 950;
106
+ }
107
+ if (labelTokens.some((token) => token.startsWith(normalizedQuery))) {
108
+ return 900;
109
+ }
110
+ if (spec.includes(normalizedQuery)) {
111
+ return 800;
112
+ }
113
+ if (label.includes(normalizedQuery)) {
114
+ return 760;
115
+ }
116
+ if (description.includes(normalizedQuery)) {
117
+ return 500;
118
+ }
119
+ if (isSubsequenceMatch(normalizedQuery, label) || isSubsequenceMatch(normalizedQuery, spec)) {
120
+ return 300;
121
+ }
122
+ return 0;
123
+ }
124
+
36
125
  export function ChatInputBar({
37
126
  isProviderStateResolved,
38
127
  draft,
@@ -53,6 +142,11 @@ export function ChatInputBar({
53
142
  selectedSkills,
54
143
  onSelectedSkillsChange
55
144
  }: ChatInputBarProps) {
145
+ const [activeSlashIndex, setActiveSlashIndex] = useState(0);
146
+ const [dismissedSlashPanel, setDismissedSlashPanel] = useState(false);
147
+ const [slashPanelWidth, setSlashPanelWidth] = useState<number | null>(null);
148
+ const slashAnchorRef = useRef<HTMLDivElement | null>(null);
149
+ const slashListRef = useRef<HTMLDivElement | null>(null);
56
150
  const hasModelOptions = modelOptions.length > 0;
57
151
  const isModelOptionsLoading = !isProviderStateResolved && !hasModelOptions;
58
152
  const isModelOptionsEmpty = isProviderStateResolved && !hasModelOptions;
@@ -69,36 +163,259 @@ export function ChatInputBar({
69
163
  label: matched?.label || spec
70
164
  };
71
165
  });
166
+ const slashQuery = useMemo(() => resolveSlashQuery(draft), [draft]);
167
+ const startsWithSlash = draft.startsWith('/');
168
+ const normalizedSlashQuery = slashQuery ?? '';
169
+ const skillSortCollator = useMemo(
170
+ () => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
171
+ []
172
+ );
173
+ const skillSlashItems = useMemo<SlashPanelItem[]>(() => {
174
+ const rankedRecords: RankedSkill[] = skillRecords
175
+ .map((record, order) => ({
176
+ record,
177
+ score: scoreSkillRecord(record, normalizedSlashQuery),
178
+ order
179
+ }))
180
+ .filter((entry) => entry.score > 0)
181
+ .sort((left, right) => {
182
+ if (right.score !== left.score) {
183
+ return right.score - left.score;
184
+ }
185
+ const leftLabel = (left.record.label || left.record.spec).trim();
186
+ const rightLabel = (right.record.label || right.record.spec).trim();
187
+ const labelCompare = skillSortCollator.compare(leftLabel, rightLabel);
188
+ if (labelCompare !== 0) {
189
+ return labelCompare;
190
+ }
191
+ return left.order - right.order;
192
+ });
193
+
194
+ return rankedRecords
195
+ .map((entry) => entry.record)
196
+ .map((record) => ({
197
+ kind: 'skill',
198
+ key: `skill:${record.spec}`,
199
+ title: record.label || record.spec,
200
+ subtitle: t('chatSlashTypeSkill'),
201
+ description: (record.descriptionZh ?? record.description ?? '').trim() || t('chatSkillsPickerNoDescription'),
202
+ detailLines: [`${t('chatSlashSkillSpec')}: ${record.spec}`],
203
+ skillSpec: record.spec
204
+ }));
205
+ }, [normalizedSlashQuery, skillRecords, skillSortCollator]);
206
+ const slashItems = useMemo(() => [...skillSlashItems], [skillSlashItems]);
207
+ const isSlashPanelOpen = slashQuery !== null && !dismissedSlashPanel;
208
+ const activeSlashItem = slashItems[activeSlashIndex] ?? null;
209
+ const isSlashPanelLoading = isSkillsLoading;
210
+ const resolvedSlashPanelWidth = slashPanelWidth ? Math.min(slashPanelWidth, SLASH_PANEL_MAX_WIDTH) : undefined;
211
+
212
+ useEffect(() => {
213
+ const anchor = slashAnchorRef.current;
214
+ if (!anchor || typeof ResizeObserver === 'undefined') {
215
+ return;
216
+ }
217
+ const update = () => {
218
+ setSlashPanelWidth(anchor.getBoundingClientRect().width);
219
+ };
220
+ update();
221
+ const observer = new ResizeObserver(() => update());
222
+ observer.observe(anchor);
223
+ return () => {
224
+ observer.disconnect();
225
+ };
226
+ }, []);
227
+
228
+ useEffect(() => {
229
+ if (!isSlashPanelOpen) {
230
+ setActiveSlashIndex(0);
231
+ return;
232
+ }
233
+ if (slashItems.length === 0) {
234
+ setActiveSlashIndex(0);
235
+ return;
236
+ }
237
+ setActiveSlashIndex((current) => {
238
+ if (current < 0) {
239
+ return 0;
240
+ }
241
+ if (current >= slashItems.length) {
242
+ return slashItems.length - 1;
243
+ }
244
+ return current;
245
+ });
246
+ }, [isSlashPanelOpen, slashItems.length]);
247
+
248
+ useEffect(() => {
249
+ if (!startsWithSlash && dismissedSlashPanel) {
250
+ setDismissedSlashPanel(false);
251
+ }
252
+ }, [dismissedSlashPanel, startsWithSlash]);
253
+
254
+ useEffect(() => {
255
+ if (!isSlashPanelOpen || isSlashPanelLoading || slashItems.length === 0) {
256
+ return;
257
+ }
258
+ const container = slashListRef.current;
259
+ if (!container) {
260
+ return;
261
+ }
262
+ const active = container.querySelector<HTMLElement>(`[data-slash-index="${activeSlashIndex}"]`);
263
+ active?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
264
+ }, [activeSlashIndex, isSlashPanelLoading, isSlashPanelOpen, slashItems.length]);
265
+
266
+ const handleSelectSlashItem = useCallback((item: SlashPanelItem) => {
267
+ if (item.kind === 'skill' && item.skillSpec) {
268
+ if (!selectedSkills.includes(item.skillSpec)) {
269
+ onSelectedSkillsChange([...selectedSkills, item.skillSpec]);
270
+ }
271
+ onDraftChange('');
272
+ setDismissedSlashPanel(false);
273
+ }
274
+ }, [onDraftChange, onSelectedSkillsChange, selectedSkills]);
275
+
276
+ const handleSlashPanelOpenChange = useCallback((open: boolean) => {
277
+ if (!open) {
278
+ setDismissedSlashPanel(true);
279
+ }
280
+ }, []);
72
281
 
73
282
  return (
74
283
  <div className="border-t border-gray-200/80 bg-white p-4">
75
284
  <div className="mx-auto w-full max-w-[min(1120px,100%)]">
76
285
  <div className="rounded-2xl border border-gray-200 bg-white shadow-card overflow-hidden">
77
- {/* Textarea */}
78
- <textarea
79
- value={draft}
80
- onChange={(e) => onDraftChange(e.target.value)}
81
- disabled={inputDisabled}
82
- onKeyDown={(e) => {
83
- if (e.key === 'Escape' && isSending && canStopGeneration) {
84
- e.preventDefault();
85
- void onStop();
86
- return;
286
+ <div className="relative">
287
+ {/* Textarea */}
288
+ <textarea
289
+ value={draft}
290
+ onChange={(e) => onDraftChange(e.target.value)}
291
+ disabled={inputDisabled}
292
+ onKeyDown={(e) => {
293
+ if (isSlashPanelOpen && !e.nativeEvent.isComposing && (e.key === ' ' || e.code === 'Space')) {
294
+ setDismissedSlashPanel(true);
295
+ }
296
+ if (isSlashPanelOpen && slashItems.length > 0) {
297
+ if (e.key === 'ArrowDown') {
298
+ e.preventDefault();
299
+ setActiveSlashIndex((current) => (current + 1) % slashItems.length);
300
+ return;
301
+ }
302
+ if (e.key === 'ArrowUp') {
303
+ e.preventDefault();
304
+ setActiveSlashIndex((current) => (current - 1 + slashItems.length) % slashItems.length);
305
+ return;
306
+ }
307
+ if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') {
308
+ e.preventDefault();
309
+ const selected = slashItems[activeSlashIndex];
310
+ if (selected) {
311
+ handleSelectSlashItem(selected);
312
+ }
313
+ return;
314
+ }
315
+ }
316
+ if (e.key === 'Escape') {
317
+ if (isSlashPanelOpen) {
318
+ e.preventDefault();
319
+ setDismissedSlashPanel(true);
320
+ return;
321
+ }
322
+ if (isSending && canStopGeneration) {
323
+ e.preventDefault();
324
+ void onStop();
325
+ return;
326
+ }
327
+ }
328
+ if (e.key === 'Enter' && !e.shiftKey) {
329
+ e.preventDefault();
330
+ void onSend();
331
+ }
332
+ }}
333
+ placeholder={
334
+ isModelOptionsLoading
335
+ ? ''
336
+ : hasModelOptions
337
+ ? t('chatInputPlaceholder')
338
+ : t('chatModelNoOptions')
87
339
  }
88
- if (e.key === 'Enter' && !e.shiftKey) {
89
- e.preventDefault();
90
- void onSend();
91
- }
92
- }}
93
- placeholder={
94
- isModelOptionsLoading
95
- ? ''
96
- : hasModelOptions
97
- ? t('chatInputPlaceholder')
98
- : t('chatModelNoOptions')
99
- }
100
- className="w-full min-h-[68px] max-h-[220px] resize-y bg-transparent outline-none text-sm px-4 py-3 text-gray-800 placeholder:text-gray-400"
101
- />
340
+ className="w-full min-h-[68px] max-h-[220px] resize-y bg-transparent outline-none text-sm px-4 py-3 text-gray-800 placeholder:text-gray-400"
341
+ />
342
+ <Popover open={isSlashPanelOpen} onOpenChange={handleSlashPanelOpenChange}>
343
+ <PopoverAnchor asChild>
344
+ <div ref={slashAnchorRef} className="pointer-events-none absolute left-3 right-3 bottom-full h-0" />
345
+ </PopoverAnchor>
346
+ <PopoverContent
347
+ side="top"
348
+ align="start"
349
+ sideOffset={10}
350
+ className="z-[70] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-2xl border border-gray-200 bg-white/95 p-0 shadow-2xl backdrop-blur-md"
351
+ onOpenAutoFocus={(event) => event.preventDefault()}
352
+ style={resolvedSlashPanelWidth ? { width: `${resolvedSlashPanelWidth}px` } : undefined}
353
+ >
354
+ <div className="grid min-h-[240px] grid-cols-[minmax(260px,340px)_minmax(0,1fr)]">
355
+ <div ref={slashListRef} className="max-h-[320px] overflow-y-auto border-r border-gray-200 p-3 custom-scrollbar">
356
+ {isSlashPanelLoading ? (
357
+ <div className="p-2 text-xs text-gray-500">{t('chatSlashLoading')}</div>
358
+ ) : (
359
+ <>
360
+ <div className="mb-2 px-2 text-[11px] font-semibold uppercase tracking-wide text-gray-500">
361
+ {t('chatSlashSectionSkills')}
362
+ </div>
363
+ {skillSlashItems.length === 0 ? (
364
+ <div className="px-2 text-xs text-gray-400">{t('chatSlashNoResult')}</div>
365
+ ) : (
366
+ <div className="space-y-1">
367
+ {skillSlashItems.map((item, index) => {
368
+ const isActive = index === activeSlashIndex;
369
+ return (
370
+ <button
371
+ key={item.key}
372
+ type="button"
373
+ data-slash-index={index}
374
+ onMouseEnter={() => setActiveSlashIndex(index)}
375
+ onClick={() => handleSelectSlashItem(item)}
376
+ className={`flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition ${
377
+ isActive ? 'bg-gray-100 text-gray-900' : 'text-gray-700 hover:bg-gray-50'
378
+ }`}
379
+ >
380
+ <span className="truncate text-xs font-semibold">{item.title}</span>
381
+ <span className="truncate text-xs text-gray-500">{item.subtitle}</span>
382
+ </button>
383
+ );
384
+ })}
385
+ </div>
386
+ )}
387
+ </>
388
+ )}
389
+ </div>
390
+ <div className="p-4">
391
+ {activeSlashItem ? (
392
+ <div className="space-y-3">
393
+ <div className="flex items-center gap-2">
394
+ <span className="inline-flex rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
395
+ {activeSlashItem.subtitle}
396
+ </span>
397
+ <span className="text-sm font-semibold text-gray-900">{activeSlashItem.title}</span>
398
+ </div>
399
+ <p className="text-xs leading-5 text-gray-600">{activeSlashItem.description}</p>
400
+ <div className="space-y-1">
401
+ {activeSlashItem.detailLines.map((line) => (
402
+ <div key={line} className="rounded-md bg-gray-50 px-2 py-1 text-[11px] text-gray-600">
403
+ {line}
404
+ </div>
405
+ ))}
406
+ </div>
407
+ <div className="pt-1 text-[11px] text-gray-500">
408
+ {t('chatSlashSkillHint')}
409
+ </div>
410
+ </div>
411
+ ) : (
412
+ <div className="text-xs text-gray-500">{t('chatSlashHint')}</div>
413
+ )}
414
+ </div>
415
+ </div>
416
+ </PopoverContent>
417
+ </Popover>
418
+ </div>
102
419
  {isModelOptionsLoading && (
103
420
  <div className="px-4 pb-2">
104
421
  <div className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
@@ -8,7 +8,7 @@ import {
8
8
  useDeleteSession,
9
9
  useSessionHistory,
10
10
  useSessions,
11
- useChatRuns
11
+ useChatRuns,
12
12
  } from '@/hooks/useConfig';
13
13
  import { useMarketplaceInstalled } from '@/hooks/useMarketplace';
14
14
  import { useConfirmDialog } from '@/hooks/useConfirmDialog';
@@ -20,6 +20,7 @@ import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
20
20
  import { useChatStreamController } from '@/components/chat/useChatStreamController';
21
21
  import { buildFallbackEventsFromMessages } from '@/lib/chat-message';
22
22
  import { buildProviderModelCatalog, composeProviderModel } from '@/lib/provider-models';
23
+ import { buildActiveRunBySessionKey, buildSessionRunStatusByKey } from '@/lib/session-run-status';
23
24
  import { t } from '@/lib/i18n';
24
25
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
25
26
 
@@ -208,11 +209,19 @@ function ChatPageLayout({ view, sidebarProps, conversationProps, confirmDialog }
208
209
  <ChatConversationPanel {...conversationProps} />
209
210
  ) : (
210
211
  <section className="flex-1 min-h-0 overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
211
- <div className="h-full overflow-auto custom-scrollbar">
212
- <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
213
- {view === 'cron' ? <CronConfig /> : <MarketplacePage forcedType="skills" />}
212
+ {view === 'cron' ? (
213
+ <div className="h-full overflow-auto custom-scrollbar">
214
+ <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
215
+ <CronConfig />
216
+ </div>
214
217
  </div>
215
- </div>
218
+ ) : (
219
+ <div className="h-full overflow-hidden">
220
+ <div className="mx-auto flex h-full min-h-0 w-full max-w-[min(1120px,100%)] flex-col px-6 py-5">
221
+ <MarketplacePage forcedType="skills" />
222
+ </div>
223
+ </div>
224
+ )}
216
225
  </section>
217
226
  )}
218
227
 
@@ -351,22 +360,28 @@ export function ChatPage({ view }: ChatPageProps) {
351
360
  refetchHistory: historyQuery.refetch
352
361
  });
353
362
 
354
- const activeRunsQuery = useChatRuns(
355
- selectedSessionKey
363
+ const sessionStatusRunsQuery = useChatRuns(
364
+ view === 'chat'
356
365
  ? {
357
- sessionKey: selectedSessionKey,
358
366
  states: ['queued', 'running'],
359
- limit: 5
367
+ limit: 200
360
368
  }
361
369
  : undefined
362
370
  );
371
+ const activeRunBySessionKey = useMemo(
372
+ () => buildActiveRunBySessionKey(sessionStatusRunsQuery.data?.runs ?? []),
373
+ [sessionStatusRunsQuery.data?.runs]
374
+ );
375
+ const sessionRunStatusByKey = useMemo(
376
+ () => buildSessionRunStatusByKey(activeRunBySessionKey),
377
+ [activeRunBySessionKey]
378
+ );
363
379
  const activeRun = useMemo(() => {
364
- const candidates = activeRunsQuery.data?.runs ?? [];
365
380
  if (!selectedSessionKey) {
366
381
  return null;
367
382
  }
368
- return candidates.find((entry) => entry.sessionKey === selectedSessionKey) ?? null;
369
- }, [activeRunsQuery.data?.runs, selectedSessionKey]);
383
+ return activeRunBySessionKey.get(selectedSessionKey) ?? null;
384
+ }, [activeRunBySessionKey, selectedSessionKey]);
370
385
 
371
386
  useEffect(() => {
372
387
  if (view !== 'chat' || !selectedSessionKey || !activeRun) {
@@ -513,6 +528,7 @@ export function ChatPage({ view }: ChatPageProps) {
513
528
 
514
529
  const sidebarProps: ComponentProps<typeof ChatSidebar> = {
515
530
  sessions,
531
+ sessionRunStatusByKey,
516
532
  selectedSessionKey,
517
533
  onSelectSession: handleSelectSession,
518
534
  onCreateSession: createNewSession,