@nextclaw/ui 0.15.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assets/{api-DLtsiBKm.js → api-b1R9VdeH.js} +1 -1
  3. package/dist/assets/{appearance-settings-page-CtkeYXux.js → appearance-settings-page-D2O9pch9.js} +1 -1
  4. package/dist/assets/{book-open-BQwWBDKb.js → book-open-B77lQ2Tg.js} +1 -1
  5. package/dist/assets/{channels-list-page-Cv8o9Hxg.js → channels-list-page-DcsIQc6A.js} +2 -2
  6. package/dist/assets/chat-page-CZ7WxPir.js +1 -0
  7. package/dist/assets/{config-split-page-EidRBpP2.js → config-split-page-BWQ6_Raq.js} +1 -1
  8. package/dist/assets/{confirm-dialog-iIJFFp2T.js → confirm-dialog-C-NXSioX.js} +1 -1
  9. package/dist/assets/desktop-update-config-usHpXugP.js +1 -0
  10. package/dist/assets/{dist-CBxWOazz.js → dist-YtoqU-AT.js} +1 -1
  11. package/dist/assets/{dist-D2eZC3OR.js → dist-jdGmbb6w.js} +1 -1
  12. package/dist/assets/doc-browser-B_Tf9n70.js +1 -0
  13. package/dist/assets/{doc-browser-B76XOXmW.js → doc-browser-BdZPFOVa.js} +1 -1
  14. package/dist/assets/{doc-browser-context-Sv-044Cl.js → doc-browser-context-DwokemUn.js} +1 -1
  15. package/dist/assets/{es2015-DhQo_oS6.js → es2015-bNpL2LKy.js} +1 -1
  16. package/dist/assets/{external-link-CM5dnUI6.js → external-link-BM6fk8q-.js} +1 -1
  17. package/dist/assets/{i18n-provider-di1waTGX.js → i18n-provider-CntWolRA.js} +1 -1
  18. package/dist/assets/index-BIcEw90u.js +104 -0
  19. package/dist/assets/index-BiNSTKTU.css +1 -0
  20. package/dist/assets/loader-circle-MBgNSVIH.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-C_MVQUbT.js → mcp-marketplace-page-CLmUstLN.js} +1 -1
  22. package/dist/assets/mcp-marketplace-page-rZkmWPMy.js +1 -0
  23. package/dist/assets/{model-config-page-Cdr9P6TQ.js → model-config-page-Di0ySJD2.js} +1 -1
  24. package/dist/assets/plus-vDqW5Kxp.js +1 -0
  25. package/dist/assets/{provider-scoped-model-input-g1WkGiJ8.js → provider-scoped-model-input-Cm_a4GHf.js} +1 -1
  26. package/dist/assets/providers-config-page-BAf2W0MV.js +1 -0
  27. package/dist/assets/{react-DjpzoZXB.js → react-BN8uzPnp.js} +1 -1
  28. package/dist/assets/{refresh-cw-DaTv3zxE.js → refresh-cw-BpVcFPmJ.js} +1 -1
  29. package/dist/assets/remote-CLQ0IBM8.js +1 -0
  30. package/dist/assets/rotate-cw-CvNBv7_w.js +1 -0
  31. package/dist/assets/{runtime-config-page-CfTqh1nv.js → runtime-config-page-CflvvoDc.js} +1 -1
  32. package/dist/assets/{save-BZof1uie.js → save-BpFW5fpk.js} +1 -1
  33. package/dist/assets/{search-MxAarS_g.js → search-D0uO7enf.js} +1 -1
  34. package/dist/assets/{search-config-page-Dm8daYny.js → search-config-page-D0klFUXd.js} +1 -1
  35. package/dist/assets/{secrets-config-page-D8AbcR05.js → secrets-config-page-dVZ631Wp.js} +1 -1
  36. package/dist/assets/security-config-C5gnlkuj.js +1 -0
  37. package/dist/assets/{select-D7GcNP0z.js → select-Bh-CrA4p.js} +1 -1
  38. package/dist/assets/{setting-row-CuozlnJd.js → setting-row-BJfCHRcb.js} +1 -1
  39. package/dist/assets/settings-2-Blzi1vr6.js +1 -0
  40. package/dist/assets/{side-dock-BcLau5S_.js → side-dock-ZdaSrQVB.js} +1 -1
  41. package/dist/assets/{skeleton-shfmXuYp.js → skeleton-BLlEzebZ.js} +1 -1
  42. package/dist/assets/{switch-C1BWMU5c.js → switch-CFLJFNSd.js} +1 -1
  43. package/dist/assets/{tag-chip-DCUrQUCP.js → tag-chip-CzGihiQx.js} +1 -1
  44. package/dist/assets/{tooltip-DfAHHA76.js → tooltip-CdcrBjrC.js} +1 -1
  45. package/dist/assets/{use-config-DAdbT5QJ.js → use-config-CVPgsBmU.js} +1 -1
  46. package/dist/assets/{use-confirm-dialog-eWcJ6V-s.js → use-confirm-dialog-CSeBk9SF.js} +1 -1
  47. package/dist/assets/x-BF2KQ--V.js +1 -0
  48. package/dist/index.html +26 -26
  49. package/package.json +9 -9
  50. package/src/features/agents/components/__tests__/agents-page.test.tsx +135 -2
  51. package/src/features/agents/components/agent-advanced-config-fields.tsx +70 -0
  52. package/src/features/agents/components/agent-details-dialog.tsx +398 -0
  53. package/src/features/agents/components/agent-dialogs.tsx +20 -47
  54. package/src/features/agents/components/agents-page.tsx +76 -10
  55. package/src/features/agents/types/agent-form.types.ts +21 -0
  56. package/src/features/agents/utils/agent-form.utils.ts +39 -0
  57. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +132 -1
  58. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +8 -0
  59. package/src/features/chat/components/conversation/chat-conversation-header.tsx +7 -3
  60. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +18 -3
  61. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +1 -1
  62. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +23 -1
  63. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +74 -0
  64. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +3 -2
  65. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +294 -0
  66. package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +15 -1
  67. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +24 -4
  68. package/src/features/chat/features/session/utils/chat-session-display.utils.ts +3 -0
  69. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +1 -0
  70. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +34 -0
  71. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +1 -7
  72. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +46 -0
  73. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +47 -0
  74. package/src/features/chat/managers/chat-thread.manager.ts +8 -2
  75. package/src/features/system-status/components/__tests__/desktop-update-config.test.tsx +97 -5
  76. package/src/features/system-status/components/desktop-update-config.tsx +91 -2
  77. package/src/features/system-status/utils/update-release-notes.utils.ts +94 -0
  78. package/src/shared/lib/api/ncp-session.types.ts +1 -1
  79. package/src/shared/lib/api/types.ts +12 -3
  80. package/src/shared/lib/i18n/locales/en-US/agents.json +41 -0
  81. package/src/shared/lib/i18n/locales/en-US/chat.json +3 -0
  82. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +8 -0
  83. package/src/shared/lib/i18n/locales/zh-CN/agents.json +41 -0
  84. package/src/shared/lib/i18n/locales/zh-CN/chat.json +3 -0
  85. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +8 -0
  86. package/dist/assets/chat-page-BtA7H1Ea.js +0 -1
  87. package/dist/assets/desktop-update-config-C6n_TB1r.js +0 -1
  88. package/dist/assets/doc-browser-DBnj9UMB.js +0 -1
  89. package/dist/assets/ellipsis-2uzhypw5.js +0 -1
  90. package/dist/assets/index-CBRP-7-a.css +0 -1
  91. package/dist/assets/index-Z1fYpbNd.js +0 -104
  92. package/dist/assets/loader-circle-BjmN5DeJ.js +0 -1
  93. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +0 -1
  94. package/dist/assets/plus-3ztRZD_l.js +0 -1
  95. package/dist/assets/providers-config-page-CWlO7J4A.js +0 -1
  96. package/dist/assets/remote-CKWZfVVB.js +0 -1
  97. package/dist/assets/rotate-cw-CHcaRUDz.js +0 -1
  98. package/dist/assets/security-config-Ds0LsJmo.js +0 -1
  99. package/dist/assets/x-DqL1mYLC.js +0 -1
