@nextclaw/ui 0.6.10 → 0.6.11

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 (80) hide show
  1. package/.eslintrc.cjs +10 -0
  2. package/CHANGELOG.md +9 -0
  3. package/dist/assets/{ChannelsList-TyMb5Mgz.js → ChannelsList-C49JQ-Zt.js} +1 -1
  4. package/dist/assets/ChatPage-DIx05c6s.js +36 -0
  5. package/dist/assets/{DocBrowser-CNtrA0ps.js → DocBrowser-CpOosDEI.js} +1 -1
  6. package/dist/assets/{LogoBadge-BLqiOM5D.js → LogoBadge-CL_8ZPXU.js} +1 -1
  7. package/dist/assets/MarketplacePage-BOzko5s9.js +49 -0
  8. package/dist/assets/{ModelConfig-CCsQ8KFq.js → ModelConfig-BZ4ZfaQB.js} +1 -1
  9. package/dist/assets/ProvidersList-fPpJ5gl6.js +1 -0
  10. package/dist/assets/{RuntimeConfig-BO6s-ls-.js → RuntimeConfig-Dt9pLB9P.js} +1 -1
  11. package/dist/assets/{SecretsConfig-mayFdxpM.js → SecretsConfig-C1PU0Yy8.js} +2 -2
  12. package/dist/assets/{SessionsConfig-DAIczdBj.js → SessionsConfig-EskBOofQ.js} +2 -2
  13. package/dist/assets/{card-BP5YnL-G.js → card-C7Gtw2Vs.js} +1 -1
  14. package/dist/assets/index-Cn6_2To7.js +8 -0
  15. package/dist/assets/{index-BUiahmWm.css → index-nEYGCJTC.css} +1 -1
  16. package/dist/assets/{input-B1D2QX0O.js → input-oBvxsnV9.js} +1 -1
  17. package/dist/assets/{label-DW0j-fXA.js → label-C7F8lMpQ.js} +1 -1
  18. package/dist/assets/{page-layout-Ch-H9gD-.js → page-layout-DO8BlScF.js} +1 -1
  19. package/dist/assets/session-run-status-Kg0FwAPn.js +3 -0
  20. package/dist/assets/{switch-_cZHlGKB.js → switch-C6a5GyZB.js} +1 -1
  21. package/dist/assets/{tabs-custom-ARxqYYjG.js → tabs-custom-BatFap5k.js} +1 -1
  22. package/dist/assets/{useConfirmDialog-BaU7nIat.js → useConfirmDialog-zJzVKMdu.js} +2 -2
  23. package/dist/assets/{vendor-C--HHaLf.js → vendor-TlME1INH.js} +84 -84
  24. package/dist/index.html +3 -3
  25. package/package.json +4 -2
  26. package/src/App.tsx +1 -2
  27. package/src/api/config.ts +199 -200
  28. package/src/api/types.ts +36 -24
  29. package/src/components/chat/ChatConversationPanel.tsx +102 -121
  30. package/src/components/chat/ChatPage.tsx +165 -437
  31. package/src/components/chat/ChatSidebar.tsx +30 -36
  32. package/src/components/chat/ChatThread.tsx +73 -131
  33. package/src/components/chat/chat-input/ChatInputBarView.tsx +82 -0
  34. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +71 -0
  35. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +39 -0
  36. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +31 -0
  37. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +112 -0
  38. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +24 -0
  39. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +58 -0
  40. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +56 -0
  41. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +40 -0
  42. package/src/components/chat/chat-input/useChatInputBarController.ts +313 -0
  43. package/src/components/chat/chat-input.types.ts +15 -0
  44. package/src/components/chat/chat-page-data.ts +121 -0
  45. package/src/components/chat/chat-page-runtime.ts +221 -0
  46. package/src/components/chat/chat-session-route.ts +59 -0
  47. package/src/components/chat/chat-stream/nextbot-parsers.ts +52 -0
  48. package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +413 -0
  49. package/src/components/chat/chat-stream/stream-event-adapter.ts +98 -0
  50. package/src/components/chat/chat-stream/transport.ts +159 -0
  51. package/src/components/chat/chat-stream/types.ts +76 -0
  52. package/src/components/chat/managers/chat-input.manager.ts +142 -0
  53. package/src/components/chat/managers/chat-run-status.manager.ts +32 -0
  54. package/src/components/chat/managers/chat-session-list.manager.ts +77 -0
  55. package/src/components/chat/managers/chat-stream-actions.manager.ts +34 -0
  56. package/src/components/chat/managers/chat-thread.manager.ts +86 -0
  57. package/src/components/chat/managers/chat-ui.manager.ts +65 -0
  58. package/src/components/chat/presenter/chat-presenter-context.tsx +25 -0
  59. package/src/components/chat/presenter/chat.presenter.ts +32 -0
  60. package/src/components/chat/stores/chat-input.store.ts +62 -0
  61. package/src/components/chat/stores/chat-run-status.store.ts +30 -0
  62. package/src/components/chat/stores/chat-session-list.store.ts +34 -0
  63. package/src/components/chat/stores/chat-thread.store.ts +52 -0
  64. package/src/components/chat/useChatRuntimeController.ts +134 -0
  65. package/src/components/chat/useChatSessionTypeState.ts +148 -0
  66. package/src/components/common/MaskedInput.tsx +1 -1
  67. package/src/hooks/useConfig.ts +31 -1
  68. package/src/hooks/useObservable.ts +20 -0
  69. package/src/lib/chat-message.ts +2 -202
  70. package/src/lib/chat-runtime-utils.ts +250 -0
  71. package/src/lib/i18n.ts +9 -0
  72. package/tsconfig.json +2 -1
  73. package/vite.config.ts +2 -1
  74. package/dist/assets/ChatPage-CQerYqvy.js +0 -34
  75. package/dist/assets/MarketplacePage-CotZxxNe.js +0 -49
  76. package/dist/assets/ProvidersList-BYYX5K_g.js +0 -1
  77. package/dist/assets/index-D6_5HaDl.js +0 -7
  78. package/dist/assets/session-run-status-BUYsQeWs.js +0 -5
  79. package/src/components/chat/ChatInputBar.tsx +0 -590
  80. package/src/components/chat/useChatStreamController.ts +0 -591
@@ -0,0 +1,34 @@
1
+ import { create } from 'zustand';
2
+ import type { SessionEntryView } from '@/api/types';
3
+
4
+ export type ChatSessionListSnapshot = {
5
+ sessions: SessionEntryView[];
6
+ selectedSessionKey: string | null;
7
+ selectedAgentId: string;
8
+ query: string;
9
+ isLoading: boolean;
10
+ };
11
+
12
+ type ChatSessionListStore = {
13
+ snapshot: ChatSessionListSnapshot;
14
+ setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
15
+ };
16
+
17
+ const initialSnapshot: ChatSessionListSnapshot = {
18
+ sessions: [],
19
+ selectedSessionKey: null,
20
+ selectedAgentId: 'main',
21
+ query: '',
22
+ isLoading: false
23
+ };
24
+
25
+ export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
26
+ snapshot: initialSnapshot,
27
+ setSnapshot: (patch) =>
28
+ set((state) => ({
29
+ snapshot: {
30
+ ...state.snapshot,
31
+ ...patch
32
+ }
33
+ }))
34
+ }));
@@ -0,0 +1,52 @@
1
+ import { create } from 'zustand';
2
+ import type { MutableRefObject } from 'react';
3
+ import type { UiMessage } from '@nextclaw/agent-chat';
4
+ import type { ChatModelOption } from '@/components/chat/chat-input.types';
5
+
6
+ export type ChatThreadSnapshot = {
7
+ isProviderStateResolved: boolean;
8
+ modelOptions: ChatModelOption[];
9
+ sessionTypeUnavailable: boolean;
10
+ sessionTypeUnavailableMessage?: string | null;
11
+ selectedSessionKey: string | null;
12
+ sessionDisplayName?: string;
13
+ canDeleteSession: boolean;
14
+ isDeletePending: boolean;
15
+ threadRef: MutableRefObject<HTMLDivElement | null> | null;
16
+ isHistoryLoading: boolean;
17
+ uiMessages: UiMessage[];
18
+ isSending: boolean;
19
+ isAwaitingAssistantOutput: boolean;
20
+ };
21
+
22
+ type ChatThreadStore = {
23
+ snapshot: ChatThreadSnapshot;
24
+ setSnapshot: (patch: Partial<ChatThreadSnapshot>) => void;
25
+ };
26
+
27
+ const initialSnapshot: ChatThreadSnapshot = {
28
+ isProviderStateResolved: false,
29
+ modelOptions: [],
30
+ sessionTypeUnavailable: false,
31
+ sessionTypeUnavailableMessage: null,
32
+ selectedSessionKey: null,
33
+ sessionDisplayName: undefined,
34
+ canDeleteSession: false,
35
+ isDeletePending: false,
36
+ threadRef: null,
37
+ isHistoryLoading: false,
38
+ uiMessages: [],
39
+ isSending: false,
40
+ isAwaitingAssistantOutput: false
41
+ };
42
+
43
+ export const useChatThreadStore = create<ChatThreadStore>((set) => ({
44
+ snapshot: initialSnapshot,
45
+ setSnapshot: (patch) =>
46
+ set((state) => ({
47
+ snapshot: {
48
+ ...state.snapshot,
49
+ ...patch
50
+ }
51
+ }))
52
+ }));
@@ -0,0 +1,134 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { type AgentChatController, getStopDisabledReason } from '@nextclaw/agent-chat';
3
+ import type { ChatRunView, SessionMessageView } from '@/api/types';
4
+ import type { SendMessageParams, UseChatStreamControllerParams } from '@/components/chat/chat-stream/types';
5
+ import { buildResumeMetadata, buildSendMetadata } from '@/components/chat/chat-stream/nextbot-parsers';
6
+ import { buildUiMessagesFromHistoryMessages, normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
7
+ import { useValueFromBehaviorSubject, useValueFromObservable } from '@/hooks/useObservable';
8
+
9
+ export function useChatRuntimeController(
10
+ params: UseChatStreamControllerParams,
11
+ controller: AgentChatController
12
+ ) {
13
+ const paramsRef = useRef(params);
14
+ useEffect(() => {
15
+ paramsRef.current = params;
16
+ });
17
+
18
+ const activeHistorySessionKeyRef = useRef<string | null>(null);
19
+
20
+ // Bind callbacks to controller
21
+ useEffect(() => {
22
+ controller.setCallbacks({
23
+ onRunSettled: async ({ sourceSessionId, resultSessionId }) => {
24
+ const bindings = paramsRef.current;
25
+ await bindings.refetchSessions();
26
+ const activeSessionKey = bindings.selectedSessionKeyRef.current;
27
+ if (!activeSessionKey || activeSessionKey === sourceSessionId || (resultSessionId && activeSessionKey === resultSessionId)) {
28
+ await bindings.refetchHistory();
29
+ }
30
+ },
31
+ onRunError: ({ sourceMessage, restoreDraft }) => {
32
+ if (restoreDraft) {
33
+ paramsRef.current.setDraft((prev) => (prev.trim().length === 0 && sourceMessage ? sourceMessage : prev));
34
+ }
35
+ },
36
+ onSessionChanged: (sessionId) => {
37
+ paramsRef.current.setSelectedSessionKey((prev) => (prev === sessionId ? prev : sessionId));
38
+ }
39
+ });
40
+ }, [controller]);
41
+
42
+ // Reactive state from controller observables
43
+ const uiMessages = useValueFromObservable(controller.messages$, controller.getMessages());
44
+ const isSending = useValueFromBehaviorSubject(controller.isAgentResponding$);
45
+ const isAwaitingAssistantOutput = useValueFromBehaviorSubject(controller.isAwaitingResponse$);
46
+ const activeRun = useValueFromBehaviorSubject(controller.activeRun$);
47
+ const lastSendError = useValueFromBehaviorSubject(controller.lastError$);
48
+
49
+ // Derived state
50
+ const activeBackendRunId = activeRun?.remoteRunId ?? null;
51
+ const stopDisabledReason = getStopDisabledReason(activeRun);
52
+ const canStopCurrentRun = Boolean(
53
+ activeRun && (stopDisabledReason === null || (activeRun.remoteStopCapable && !activeBackendRunId))
54
+ );
55
+
56
+ const sendMessage = useCallback(async (payload: SendMessageParams) => {
57
+ const requestedSkills = normalizeRequestedSkills(payload.requestedSkills);
58
+ const metadata = buildSendMetadata(payload, requestedSkills);
59
+ await controller.send({
60
+ message: payload.message,
61
+ sessionId: payload.sessionKey,
62
+ agentId: payload.agentId,
63
+ metadata,
64
+ restoreDraftOnError: payload.restoreDraftOnError,
65
+ stopCapable: payload.stopSupported,
66
+ stopReason: payload.stopReason
67
+ });
68
+ }, [controller]);
69
+
70
+ const resumeRun = useCallback(async (run: ChatRunView) => {
71
+ const backendRunId = run.runId?.trim();
72
+ const sessionKey = run.sessionKey?.trim();
73
+ if (!backendRunId || !sessionKey) {
74
+ return;
75
+ }
76
+ const metadata = buildResumeMetadata(run);
77
+ await controller.resume({
78
+ remoteRunId: backendRunId,
79
+ sessionId: sessionKey,
80
+ agentId: run.agentId,
81
+ metadata,
82
+ stopCapable: run.stopSupported,
83
+ stopReason: run.stopReason
84
+ });
85
+ }, [controller]);
86
+
87
+ const stopCurrentRun = useCallback(async () => {
88
+ await controller.stop();
89
+ }, [controller]);
90
+
91
+ const resetStreamState = useCallback(() => {
92
+ activeHistorySessionKeyRef.current = null;
93
+ controller.reset();
94
+ }, [controller]);
95
+
96
+ const applyHistoryMessages = useCallback((messages: SessionMessageView[], options?: { isLoading?: boolean }) => {
97
+ const isRunActive = Boolean(controller.activeRun$.getValue() || controller.isAgentResponding$.getValue());
98
+ if (isRunActive) {
99
+ return;
100
+ }
101
+ const selectedSessionKey = paramsRef.current.selectedSessionKeyRef.current;
102
+ if (selectedSessionKey !== activeHistorySessionKeyRef.current) {
103
+ activeHistorySessionKeyRef.current = selectedSessionKey;
104
+ if (controller.getMessages().length > 0) {
105
+ controller.setMessages([]);
106
+ }
107
+ }
108
+ if (!selectedSessionKey) {
109
+ if (controller.getMessages().length > 0) {
110
+ controller.setMessages([]);
111
+ }
112
+ return;
113
+ }
114
+ if (options?.isLoading && messages.length === 0) {
115
+ return;
116
+ }
117
+ controller.setMessages(buildUiMessagesFromHistoryMessages(messages));
118
+ }, [controller]);
119
+
120
+ return {
121
+ uiMessages,
122
+ isSending,
123
+ isAwaitingAssistantOutput,
124
+ canStopCurrentRun,
125
+ stopDisabledReason,
126
+ lastSendError,
127
+ activeBackendRunId,
128
+ sendMessage,
129
+ stopCurrentRun,
130
+ resumeRun,
131
+ resetStreamState,
132
+ applyHistoryMessages
133
+ };
134
+ }
@@ -0,0 +1,148 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import type { Dispatch, SetStateAction } from 'react';
3
+ import type { SessionEntryView } from '@/api/types';
4
+ import { t } from '@/lib/i18n';
5
+
6
+ export const DEFAULT_SESSION_TYPE = 'native';
7
+
8
+ export type ChatSessionTypeOption = {
9
+ value: string;
10
+ label: string;
11
+ };
12
+
13
+ type UseChatSessionTypeStateParams = {
14
+ selectedSession: SessionEntryView | null;
15
+ selectedSessionKey: string | null;
16
+ pendingSessionType: string;
17
+ setPendingSessionType: Dispatch<SetStateAction<string>>;
18
+ sessionTypesData?: {
19
+ defaultType?: string;
20
+ options?: Array<{ value: string; label: string }>;
21
+ } | null;
22
+ };
23
+
24
+ export function normalizeSessionType(value: unknown): string {
25
+ if (typeof value !== 'string') {
26
+ return DEFAULT_SESSION_TYPE;
27
+ }
28
+ const normalized = value.trim().toLowerCase();
29
+ return normalized || DEFAULT_SESSION_TYPE;
30
+ }
31
+
32
+ export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: string): string {
33
+ if (sessionType === 'native') {
34
+ return t('chatSessionTypeNative');
35
+ }
36
+ if (sessionType === 'codex-sdk') {
37
+ return t('chatSessionTypeCodex');
38
+ }
39
+ if (sessionType === 'claude-agent-sdk') {
40
+ return t('chatSessionTypeClaude');
41
+ }
42
+ return fallbackLabel?.trim() || sessionType;
43
+ }
44
+
45
+ function buildSessionTypeOptions(
46
+ options: Array<{ value: string; label: string }>
47
+ ): ChatSessionTypeOption[] {
48
+ const deduped = new Map<string, ChatSessionTypeOption>();
49
+ for (const option of options) {
50
+ const value = normalizeSessionType(option.value);
51
+ deduped.set(value, {
52
+ value,
53
+ label: option.label?.trim() || resolveSessionTypeLabel(value)
54
+ });
55
+ }
56
+ if (!deduped.has(DEFAULT_SESSION_TYPE)) {
57
+ deduped.set(DEFAULT_SESSION_TYPE, {
58
+ value: DEFAULT_SESSION_TYPE,
59
+ label: resolveSessionTypeLabel(DEFAULT_SESSION_TYPE)
60
+ });
61
+ }
62
+ return Array.from(deduped.values()).sort((left, right) => {
63
+ if (left.value === DEFAULT_SESSION_TYPE) {
64
+ return -1;
65
+ }
66
+ if (right.value === DEFAULT_SESSION_TYPE) {
67
+ return 1;
68
+ }
69
+ return left.value.localeCompare(right.value);
70
+ });
71
+ }
72
+
73
+ export function useChatSessionTypeState(params: UseChatSessionTypeStateParams): {
74
+ sessionTypeOptions: ChatSessionTypeOption[];
75
+ defaultSessionType: string;
76
+ selectedSessionType: string;
77
+ canEditSessionType: boolean;
78
+ sessionTypeUnavailable: boolean;
79
+ sessionTypeUnavailableMessage: string | null;
80
+ } {
81
+ const {
82
+ selectedSession,
83
+ selectedSessionKey,
84
+ pendingSessionType,
85
+ setPendingSessionType,
86
+ sessionTypesData
87
+ } = params;
88
+
89
+ const runtimeSessionTypeOptions = useMemo(
90
+ () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
91
+ [sessionTypesData?.options]
92
+ );
93
+ const sessionTypeOptions = useMemo(() => {
94
+ const options = [...runtimeSessionTypeOptions];
95
+ const currentSessionType = normalizeSessionType(selectedSession?.sessionType);
96
+ if (!options.some((option) => option.value === currentSessionType)) {
97
+ options.push({
98
+ value: currentSessionType,
99
+ label: resolveSessionTypeLabel(currentSessionType)
100
+ });
101
+ }
102
+ return options.sort((left, right) => {
103
+ if (left.value === DEFAULT_SESSION_TYPE) {
104
+ return -1;
105
+ }
106
+ if (right.value === DEFAULT_SESSION_TYPE) {
107
+ return 1;
108
+ }
109
+ return left.value.localeCompare(right.value);
110
+ });
111
+ }, [runtimeSessionTypeOptions, selectedSession?.sessionType]);
112
+ const defaultSessionType = useMemo(
113
+ () => normalizeSessionType(sessionTypesData?.defaultType ?? DEFAULT_SESSION_TYPE),
114
+ [sessionTypesData?.defaultType]
115
+ );
116
+ const selectedSessionType = useMemo(
117
+ () => normalizeSessionType(selectedSession?.sessionType ?? pendingSessionType ?? defaultSessionType),
118
+ [defaultSessionType, pendingSessionType, selectedSession?.sessionType]
119
+ );
120
+
121
+ useEffect(() => {
122
+ if (selectedSessionKey) {
123
+ return;
124
+ }
125
+ setPendingSessionType(defaultSessionType);
126
+ }, [defaultSessionType, selectedSessionKey, setPendingSessionType]);
127
+
128
+ const canEditSessionType = !selectedSessionKey || Boolean(selectedSession?.sessionTypeMutable);
129
+ const availableSessionTypeSet = useMemo(
130
+ () => new Set(runtimeSessionTypeOptions.map((option) => option.value)),
131
+ [runtimeSessionTypeOptions]
132
+ );
133
+ const sessionTypeUnavailable = Boolean(
134
+ selectedSession && !availableSessionTypeSet.has(normalizeSessionType(selectedSession.sessionType))
135
+ );
136
+ const sessionTypeUnavailableMessage = sessionTypeUnavailable
137
+ ? `${resolveSessionTypeLabel(selectedSessionType)} ${t('chatSessionTypeUnavailableSuffix')}`
138
+ : null;
139
+
140
+ return {
141
+ sessionTypeOptions,
142
+ defaultSessionType,
143
+ selectedSessionType,
144
+ canEditSessionType,
145
+ sessionTypeUnavailable,
146
+ sessionTypeUnavailableMessage
147
+ };
148
+ }
@@ -9,7 +9,7 @@ interface MaskedInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
9
9
  isSet?: boolean;