@@ -0,0 +1,294 @@
1
+ import { useMemo, useState } from "react";
2
+ import {
3
+ Check,
4
+ ChevronDown,
5
+ Loader2,
6
+ MessageSquareText,
7
+ Search,
8
+ } from "lucide-react";
9
+ import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
10
+ import {
11
+ useNcpSessionListView,
12
+ type NcpSessionListItemView,
13
+ } from "@/features/chat/features/ncp/hooks/use-ncp-session-list-view";
14
+ import { SessionContextIconNode } from "@/features/chat/features/session/components/session-context-icon";
15
+ import { SessionRunBadge } from "@/features/chat/features/session/components/session-run-badge";
16
+ import {
17
+ formatSessionListTime,
18
+ sessionActivityPreviewText,
19
+ sessionDisplayName,
20
+ } from "@/features/chat/features/session/utils/chat-session-display.utils";
21
+ import { sortSessionItemsByActivityAtDesc } from "@/features/chat/features/session/utils/chat-sidebar-session-groups.utils";
22
+ import { resolveSessionContextView } from "@/features/chat/features/session/utils/session-context.utils";
23
+ import {
24
+ buildSessionTypeOptions,
25
+ type ChatSessionTypeOption,
26
+ } from "@/features/chat/features/session-type/utils/chat-session-type.utils";
27
+ import {
28
+ shouldShowUnreadSessionIndicator,
29
+ useChatSessionListStore,
30
+ } from "@/features/chat/stores/chat-session-list.store";
31
+ import { useChatQueryStore } from "@/features/chat/stores/ncp-chat-query.store";
32
+ import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
33
+ import {
34
+ Popover,
35
+ PopoverContent,
36
+ PopoverTrigger,
37
+ } from "@/shared/components/ui/popover";
38
+ import { Input } from "@/shared/components/ui/input";
39
+ import { t } from "@/shared/lib/i18n";
40
+ import { cn } from "@/shared/lib/utils";
41
+
42
+ const SWITCHER_TRIGGER_CLASS =
43
+ "group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40";
44
+
45
+ function ChatSessionTitle({ title }: { title: string }) {
46
+ return (
47
+ <span className="text-sm font-medium text-foreground truncate">
48
+ {title}
49
+ </span>
50
+ );
51
+ }
52
+
53
+ function ChatSessionTitleSwitcherEmptyState({ label }: { label: string }) {
54
+ return (
55
+ <div className="flex items-center gap-2 px-3 py-4 text-xs text-muted-foreground">
56
+ <MessageSquareText className="h-4 w-4 shrink-0 text-muted-foreground/55" />
57
+ <span>{label}</span>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ function ChatSessionTitleSwitcherLoadingState() {
63
+ return (
64
+ <div className="flex items-center gap-2 px-3 py-4 text-xs text-muted-foreground">
65
+ <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
66
+ <span>{t("sessionsLoading")}</span>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function ChatSessionSwitchItem({
72
+ item,
73
+ selectedSessionKey,
74
+ optimisticReadAtBySessionKey,
75
+ sessionTypeOptions,
76
+ onSelect,
77
+ }: {
78
+ item: NcpSessionListItemView;
79
+ selectedSessionKey: string | null;
80
+ optimisticReadAtBySessionKey: Record<string, string>;
81
+ sessionTypeOptions: ChatSessionTypeOption[];
82
+ onSelect: (sessionKey: string) => void;
83
+ }) {
84
+ const { session, runStatus } = item;
85
+ const active = selectedSessionKey === session.key;
86
+ const optimisticReadAt = optimisticReadAtBySessionKey[session.key];
87
+ const effectiveReadAt =
88
+ optimisticReadAt && session.readAt
89
+ ? optimisticReadAt.localeCompare(session.readAt) > 0
90
+ ? optimisticReadAt
91
+ : session.readAt
92
+ : (optimisticReadAt ?? session.readAt);
93
+ const shouldShowUnread = shouldShowUnreadSessionIndicator({
94
+ active,
95
+ lastMessageAt: session.lastMessageAt,
96
+ readAt: effectiveReadAt,
97
+ runStatus,
98
+ });
99
+ const previewText =
100
+ sessionActivityPreviewText(session) ??
101
+ session.projectName ??
102
+ session.projectRoot ??
103
+ `${session.messageCount}`;
104
+ const trailingText = formatSessionListTime(
105
+ session.lastMessageAt ?? session.createdAt,
106
+ );
107
+ const sessionContext = resolveSessionContextView(session, sessionTypeOptions);
108
+
109
+ return (
110
+ <button
111
+ type="button"
112
+ aria-current={active ? "page" : undefined}
113
+ className={cn(
114
+ "flex w-full min-w-0 items-start gap-2 rounded-lg px-2.5 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
115
+ active
116
+ ? "bg-accent text-accent-foreground"
117
+ : "text-popover-foreground hover:bg-accent/70",
118
+ )}
119
+ onClick={() => onSelect(session.key)}
120
+ >
121
+ <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
122
+ {active ? (
123
+ <Check className="h-3.5 w-3.5" />
124
+ ) : shouldShowUnread ? (
125
+ <span
126
+ aria-label={t("chatSessionUnread")}
127
+ className="h-2 w-2 rounded-full bg-primary"
128
+ />
129
+ ) : null}
130
+ </span>
131
+ <span className="min-w-0 flex-1">
132
+ <span className="flex min-w-0 items-center gap-1.5">
133
+ <span className="min-w-0 truncate text-[13px] font-medium">
134
+ {sessionDisplayName(session)}
135
+ </span>
136
+ {sessionContext.label ? (
137
+ <span className="shrink-0 rounded-full border border-border/70 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground">
138
+ {sessionContext.label}
139
+ </span>
140
+ ) : null}
141
+ {sessionContext.icon ? (
142
+ <span className="inline-flex h-[1.125rem] w-[1.125rem] shrink-0 items-center justify-center text-muted-foreground">
143
+ <SessionContextIconNode icon={sessionContext.icon} />
144
+ </span>
145
+ ) : null}
146
+ </span>
147
+ <span className="mt-0.5 flex min-w-0 items-center gap-2 text-[11px] text-muted-foreground">
148
+ <span className="min-w-0 truncate">{previewText}</span>
149
+ <span className="shrink-0">{trailingText}</span>
150
+ </span>
151
+ </span>
152
+ {runStatus ? (
153
+ <span className="mt-0.5 shrink-0">
154
+ <SessionRunBadge status={runStatus} />
155
+ </span>
156
+ ) : null}
157
+ </button>
158
+ );
159
+ }
160
+
161
+ function ChatSessionTitleSwitcherPopover({
162
+ selectedSessionKey,
163
+ title,
164
+ }: {
165
+ selectedSessionKey: string | null;
166
+ title: string;
167
+ }) {
168
+ const presenter = usePresenter();
169
+ const [isOpen, setIsOpen] = useState(false);
170
+ const [searchQuery, setSearchQuery] = useState("");
171
+ const optimisticReadAtBySessionKey = useChatSessionListStore(
172
+ (state) => state.optimisticReadAtBySessionKey,
173
+ );
174
+ const sessionTypesData = useChatQueryStore(
175
+ (state) => state.snapshot.sessionTypesQuery?.data ?? null,
176
+ );
177
+ const { isLoading, items } = useNcpSessionListView({ query: searchQuery });
178
+ const sortedItems = useMemo(
179
+ () => sortSessionItemsByActivityAtDesc(items),
180
+ [items],
181
+ );
182
+ const sessionTypeOptions = useMemo(
183
+ () => buildSessionTypeOptions(sessionTypesData?.options ?? []),
184
+ [sessionTypesData?.options],
185
+ );
186
+
187
+ const selectSession = (sessionKey: string) => {
188
+ setIsOpen(false);
189
+ setSearchQuery("");
190
+ if (sessionKey === selectedSessionKey) {
191
+ return;
192
+ }
193
+ presenter.chatSessionListManager.selectSession(sessionKey);
194
+ };
195
+ const updateOpen = (open: boolean) => {
196
+ setIsOpen(open);
197
+ if (!open) {
198
+ setSearchQuery("");
199
+ }
200
+ };
201
+
202
+ return (
203
+ <Popover open={isOpen} onOpenChange={updateOpen}>
204
+ <PopoverTrigger asChild>
205
+ <button
206
+ type="button"
207
+ aria-label={`${t("chatSessionSwitch")}: ${title}`}
208
+ aria-expanded={isOpen}
209
+ className={SWITCHER_TRIGGER_CLASS}
210
+ >
211
+ <span className="min-w-0 truncate">{title}</span>
212
+ <ChevronDown
213
+ className={cn(
214
+ "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:text-accent-foreground",
215
+ isOpen ? "rotate-180" : null,
216
+ )}
217
+ />
218
+ </button>
219
+ </PopoverTrigger>
220
+ <PopoverContent
221
+ align="start"
222
+ className="w-[22rem] max-w-[calc(100vw-2rem)] p-0"
223
+ >
224
+ <div className="space-y-2 border-b border-border px-3 py-2">
225
+ <div className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/75">
226
+ {t("chatSessionSwitcherTitle")}
227
+ </div>
228
+ <div className="relative">
229
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/65" />
230
+ <Input
231
+ value={searchQuery}
232
+ onChange={(event) => setSearchQuery(event.target.value)}
233
+ placeholder={t("chatSearchSessionPlaceholder")}
234
+ aria-label={t("chatSearchSessionPlaceholder")}
235
+ className="h-8 rounded-lg bg-background pl-8 pr-2 text-xs"
236
+ autoFocus
237
+ />
238
+ </div>
239
+ </div>
240
+ <div className="max-h-80 overflow-y-auto p-1.5">
241
+ {isLoading ? (
242
+ <ChatSessionTitleSwitcherLoadingState />
243
+ ) : sortedItems.length > 0 ? (
244
+ <div className="space-y-1">
245
+ {sortedItems.map((item) => (
246
+ <ChatSessionSwitchItem
247
+ key={item.session.key}
248
+ item={item}
249
+ selectedSessionKey={selectedSessionKey}
250
+ optimisticReadAtBySessionKey={optimisticReadAtBySessionKey}
251
+ sessionTypeOptions={sessionTypeOptions}
252
+ onSelect={selectSession}
253
+ />
254
+ ))}
255
+ </div>
256
+ ) : (
257
+ <ChatSessionTitleSwitcherEmptyState
258
+ label={
259
+ searchQuery.trim()
260
+ ? t("chatSessionSwitcherNoResults")
261
+ : t("sessionsEmpty")
262
+ }
263
+ />
264
+ )}
265
+ </div>
266
+ </PopoverContent>
267
+ </Popover>
268
+ );
269
+ }
270
+
271
+ export function ChatSessionTitleSwitcher({
272
+ layoutMode,
273
+ selectedSessionKey,
274
+ title,
275
+ }: {
276
+ layoutMode: "desktop" | "mobile";
277
+ selectedSessionKey: string | null;
278
+ title: string;
279
+ }) {
280
+ const isSidebarCollapsed = useViewportLayoutStore(
281
+ (state) => state.isSidebarCollapsed,
282
+ );
283
+
284
+ if (layoutMode !== "desktop" || !isSidebarCollapsed) {
285
+ return <ChatSessionTitle title={title} />;
286
+ }
287
+
288
+ return (
289
+ <ChatSessionTitleSwitcherPopover
290
+ selectedSessionKey={selectedSessionKey}
291
+ title={title}
292
+ />
293
+ );
294
+ }
@@ -5,7 +5,7 @@ import {
5
5
  sessionActivityPreviewText,
6
6
  sessionDisplayName,
7
7
  sessionMatchesQuery
8
- } from '../chat-session-display.utils';
8
+ } from '@/features/chat/features/session/utils/chat-session-display.utils';
9
9
 
10
10
  function createSession(overrides: Partial<SessionEntryView> = {}): SessionEntryView {
11
11
  return {
@@ -88,6 +88,20 @@ describe('chat-session-display', () => {
88
88
  ).toBe('最终回复内容');
89
89
  });
90
90
 
91
+ it('does not show cancelled activity as ordinary session preview text', () => {
92
+ expect(
93
+ sessionActivityPreviewText(
94
+ createSession({
95
+ activityPreview: {
96
+ state: 'cancelled',
97
+ statusText: 'Run interrupted: User stopped the current run.',
98
+ timestamp: '2026-05-16T01:00:00.000Z'
99
+ }
100
+ })
101
+ )
102
+ ).toBeNull();
103
+ });
104
+
91
105
  it('formats today activity as time like WeChat session lists', () => {
92
106
  expect(
93
107
  formatSessionListTime(
@@ -105,8 +105,8 @@ describe('adaptNcpSessionSummary', () => {
105
105
  metadata: {
106
106
  last_activity_preview: {
107
107
  state: 'completed',
108
- replyText: '已经整理好方案',
109
- statusText: '工具调用完成',
108
+ replyText: 'Plan is ready',
109
+ statusText: 'Tool call completed',
110
110
  timestamp: '2026-05-16T01:00:00.000Z',
111
111
  },
112
112
  },
@@ -115,8 +115,28 @@ describe('adaptNcpSessionSummary', () => {
115
115
 
116
116
  expect(adapted.activityPreview).toEqual({
117
117
  state: 'completed',
118
- replyText: '已经整理好方案',
119
- statusText: '工具调用完成',
118
+ replyText: 'Plan is ready',
119
+ statusText: 'Tool call completed',
120
+ timestamp: '2026-05-16T01:00:00.000Z',
121
+ });
122
+ });
123
+
124
+ it('maps cancelled session activity preview metadata into the session entry', () => {
125
+ const adapted = adaptNcpSessionSummary(
126
+ createSummary({
127
+ metadata: {
128
+ last_activity_preview: {
129
+ state: 'cancelled',
130
+ statusText: 'Run interrupted: User stopped the current run.',
131
+ timestamp: '2026-05-16T01:00:00.000Z',
132
+ },
133
+ },
134
+ }),
135
+ );
136
+
137
+ expect(adapted.activityPreview).toEqual({
138
+ state: 'cancelled',
139
+ statusText: 'Run interrupted: User stopped the current run.',
120
140
  timestamp: '2026-05-16T01:00:00.000Z',
121
141
  });
122
142
  });
@@ -15,6 +15,9 @@ export function sessionActivityPreviewText(session: SessionEntryView): string |
15
15
  if (!preview) {
16
16
  return null;
17
17
  }
18
+ if (preview.state === 'cancelled') {
19
+ return null;
20
+ }
18
21
  if (preview.state === 'failed' || preview.state === 'running') {
19
22
  return preview.statusText ?? preview.replyText ?? null;
20
23
  }
@@ -18,6 +18,7 @@ const SESSION_ACTIVITY_PREVIEW_STATE_SET = new Set<SessionActivityPreviewView['s
18
18
  'running',
19
19
  'completed',
20
20
  'failed',
21
+ 'cancelled',
21
22
  'idle'
22
23
  ]);
23
24
 
@@ -189,6 +189,23 @@ describe("ChatSessionWorkspaceFilePreview rendering", () => {
189
189
  ).toBe("js");
190
190
  });
191
191
 
192
+ it("keeps HTML files in the source preview when preview viewer is automatic", () => {
193
+ mockTextRead({
194
+ resolvedPath: "/tmp/example.html",
195
+ text: "<!doctype html><h1>Hello</h1>",
196
+ });
197
+ renderWorkspaceFilePreview({
198
+ file: {
199
+ label: "example.html",
200
+ path: "/tmp/example.html",
201
+ viewMode: "preview",
202
+ },
203
+ });
204
+
205
+ expect(screen.queryByTestId("workspace-html-preview")).toBeNull();
206
+ expect(screen.getByTestId("file-code-surface")).toBeTruthy();
207
+ });
208
+
192
209
  it("renders HTML files through an unrestricted server content iframe when rendered preview is requested", () => {
193
210
  mockTextRead({
194
211
  resolvedPath: "/tmp/example.html",
@@ -212,6 +229,23 @@ describe("ChatSessionWorkspaceFilePreview rendering", () => {
212
229
  expect(screen.queryByTestId("file-code-surface")).toBeNull();
213
230
  });
214
231
 
232
+ it("keeps HTML files in the source preview when source is requested", () => {
233
+ mockTextRead({
234
+ resolvedPath: "/tmp/example.html",
235
+ text: "<!doctype html><h1>Hello</h1>",
236
+ });
237
+ renderWorkspaceFilePreview({
238
+ file: {
239
+ path: "/tmp/example.html",
240
+ previewViewer: "source",
241
+ viewMode: "preview",
242
+ },
243
+ });
244
+
245
+ expect(screen.queryByTestId("workspace-html-preview")).toBeNull();
246
+ expect(screen.getByTestId("file-code-surface")).toBeTruthy();
247
+ });
248
+
215
249
  it("falls back to the source preview when rendered is requested for a non-HTML file", () => {
216
250
  mockTextRead({ resolvedPath: "/tmp/example.txt", text: "plain text" });
217
251
  renderWorkspaceFilePreview({
@@ -24,17 +24,11 @@ function inferPreviewKind(params: {
24
24
  return /\.mdx?$/i.test(params.path) ? "markdown" : "text";
25
25
  }
26
26
 
27
- function isHtmlFilePath(path: string): boolean {
28
- return /\.html?$/i.test(path);
29
- }
30
-
31
27
  function resolveFilePreviewViewer(params: {
32
28
  path: string;
33
29
  viewer?: ChatFilePreviewViewer | null;
34
30
  }): "source" | "rendered" {
35
- return params.viewer === "rendered" && isHtmlFilePath(params.path)
36
- ? "rendered"
37
- : "source";
31
+ return params.viewer === "rendered" && /\.html?$/i.test(params.path) ? "rendered" : "source";
38
32
  }
39
33
 
40
34
  function buildPreviewBlock(params: {
@@ -158,4 +158,50 @@ describe("buildWorkspaceTabsViewModel", () => {
158
158
  active: true,
159
159
  });
160
160
  });
161
+
162
+ it("keeps distinct source and rendered file tabs without collapsing by path", () => {
163
+ const renderedTab = {
164
+ key: "parent::preview:rendered::demo.html",
165
+ parentSessionKey: "parent-1",
166
+ path: "demo.html",
167
+ label: "demo.html",
168
+ viewMode: "preview" as const,
169
+ previewViewer: "rendered" as const,
170
+ };
171
+ const sourceTab = {
172
+ key: "parent::preview::demo.html",
173
+ parentSessionKey: "parent-1",
174
+ path: "demo.html",
175
+ label: "demo.html",
176
+ viewMode: "preview" as const,
177
+ previewViewer: "source" as const,
178
+ };
179
+
180
+ const tabs = buildWorkspaceTabsViewModel({
181
+ resolvedChildTabs: [],
182
+ activeSideChatDraft: null,
183
+ workspaceFileTabs: [sourceTab, renderedTab],
184
+ sessionCronJobCount: 0,
185
+ activeSelection: {
186
+ kind: "file",
187
+ file: sourceTab,
188
+ },
189
+ optimisticReadAtBySessionKey: {},
190
+ onSelectSession: vi.fn(),
191
+ onSelectFile: vi.fn(),
192
+ onCloseFile: vi.fn(),
193
+ onSelectCronJobs: vi.fn(),
194
+ });
195
+
196
+ expect(tabs).toEqual([
197
+ expect.objectContaining({
198
+ key: "file:parent::preview::demo.html",
199
+ active: true,
200
+ }),
201
+ expect.objectContaining({
202
+ key: "file:parent::preview:rendered::demo.html",
203
+ active: false,
204
+ }),
205
+ ]);
206
+ });
161
207
  });
@@ -331,6 +331,53 @@ describe('ChatThreadManager', () => {
331
331
  });
332
332
 
333
333
  describe('ChatThreadManager workspace file tabs', () => {
334
+ it('opens HTML previews in source mode when the viewer is omitted or auto', () => {
335
+ const manager = new ChatThreadManager(
336
+ createUiManager(),
337
+ {} as ConstructorParameters<typeof ChatThreadManager>[1],
338
+ );
339
+
340
+ manager.openFilePreview({
341
+ path: 'demo.html',
342
+ label: 'demo.html',
343
+ viewMode: 'preview',
344
+ });
345
+
346
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
347
+ activeWorkspaceFileKey: 'parent-session-1::preview::demo.html',
348
+ workspaceFileTabs: [
349
+ {
350
+ key: 'parent-session-1::preview::demo.html',
351
+ path: 'demo.html',
352
+ viewMode: 'preview',
353
+ previewViewer: 'source',
354
+ },
355
+ ],
356
+ });
357
+
358
+ manager.openFilePreview({
359
+ path: 'auto.html',
360
+ label: 'auto.html',
361
+ viewMode: 'preview',
362
+ previewViewer: 'auto',
363
+ });
364
+
365
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
366
+ activeWorkspaceFileKey: 'parent-session-1::preview::auto.html',
367
+ workspaceFileTabs: [
368
+ {
369
+ key: 'parent-session-1::preview::auto.html',
370
+ path: 'auto.html',
371
+ viewMode: 'preview',
372
+ previewViewer: 'source',
373
+ },
374
+ expect.objectContaining({
375
+ key: 'parent-session-1::preview::demo.html',
376
+ }),
377
+ ],
378
+ });
379
+ });
380
+
334
381
  it('keeps source and rendered previews for the same file as separate workspace tabs', () => {
335
382
  const manager = new ChatThreadManager(
336
383
  createUiManager(),
@@ -111,14 +111,20 @@ export class ChatThreadManager {
111
111
  return null;
112
112
  }
113
113
  const normalizedParentSessionKey = parentSessionKey?.trim() || null;
114
- const tabViewIdentity = action.viewMode === 'preview' && action.previewViewer === 'rendered' ? 'preview:rendered' : action.viewMode;
114
+ const previewViewer =
115
+ action.viewMode === 'preview' &&
116
+ action.previewViewer !== 'rendered' &&
117
+ (action.previewViewer === 'auto' || action.previewViewer === 'source' || /\.html?$/i.test(normalizedPath))
118
+ ? 'source'
119
+ : action.previewViewer ?? null;
120
+ const tabViewIdentity = action.viewMode === 'preview' && previewViewer === 'rendered' ? 'preview:rendered' : action.viewMode;
115
121
  return {
116
122
  key: `${normalizedParentSessionKey ?? 'draft'}::${tabViewIdentity}::${normalizedPath}`,
117
123
  parentSessionKey: normalizedParentSessionKey,
118
124
  path: normalizedPath,
119
125
  label: action.label?.trim() || null,
120
126
  viewMode: action.viewMode,
121
- previewViewer: action.previewViewer ?? null,
127
+ previewViewer,
122
128
  line: action.line ?? null,
123
129
  column: action.column ?? null,
124
130
  rawText: action.rawText ?? null,