10
10
  }
11
11
 
12
- export function MaskedInput({ maskedValue, isSet, className, value, onChange, placeholder, ...props }: MaskedInputProps) {
12
+ export function MaskedInput({ isSet, className, value, onChange, placeholder, ...props }: MaskedInputProps) {
13
13
  const [showKey, setShowKey] = useState(false);
14
14
  const [isEditing, setIsEditing] = useState(false);
15
15
  const hasUserInput = typeof value === 'string' && value.length > 0;
@@ -24,6 +24,7 @@ import {
24
24
  fetchChatRun,
25
25
  fetchChatRuns,
26
26
  fetchChatCapabilities,
27
+ fetchChatSessionTypes,
27
28
  fetchCronJobs,
28
29
  deleteCronJob,
29
30
  setCronJobEnabled,
@@ -294,9 +295,26 @@ export function useChatCapabilities(params?: { sessionKey?: string | null; agent
294
295
  });
295
296
  }
296
297
 
297
- export function useChatRuns(params?: { sessionKey?: string | null; states?: Array<'queued' | 'running' | 'completed' | 'failed' | 'aborted'>; limit?: number }) {
298
+ export function useChatSessionTypes() {
299
+ return useQuery({
300
+ queryKey: ['chat-session-types'],
301
+ queryFn: fetchChatSessionTypes,
302
+ staleTime: 10_000,
303
+ retry: false
304
+ });
305
+ }
306
+
307
+ export function useChatRuns(params?: {
308
+ sessionKey?: string | null;
309
+ states?: Array<'queued' | 'running' | 'completed' | 'failed' | 'aborted'>;
310
+ limit?: number;
311
+ syncActiveStates?: boolean;
312
+ isLocallyRunning?: boolean;
313
+ }) {
298
314
  const sessionKey = params?.sessionKey?.trim() || undefined;
299
315
  const states = Array.isArray(params?.states) && params.states.length > 0 ? params.states : undefined;
316
+ const isActiveStatesQuery = Boolean(states?.some((state) => state === 'queued' || state === 'running'));
317
+ const shouldSyncActiveStates = Boolean(params?.syncActiveStates && isActiveStatesQuery);
300
318
  return useQuery({
301
319
  queryKey: ['chat-runs', sessionKey ?? null, states ?? null, params?.limit ?? null],
302
320
  queryFn: () => fetchChatRuns({
@@ -306,6 +324,18 @@ export function useChatRuns(params?: { sessionKey?: string | null; states?: Arra
306
324
  }),
307
325
  enabled: Boolean(sessionKey) || Boolean(states),
308
326
  staleTime: 5_000,
327
+ refetchInterval: (query) => {
328
+ if (!shouldSyncActiveStates) {
329
+ return false;
330
+ }
331
+ if (params?.isLocallyRunning) {
332
+ return 800;
333
+ }
334
+ const data = query.state.data;
335
+ const hasActiveRuns = Array.isArray(data?.runs) && data.runs.length > 0;
336
+ return hasActiveRuns ? 800 : false;
337
+ },
338
+ refetchIntervalInBackground: false,
309
339
  retry: false
310
340
  });
311
341
  }
@@ -0,0 +1,20 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { BehaviorSubject, Observable } from 'rxjs';
3
+
4
+ export function useValueFromBehaviorSubject<T>(subject: BehaviorSubject<T>): T {
5
+ const [state, setState] = useState<T>(subject.getValue());
6
+ useEffect(() => {
7
+ const subscription = subject.subscribe(setState);
8
+ return () => subscription.unsubscribe();
9
+ }, [subject]);
10
+ return state;
11
+ }
12
+
13
+ export function useValueFromObservable<T>(observable: Observable<T>, defaultValue: T): T {
14
+ const [state, setState] = useState<T>(defaultValue);
15
+ useEffect(() => {
16
+ const subscription = observable.subscribe(setState);
17
+ return () => subscription.unsubscribe();
18
+ }, [observable]);
19
+ return state;
20
+ }
@@ -11,37 +11,6 @@ export type ToolCard = {
11
11
  hasResult?: boolean;
12
12
  };
13
13
 
14
- export type ChatTimelineMessageItem = {
15
- kind: 'message';
16
- key: string;
17
- role: ChatRole;
18
- timestamp: string;
19
- message: SessionMessageView;
20
- };
21
-
22
- export type ChatTimelineAssistantTurnSegment =
23
- | {
24
- kind: 'assistant_message';
25
- key: string;
26
- text: string;
27
- reasoning: string;
28
- }
29
- | {
30
- kind: 'tool_card';
31
- key: string;
32
- card: ToolCard;
33
- };
34
-
35
- export type ChatTimelineAssistantTurnItem = {
36
- kind: 'assistant_turn';
37
- key: string;
38
- role: 'assistant';
39
- timestamp: string;
40
- segments: ChatTimelineAssistantTurnSegment[];
41
- };
42
-
43
- export type ChatTimelineItem = ChatTimelineMessageItem | ChatTimelineAssistantTurnItem;
44
-
45
14
  const TOOL_DETAIL_FIELDS = ['cmd', 'command', 'query', 'q', 'path', 'url', 'to', 'channel', 'agentId', 'sessionKey'];
46
15
 
47
16
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -55,7 +24,7 @@ function truncateText(value: string, maxChars = 2400): string {
55
24
  return `${value.slice(0, maxChars)}\n…`;
56
25
  }
57
26
 
58
- function stringifyUnknown(value: unknown): string {
27
+ export function stringifyUnknown(value: unknown): string {
59
28
  if (typeof value === 'string') {
60
29
  return value;
61
30
  }
@@ -91,7 +60,7 @@ function parseArgsObject(value: unknown): Record<string, unknown> | null {
91
60
  }
92
61
  }
93
62
 
94
- function summarizeToolArgs(args: unknown): string | undefined {
63
+ export function summarizeToolArgs(args: unknown): string | undefined {
95
64
  const parsed = parseArgsObject(args);
96
65
  if (!parsed) {
97
66
  const text = stringifyUnknown(args).trim();
@@ -212,20 +181,6 @@ export function extractToolCards(message: SessionMessageView): ToolCard[] {
212
181
  return cards;
213
182
  }
214
183
 
215
- function normalizeEvent(event: SessionEventView, index: number): SessionEventView & { _idx: number; _seq: number } {
216
- const seq = Number.isFinite(event.seq) && event.seq > 0 ? Math.trunc(event.seq) : index + 1;
217
- const timestamp =
218
- typeof event.timestamp === 'string' && event.timestamp
219
- ? event.timestamp
220
- : event.message?.timestamp ?? new Date().toISOString();
221
- return {
222
- ...event,
223
- timestamp,
224
- _idx: index,
225
- _seq: seq
226
- };
227
- }
228
-
229
184
  function inferEventTypeFromMessage(message: SessionMessageView): string {
230
185
  const role = normalizeChatRole(message);
231
186
  if (role === 'assistant' && hasToolCalls(message)) {
@@ -245,158 +200,3 @@ export function buildFallbackEventsFromMessages(messages: SessionMessageView[]):
245
200
  message
246
201
  }));
247
202
  }
248
-
249
- function appendText(base: string, next: string): string {
250
- if (!next) {
251
- return base;
252
- }
253
- if (!base) {
254
- return next;
255
- }
256
- return `${base}\n\n${next}`;
257
- }
258
-
259
- export function buildChatTimeline(events: SessionEventView[]): ChatTimelineItem[] {
260
- const normalized = events
261
- .map((event, index) => normalizeEvent(event, index))
262
- .sort((left, right) => {
263
- if (left._seq !== right._seq) {
264
- return left._seq - right._seq;
265
- }
266
- const leftTs = Date.parse(left.timestamp);
267
- const rightTs = Date.parse(right.timestamp);
268
- if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
269
- return leftTs - rightTs;
270
- }
271
- return left._idx - right._idx;
272
- });
273
-
274
- const timeline: ChatTimelineItem[] = [];
275
- let activeTurn:
276
- | {
277
- item: ChatTimelineAssistantTurnItem;
278
- cardByCallId: Map<string, ToolCard>;
279
- }
280
- | null = null;
281
-
282
- const closeActiveTurn = () => {
283
- activeTurn = null;
284
- };
285
-
286
- const ensureActiveTurn = (eventKey: string, timestamp: string) => {
287
- if (activeTurn) {
288
- activeTurn.item.timestamp = timestamp;
289
- return activeTurn;
290
- }
291
- const item: ChatTimelineAssistantTurnItem = {
292
- kind: 'assistant_turn',
293
- key: `turn-${eventKey}`,
294
- role: 'assistant',
295
- timestamp,
296
- segments: []
297
- };
298
- timeline.push(item);
299
- activeTurn = {
300
- item,
301
- cardByCallId: new Map<string, ToolCard>()
302
- };
303
- return activeTurn;
304
- };
305
-
306
- const pushAssistantMessageSegment = (
307
- target: { item: ChatTimelineAssistantTurnItem },
308
- eventKey: string,
309
- message: SessionMessageView
310
- ) => {
311
- const text = extractMessageText(message.content).trim();
312
- const reasoning =
313
- typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : '';
314
- if (!text && !reasoning) {
315
- return;
316
- }
317
- target.item.segments.push({
318
- kind: 'assistant_message',
319
- key: `assistant-${eventKey}-${target.item.segments.length}`,
320
- text,
321
- reasoning
322
- });
323
- };
324
-
325
- for (const event of normalized) {
326
- const message = event.message;
327
- if (!message) {
328
- continue;
329
- }
330
-
331
- const role = normalizeChatRole(message);
332
- const timestamp =
333
- typeof message.timestamp === 'string' && message.timestamp
334
- ? message.timestamp
335
- : event.timestamp;
336
- const eventKey = `${event._seq}-${event._idx}`;
337
-
338
- if (role === 'assistant') {
339
- const turn = ensureActiveTurn(eventKey, timestamp);
340
- pushAssistantMessageSegment(turn, eventKey, message);
341
- if (!hasToolCalls(message)) {
342
- continue;
343
- }
344
-
345
- const toolCards = buildToolCallCards(message);
346
- for (const card of toolCards) {
347
- turn.item.segments.push({
348
- kind: 'tool_card',
349
- key: `tool-call-${eventKey}-${turn.item.segments.length}`,
350
- card
351
- });
352
- if (typeof card.callId === 'string' && card.callId.trim()) {
353
- turn.cardByCallId.set(card.callId, card);
354
- }
355
- }
356
- continue;
357
- }
358
-
359
- if (role === 'tool') {
360
- const turn = ensureActiveTurn(eventKey, timestamp);
361
- const callId =
362
- typeof message.tool_call_id === 'string' && message.tool_call_id.trim()
363
- ? message.tool_call_id.trim()
364
- : undefined;
365
- if (callId && turn.cardByCallId.has(callId)) {
366
- const card = turn.cardByCallId.get(callId)!;
367
- const resultText = extractMessageText(message.content).trim();
368
- card.text = appendText(card.text ?? '', resultText);
369
- card.hasResult = true;
370
- if (typeof message.name === 'string' && message.name.trim()) {
371
- card.name = message.name.trim();
372
- }
373
- turn.item.timestamp = timestamp;
374
- continue;
375
- }
376
-
377
- turn.item.segments.push({
378
- kind: 'tool_card',
379
- key: `tool-result-${eventKey}-${turn.item.segments.length}`,
380
- card: {
381
- kind: 'result',
382
- name: toToolName(message.name),
383
- text: extractMessageText(message.content).trim(),
384
- callId,
385
- hasResult: true
386
- }
387
- });
388
- continue;
389
- }
390
-
391
- timeline.push({
392
- kind: 'message',
393
- key: `message-${event._seq}-${event._idx}`,
394
- role,
395
- timestamp,
396
- message
397
- });
398
- closeActiveTurn();
399
- }
400
-
401
- return timeline;
402
- }