@theia/ai-ide 1.74.0-next.8 → 1.74.0

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 (96) hide show
  1. package/lib/browser/ai-allow-all-mode-chat-banner.d.ts +68 -0
  2. package/lib/browser/ai-allow-all-mode-chat-banner.d.ts.map +1 -0
  3. package/lib/browser/ai-allow-all-mode-chat-banner.js +184 -0
  4. package/lib/browser/ai-allow-all-mode-chat-banner.js.map +1 -0
  5. package/lib/browser/ai-allow-all-mode-chat-banner.spec.d.ts +2 -0
  6. package/lib/browser/ai-allow-all-mode-chat-banner.spec.d.ts.map +1 -0
  7. package/lib/browser/ai-allow-all-mode-chat-banner.spec.js +170 -0
  8. package/lib/browser/ai-allow-all-mode-chat-banner.spec.js.map +1 -0
  9. package/lib/browser/ai-configuration/tools-configuration-widget.d.ts +3 -4
  10. package/lib/browser/ai-configuration/tools-configuration-widget.d.ts.map +1 -1
  11. package/lib/browser/ai-configuration/tools-configuration-widget.js +17 -10
  12. package/lib/browser/ai-configuration/tools-configuration-widget.js.map +1 -1
  13. package/lib/browser/ai-first-perspective-contribution.d.ts +6 -0
  14. package/lib/browser/ai-first-perspective-contribution.d.ts.map +1 -0
  15. package/lib/browser/ai-first-perspective-contribution.js +50 -0
  16. package/lib/browser/ai-first-perspective-contribution.js.map +1 -0
  17. package/lib/browser/ai-sessions-view-contribution.d.ts +13 -0
  18. package/lib/browser/ai-sessions-view-contribution.d.ts.map +1 -0
  19. package/lib/browser/ai-sessions-view-contribution.js +69 -0
  20. package/lib/browser/ai-sessions-view-contribution.js.map +1 -0
  21. package/lib/browser/ai-sessions-widget.d.ts +27 -0
  22. package/lib/browser/ai-sessions-widget.d.ts.map +1 -0
  23. package/lib/browser/ai-sessions-widget.js +131 -0
  24. package/lib/browser/ai-sessions-widget.js.map +1 -0
  25. package/lib/browser/chat-session-item-action-contribution.js +2 -2
  26. package/lib/browser/chat-session-item-action-contribution.js.map +1 -1
  27. package/lib/browser/chat-session-item.d.ts +15 -0
  28. package/lib/browser/chat-session-item.d.ts.map +1 -1
  29. package/lib/browser/chat-session-item.js +39 -18
  30. package/lib/browser/chat-session-item.js.map +1 -1
  31. package/lib/browser/chat-session-list-components.d.ts +38 -0
  32. package/lib/browser/chat-session-list-components.d.ts.map +1 -0
  33. package/lib/browser/chat-session-list-components.js +87 -0
  34. package/lib/browser/chat-session-list-components.js.map +1 -0
  35. package/lib/browser/chat-session-list-service.d.ts +34 -0
  36. package/lib/browser/chat-session-list-service.d.ts.map +1 -0
  37. package/lib/browser/chat-session-list-service.js +251 -0
  38. package/lib/browser/chat-session-list-service.js.map +1 -0
  39. package/lib/browser/chat-session-list-service.spec.d.ts +2 -0
  40. package/lib/browser/chat-session-list-service.spec.d.ts.map +1 -0
  41. package/lib/browser/{chat-sessions-welcome-message-provider.spec.js → chat-session-list-service.spec.js} +133 -39
  42. package/lib/browser/chat-session-list-service.spec.js.map +1 -0
  43. package/lib/browser/chat-session-tooltip.d.ts +2 -2
  44. package/lib/browser/chat-session-tooltip.d.ts.map +1 -1
  45. package/lib/browser/chat-session-tooltip.js +33 -10
  46. package/lib/browser/chat-session-tooltip.js.map +1 -1
  47. package/lib/browser/chat-sessions-welcome-message-provider.d.ts +9 -35
  48. package/lib/browser/chat-sessions-welcome-message-provider.d.ts.map +1 -1
  49. package/lib/browser/chat-sessions-welcome-message-provider.js +51 -205
  50. package/lib/browser/chat-sessions-welcome-message-provider.js.map +1 -1
  51. package/lib/browser/context-file-validation-service-impl.spec.js +2 -2
  52. package/lib/browser/context-file-validation-service-impl.spec.js.map +1 -1
  53. package/lib/browser/frontend-module.d.ts.map +1 -1
  54. package/lib/browser/frontend-module.js +20 -0
  55. package/lib/browser/frontend-module.js.map +1 -1
  56. package/lib/browser/workspace-functions.d.ts +5 -6
  57. package/lib/browser/workspace-functions.d.ts.map +1 -1
  58. package/lib/browser/workspace-functions.js +24 -9
  59. package/lib/browser/workspace-functions.js.map +1 -1
  60. package/lib/browser/workspace-functions.spec.js +29 -29
  61. package/lib/browser/workspace-functions.spec.js.map +1 -1
  62. package/lib/browser/workspace-launch-provider.spec.js +3 -3
  63. package/lib/browser/workspace-launch-provider.spec.js.map +1 -1
  64. package/lib/browser/workspace-search-provider.d.ts.map +1 -1
  65. package/lib/browser/workspace-search-provider.js +28 -20
  66. package/lib/browser/workspace-search-provider.js.map +1 -1
  67. package/lib/browser/workspace-search-provider.spec.js +68 -0
  68. package/lib/browser/workspace-search-provider.spec.js.map +1 -1
  69. package/lib/browser/workspace-task-provider.spec.js +4 -4
  70. package/lib/browser/workspace-task-provider.spec.js.map +1 -1
  71. package/package.json +25 -25
  72. package/src/browser/ai-allow-all-mode-chat-banner.spec.ts +205 -0
  73. package/src/browser/ai-allow-all-mode-chat-banner.tsx +217 -0
  74. package/src/browser/ai-configuration/tools-configuration-widget.tsx +20 -13
  75. package/src/browser/ai-first-perspective-contribution.ts +48 -0
  76. package/src/browser/ai-sessions-view-contribution.ts +67 -0
  77. package/src/browser/ai-sessions-widget.tsx +151 -0
  78. package/src/browser/chat-session-item-action-contribution.ts +2 -2
  79. package/src/browser/chat-session-item.tsx +82 -27
  80. package/src/browser/chat-session-list-components.tsx +136 -0
  81. package/src/browser/{chat-sessions-welcome-message-provider.spec.ts → chat-session-list-service.spec.ts} +136 -30
  82. package/src/browser/chat-session-list-service.ts +261 -0
  83. package/src/browser/chat-session-tooltip.ts +31 -11
  84. package/src/browser/chat-sessions-welcome-message-provider.tsx +78 -268
  85. package/src/browser/context-file-validation-service-impl.spec.ts +3 -3
  86. package/src/browser/frontend-module.ts +23 -0
  87. package/src/browser/style/index.css +150 -3
  88. package/src/browser/workspace-functions.spec.ts +36 -37
  89. package/src/browser/workspace-functions.ts +8 -9
  90. package/src/browser/workspace-launch-provider.spec.ts +5 -5
  91. package/src/browser/workspace-search-provider.spec.ts +101 -0
  92. package/src/browser/workspace-search-provider.ts +34 -23
  93. package/src/browser/workspace-task-provider.spec.ts +6 -7
  94. package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts +0 -2
  95. package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts.map +0 -1
  96. package/lib/browser/chat-sessions-welcome-message-provider.spec.js.map +0 -1
@@ -15,7 +15,7 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import {
18
- ChatAgentService, ChatRequestModel, ChatService, ChatSession, ChatSessionMetadata
18
+ ChatAgentService, ChatService, ChatSession, ChatSessionMetadata, ChatSessionStatus
19
19
  } from '@theia/ai-chat';
20
20
  import { DisposableCollection, Event } from '@theia/core';
21
21
  import { buttonKeyboardProps, codicon, HoverService, isActivationKey } from '@theia/core/lib/browser';
@@ -45,6 +45,21 @@ export interface ChatSessionItemProps {
45
45
  actions?: ChatSessionItemAction[];
46
46
  /** Invoked when an action is triggered, with the action and this item's session. */
47
47
  onAction?: (action: ChatSessionItemAction, session: ChatSessionMetadata) => void;
48
+ /**
49
+ * Whether this session has child sessions. The children themselves are rendered separately by
50
+ * the parent list; this only drives the expand/collapse toggle.
51
+ */
52
+ hasChildSessions?: boolean;
53
+ /** Whether this item is a child of a parent session. */
54
+ isChildSession?: boolean;
55
+ /** Depth level for indentation. */
56
+ depth?: number;
57
+ /** Whether the parent session is expanded. */
58
+ isExpanded?: boolean;
59
+ /** Whether any descendant session needs user action; shows a warning badge on this row. */
60
+ descendantNeedsAttention?: boolean;
61
+ /** Called when the user toggles the expand/collapse state. */
62
+ onToggleExpand?: () => void;
48
63
  }
49
64
 
50
65
  /** Subscribes the component to the unread flag for one session. */
@@ -92,15 +107,19 @@ export interface ChatSessionItemComponentProps extends ChatSessionItemProps {
92
107
  }
93
108
 
94
109
  export function ChatSessionItem(props: ChatSessionItemComponentProps): React.ReactElement {
95
- const { session, isRestored, chatService, chatAgentService, hoverService, markdownRenderer, unreadState, onClick, actions, onAction, formatTimeAgo } = props;
110
+ const {
111
+ session, isRestored, chatService, chatAgentService, hoverService, markdownRenderer, unreadState,
112
+ onClick, actions, onAction, formatTimeAgo, hasChildSessions, isChildSession, depth, isExpanded,
113
+ descendantNeedsAttention, onToggleExpand
114
+ } = props;
115
+ const currentDepth = depth ?? 0;
96
116
 
97
117
  // eslint-disable-next-line no-null/no-null
98
118
  const itemRef = React.useRef<HTMLDivElement | null>(null);
99
119
  const hoverActiveRef = React.useRef(false);
100
120
 
101
121
  const timeAgo = useTimeAgo(session.saveDate, formatTimeAgo);
102
- const [isWorking, setIsWorking] = React.useState(false);
103
- const [isWaitingForInput, setIsWaitingForInput] = React.useState(false);
122
+ const [status, setStatus] = React.useState<ChatSessionStatus>();
104
123
  const [hasError, setHasError] = React.useState(session.hasError === true);
105
124
  const hasUnread = useUnreadMessages(session.sessionId, unreadState);
106
125
 
@@ -119,11 +138,8 @@ export function ChatSessionItem(props: ChatSessionItemComponentProps): React.Rea
119
138
 
120
139
  const attach = (s: ChatSession) => {
121
140
  const recompute = () => {
122
- const requests = s.model.getRequests();
123
- setIsWorking(requests.some(ChatRequestModel.isInProgress));
124
- setIsWaitingForInput(requests.some(r => r.response.isWaitingForInput));
125
- const lastReq = requests.at(-1);
126
- setHasError(lastReq?.response.isComplete === true && lastReq?.response.isError === true);
141
+ setStatus(s.model.status);
142
+ setHasError(s.model.status === 'failed');
127
143
  };
128
144
  recompute();
129
145
  s.model.onDidChange(recompute, undefined, trash);
@@ -159,13 +175,13 @@ export function ChatSessionItem(props: ChatSessionItemComponentProps): React.Rea
159
175
  // once the session is actually opened.
160
176
  const loadedSession = chatService.getSession(session.sessionId);
161
177
  const tooltip = loadedSession
162
- ? buildSessionTooltip(loadedSession, session, chatAgentService, markdownRenderer, hasUnread, isWorking, hasError, isWaitingForInput)
163
- : buildRestoredSessionTooltip(session, chatAgentService);
178
+ ? buildSessionTooltip(loadedSession, session, chatAgentService, markdownRenderer, hasUnread, descendantNeedsAttention)
179
+ : buildRestoredSessionTooltip(session, chatAgentService, descendantNeedsAttention);
164
180
  // The tooltip may hold a markdown render result; dispose it once the hover is torn down
165
181
  // (or right away if we no longer intend to show it).
166
182
  if (!hoverActiveRef.current) { tooltip.dispose(); return; }
167
183
  hoverService.requestHover({ content: tooltip.element, target, position: 'left', onHide: () => tooltip.dispose() });
168
- }, [session, chatService, chatAgentService, hoverService, markdownRenderer, hasUnread, isWorking, hasError, isWaitingForInput]);
184
+ }, [session, chatService, chatAgentService, hoverService, markdownRenderer, hasUnread, descendantNeedsAttention]);
169
185
 
170
186
  React.useEffect(() => () => { hoverActiveRef.current = false; }, []);
171
187
 
@@ -188,14 +204,24 @@ export function ChatSessionItem(props: ChatSessionItemComponentProps): React.Rea
188
204
  }
189
205
  }, [onClick]);
190
206
 
191
- // A session waiting for input is still "in progress" (ChatRequestModel.isInProgress is true),
207
+ // A session waiting for input or approval is still working (its request is in progress),
192
208
  // so the waiting state takes precedence over the generic working spinner: it signals that the
193
209
  // user, not the agent, needs to act.
210
+ const isWorking = status !== undefined && ChatSessionStatus.isInProgress(status);
211
+ const ownWaitingForInput = status !== undefined && ChatSessionStatus.requiresUserAction(status);
212
+ // A row whose (possibly nested) delegated child needs action shows the same bell + dot as a row
213
+ // that needs action itself, so the signal is consistent and stands out even when collapsed.
214
+ const isWaitingForInput = ownWaitingForInput || descendantNeedsAttention === true;
194
215
  const showWorking = isWorking && !isWaitingForInput;
195
- const showUnread = hasUnread && !isWorking && !hasError;
196
- const waitingForInputLabel = nls.localize('theia/ai/ide/waitingForInput', 'Waiting for your input');
216
+ const showUnread = hasUnread && !isWorking && !hasError && !isWaitingForInput;
217
+ const attentionLabel = ownWaitingForInput
218
+ ? (status === 'awaitingApproval'
219
+ ? nls.localize('theia/ai/ide/requiresApproval', 'Requires your approval')
220
+ : nls.localize('theia/ai/ide/waitingForInput', 'Waiting for your input'))
221
+ : nls.localize('theia/ai/ide/childInteractionNeeded', 'A delegated session needs your attention');
197
222
  const itemClasses = [
198
223
  'theia-chat-session-item',
224
+ isChildSession && 'theia-chat-session-item-child',
199
225
  isWaitingForInput && 'theia-chat-session-item-attention',
200
226
  showWorking && 'theia-chat-session-item-working',
201
227
  hasError && !isWorking && 'theia-chat-session-item-error',
@@ -213,26 +239,55 @@ export function ChatSessionItem(props: ChatSessionItemComponentProps): React.Rea
213
239
  className={itemClasses}
214
240
  role="button"
215
241
  tabIndex={0}
242
+ style={{ '--tree-indent': `${currentDepth * 12}px` } as React.CSSProperties}
216
243
  onClick={onClick}
217
244
  onKeyDown={handleKeyDown}
218
245
  onMouseEnter={handleMouseEnter}
219
246
  onMouseLeave={handleMouseLeave}
220
247
  onMouseOver={handleMouseOver}>
221
248
  <div className="theia-chat-session-item-icon-col">
222
- {isRestored ? (
223
- <span className={`${codicon('archive')} theia-chat-session-item-archive-icon`}
224
- title={nls.localize('theia/ai/ide/restoredSession', 'Restored session')} />
249
+ {hasChildSessions ? (
250
+ <span className="theia-chat-session-item-expand-icon"
251
+ onClick={e => {
252
+ e.stopPropagation();
253
+ onToggleExpand?.();
254
+ }}
255
+ onKeyDown={e => {
256
+ if (isActivationKey(e)) {
257
+ e.preventDefault();
258
+ e.stopPropagation();
259
+ onToggleExpand?.();
260
+ }
261
+ }}
262
+ onMouseDown={e => e.preventDefault()}
263
+ role="button"
264
+ tabIndex={0}
265
+ aria-expanded={isExpanded}
266
+ aria-label={isExpanded
267
+ ? nls.localize('theia/ai/ide/collapseSession', 'Collapse {0}', title)
268
+ : nls.localize('theia/ai/ide/expandSession', 'Expand {0}', title)}>
269
+ <span className={`codicon ${isExpanded ? 'codicon-chevron-down' : 'codicon-chevron-right'}`} />
270
+ </span>
225
271
  ) : (
226
- <span className={`theia-chat-session-item-agent-icon ${iconClass}`} />
227
- )}
228
- {showUnread && (
229
- <span className="theia-chat-session-item-unread-dot"
230
- aria-label={nls.localize('theia/ai/ide/tooltip/unread', 'Unread')} />
231
- )}
232
- {isWaitingForInput && (
233
- <span className="theia-chat-session-item-attention-dot"
234
- aria-label={waitingForInputLabel} />
272
+ <span className="theia-chat-session-item-expand-icon theia-chat-session-item-expand-placeholder" aria-hidden="true" />
235
273
  )}
274
+ <span className="theia-chat-session-item-status-icon">
275
+ {isRestored && !isWaitingForInput ? (
276
+ <span className={`${codicon('archive')} theia-chat-session-item-archive-icon`}
277
+ title={nls.localize('theia/ai/ide/restoredSession', 'Restored session')} />
278
+ ) : (
279
+ <span className={`theia-chat-session-item-agent-icon ${iconClass}`}
280
+ title={isWaitingForInput ? attentionLabel : undefined} />
281
+ )}
282
+ {showUnread && (
283
+ <span className="theia-chat-session-item-unread-dot"
284
+ aria-label={nls.localize('theia/ai/ide/tooltip/unread', 'Unread')} />
285
+ )}
286
+ {isWaitingForInput && (
287
+ <span className="theia-chat-session-item-attention-dot"
288
+ aria-label={attentionLabel} />
289
+ )}
290
+ </span>
236
291
  </div>
237
292
  <div className="theia-chat-session-item-content">
238
293
  <div className="theia-chat-session-item-title-line">
@@ -0,0 +1,136 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { ChatSessionMetadata } from '@theia/ai-chat';
18
+ import { buttonKeyboardProps, isActivationKey } from '@theia/core/lib/browser';
19
+ import { nls } from '@theia/core/lib/common/nls';
20
+ import * as React from '@theia/core/shared/react';
21
+
22
+ /** When both Active and Restored sections are non-empty, keep at least this many Restored slots. */
23
+ const RESTORED_MIN_RESERVATION = 5;
24
+
25
+ export interface SectionedSessions {
26
+ active: ChatSessionMetadata[];
27
+ restored: ChatSessionMetadata[];
28
+ }
29
+
30
+ /** A session row with its optional child sessions. */
31
+ export interface SessionRow {
32
+ session: ChatSessionMetadata;
33
+ isRestored: boolean;
34
+ childSessions: SessionRow[];
35
+ }
36
+
37
+ /**
38
+ * The id of a session's immediate parent for tree building. Falls back to the root session id for
39
+ * sessions persisted before immediate-parent tracking existed, so their hierarchy still renders
40
+ * (flat under the root, as before).
41
+ */
42
+ export function parentIdOf(session: ChatSessionMetadata): string | undefined {
43
+ return session.parentSessionId ?? session.rootSessionId;
44
+ }
45
+
46
+ export interface VisibleSessionSlots {
47
+ activeCount: number;
48
+ restoredCount: number;
49
+ }
50
+
51
+ /**
52
+ * Allocates the capped number of visible items between the Active and Restored sections of the
53
+ * overview. When both sections are non-empty, up to {@link RESTORED_MIN_RESERVATION} slots are
54
+ * reserved for Restored so active sessions cannot crowd it out entirely. A cap of 0 hides the
55
+ * inline list (every session stays reachable via the "Browse all chats..." link).
56
+ */
57
+ export function computeVisibleSessionSlots(activeTotal: number, restoredTotal: number, maxSessions: number): VisibleSessionSlots {
58
+ const cap = Math.max(0, maxSessions);
59
+ if (cap === 0) {
60
+ return { activeCount: 0, restoredCount: 0 };
61
+ }
62
+ if (restoredTotal === 0) {
63
+ return { activeCount: Math.min(activeTotal, cap), restoredCount: 0 };
64
+ }
65
+ if (activeTotal === 0) {
66
+ return { activeCount: 0, restoredCount: Math.min(restoredTotal, cap) };
67
+ }
68
+ const reserved = Math.min(restoredTotal, Math.min(RESTORED_MIN_RESERVATION, Math.max(1, cap - 1)));
69
+ const activeCount = Math.min(activeTotal, cap - reserved);
70
+ const restoredCount = Math.min(restoredTotal, cap - activeCount);
71
+ return { activeCount, restoredCount };
72
+ }
73
+
74
+ export interface SessionsListProps {
75
+ rows: SessionRow[];
76
+ /** Total cap on items shown; overflow surfaces via the Browse all link. Defaults to no cap. */
77
+ maxSessions?: number;
78
+ renderRow: (row: SessionRow) => React.ReactNode;
79
+ onBrowseAll?: () => void;
80
+ }
81
+
82
+ export function SessionsList({ rows, maxSessions = Number.MAX_SAFE_INTEGER, renderRow, onBrowseAll }: SessionsListProps): React.ReactElement {
83
+ // Children are rendered inline by their parent row, so only top-level rows appear in the
84
+ // sections. A child whose parent session is not in the list falls back to top-level (orphan).
85
+ const topLevelRows = rows.filter(row => {
86
+ const parentId = parentIdOf(row.session);
87
+ if (!parentId) {
88
+ return true;
89
+ }
90
+ return !rows.some(r => r.session.sessionId === parentId);
91
+ });
92
+
93
+ const activeRows = topLevelRows.filter(row => !row.isRestored);
94
+ const restoredRows = topLevelRows.filter(row => row.isRestored);
95
+
96
+ const { activeCount, restoredCount } = computeVisibleSessionSlots(activeRows.length, restoredRows.length, maxSessions);
97
+ const activeVisible = activeRows.slice(0, activeCount);
98
+ const restoredVisible = restoredRows.slice(0, restoredCount);
99
+ const hiddenCount = topLevelRows.length - activeVisible.length - restoredVisible.length;
100
+
101
+ return (
102
+ <div className="theia-WelcomeMessage-SessionsList">
103
+ {activeVisible.length > 0 && (
104
+ <div className="theia-WelcomeMessage-SessionsGroup">
105
+ <div className="theia-WelcomeMessage-SessionsHeader">
106
+ {nls.localizeByDefault('Active')}
107
+ </div>
108
+ {activeVisible.map(row => renderRow(row))}
109
+ </div>
110
+ )}
111
+ {restoredVisible.length > 0 && (
112
+ <div className="theia-WelcomeMessage-SessionsGroup">
113
+ <div className="theia-WelcomeMessage-SessionsHeader">
114
+ {nls.localize('theia/ai/ide/sectionRestored', 'Restored')}
115
+ </div>
116
+ {restoredVisible.map(row => renderRow(row))}
117
+ </div>
118
+ )}
119
+ {hiddenCount > 0 && onBrowseAll && (
120
+ <div className="theia-WelcomeMessage-SessionsFooter">
121
+ <a className="theia-WelcomeMessage-FooterLink"
122
+ {...buttonKeyboardProps(nls.localize('theia/ai/ide/browseAllChats', 'Browse all chats...'))}
123
+ onClick={onBrowseAll}
124
+ onKeyDown={e => {
125
+ if (isActivationKey(e)) {
126
+ e.preventDefault();
127
+ onBrowseAll();
128
+ }
129
+ }}>
130
+ {nls.localize('theia/ai/ide/browseAllChats', 'Browse all chats...')}
131
+ </a>
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }
@@ -20,10 +20,11 @@ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/front
20
20
  FrontendApplicationConfigProvider.set({});
21
21
  import { expect } from 'chai';
22
22
  import { Emitter } from '@theia/core';
23
- import { ChatAgentLocation, ChatService, ChatSession, ChatSessionMetadata } from '@theia/ai-chat';
23
+ import { ChatAgentLocation, ChatService, ChatSession, ChatSessionMetadata, ChatSessionStatus } from '@theia/ai-chat';
24
24
  import { ChatViewWidget } from '@theia/ai-chat-ui/lib/browser/chat-view-widget';
25
25
  import { ApplicationShell, Widget } from '@theia/core/lib/browser';
26
- import { ChatSessionsWelcomeMessageProvider, SectionedSessions, computeVisibleSessionSlots } from './chat-sessions-welcome-message-provider';
26
+ import { ChatSessionListService } from './chat-session-list-service';
27
+ import { SectionedSessions, SessionRow, computeVisibleSessionSlots } from './chat-session-list-components';
27
28
  disableJSDOM();
28
29
 
29
30
  /**
@@ -32,7 +33,7 @@ disableJSDOM();
32
33
  * unread bookkeeping; spinning up the full container would pull in unrelated
33
34
  * services and obscure the assertion.
34
35
  */
35
- class TestableProvider extends ChatSessionsWelcomeMessageProvider {
36
+ class TestableService extends ChatSessionListService {
36
37
  constructor(chatService: ChatService, shell: ApplicationShell) {
37
38
  super();
38
39
  (this as unknown as { chatService: ChatService }).chatService = chatService;
@@ -59,6 +60,7 @@ function createFakeSession(id: string): {
59
60
  id,
60
61
  isActive: false,
61
62
  model: {
63
+ status: 'idle' as ChatSessionStatus,
62
64
  getRequests: () => requests,
63
65
  onDidChange: onDidChangeEmitter.event,
64
66
  }
@@ -70,7 +72,7 @@ function createFakeSession(id: string): {
70
72
  };
71
73
  }
72
74
 
73
- describe('ChatSessionsWelcomeMessageProvider', () => {
75
+ describe('ChatSessionListService', () => {
74
76
 
75
77
  describe('unread state', () => {
76
78
  let activeSessionId: string | undefined;
@@ -112,33 +114,33 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
112
114
  });
113
115
 
114
116
  it('marks the session unread when a new request arrives and the chat view is not focused', () => {
115
- const provider = new TestableProvider(chatService, shell);
117
+ const service = new TestableService(chatService, shell);
116
118
  const { session, fire, pushRequest } = createFakeSession('s1');
117
119
  activeSessionId = 's1';
118
120
  activeWidget = otherWidget;
119
121
 
120
- provider.watch(session);
122
+ service.watch(session);
121
123
  pushRequest(true);
122
124
  fire();
123
125
 
124
- expect(provider.isUnread('s1')).to.equal(true);
126
+ expect(service.isUnread('s1')).to.equal(true);
125
127
  });
126
128
 
127
129
  it('does not mark unread when the session is active AND the chat view is focused', () => {
128
- const provider = new TestableProvider(chatService, shell);
130
+ const service = new TestableService(chatService, shell);
129
131
  const { session, fire, pushRequest } = createFakeSession('s1');
130
132
  activeSessionId = 's1';
131
133
  activeWidget = chatViewWidget;
132
134
 
133
- provider.watch(session);
135
+ service.watch(session);
134
136
  pushRequest(true);
135
137
  fire();
136
138
 
137
- expect(provider.isUnread('s1')).to.equal(false);
139
+ expect(service.isUnread('s1')).to.equal(false);
138
140
  });
139
141
 
140
142
  it('does not mark unread when focus is inside a child widget of the chat view', () => {
141
- const provider = new TestableProvider(chatService, shell);
143
+ const service = new TestableService(chatService, shell);
142
144
  const { session, fire, pushRequest } = createFakeSession('s1');
143
145
  activeSessionId = 's1';
144
146
  // Simulate focus inside a descendant (e.g. AIChatInputWidget): the shell's
@@ -147,42 +149,42 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
147
149
  activeWidget = childWidget;
148
150
  widgetForActiveElement = childWidget;
149
151
 
150
- provider.watch(session);
152
+ service.watch(session);
151
153
  pushRequest(true);
152
154
  fire();
153
155
 
154
- expect(provider.isUnread('s1')).to.equal(false);
156
+ expect(service.isUnread('s1')).to.equal(false);
155
157
  });
156
158
 
157
159
  it('marks unread when the session is not the active one, even if the chat view is focused', () => {
158
- const provider = new TestableProvider(chatService, shell);
160
+ const service = new TestableService(chatService, shell);
159
161
  const { session, fire, pushRequest } = createFakeSession('s1');
160
162
  activeSessionId = 'other';
161
163
  activeWidget = chatViewWidget;
162
164
 
163
- provider.watch(session);
165
+ service.watch(session);
164
166
  pushRequest(true);
165
167
  fire();
166
168
 
167
- expect(provider.isUnread('s1')).to.equal(true);
169
+ expect(service.isUnread('s1')).to.equal(true);
168
170
  });
169
171
 
170
172
  it('fires onUnreadChanged once when a session transitions to unread', () => {
171
- const provider = new TestableProvider(chatService, shell);
173
+ const service = new TestableService(chatService, shell);
172
174
  const { session, fire, pushRequest } = createFakeSession('s1');
173
175
  activeSessionId = undefined;
174
176
  activeWidget = otherWidget;
175
177
 
176
178
  let fired = 0;
177
- provider.onUnreadChanged(() => { fired++; });
179
+ service.onUnreadChanged(() => { fired++; });
178
180
 
179
- provider.watch(session);
181
+ service.watch(session);
180
182
  pushRequest(true);
181
183
  fire();
182
184
  pushRequest(true);
183
185
  fire();
184
186
 
185
- expect(provider.isUnread('s1')).to.equal(true);
187
+ expect(service.isUnread('s1')).to.equal(true);
186
188
  expect(fired).to.equal(1);
187
189
  });
188
190
  });
@@ -239,11 +241,11 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
239
241
 
240
242
  describe('getSections', () => {
241
243
 
242
- class SectionsTestProvider extends ChatSessionsWelcomeMessageProvider {
244
+ class SectionsTestService extends ChatSessionListService {
243
245
  constructor(sessions: ChatSession[], persistedSessions: ChatSessionMetadata[]) {
244
246
  super();
245
247
  (this as unknown as { chatService: ChatService }).chatService = { getSessions: () => sessions } as unknown as ChatService;
246
- this._persistedSessions = persistedSessions;
248
+ (this as unknown as { _persistedSessions: ChatSessionMetadata[] })._persistedSessions = persistedSessions;
247
249
  }
248
250
  sections(): SectionedSessions {
249
251
  return this.getSections();
@@ -254,9 +256,8 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
254
256
  lastInteraction?: Date;
255
257
  pinnedAgentId?: string;
256
258
  location?: ChatAgentLocation;
257
- lastRequest?: { isComplete: boolean; isError: boolean };
259
+ status?: ChatSessionStatus;
258
260
  }): ChatSession {
259
- const requests = opts?.lastRequest ? [{ response: opts.lastRequest }] : [];
260
261
  return {
261
262
  id,
262
263
  title,
@@ -265,7 +266,8 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
265
266
  pinnedAgent: opts?.pinnedAgentId ? { id: opts.pinnedAgentId } : undefined,
266
267
  model: {
267
268
  location: opts?.location ?? ChatAgentLocation.Panel,
268
- getRequests: () => requests
269
+ status: opts?.status ?? 'idle',
270
+ getRequests: () => []
269
271
  }
270
272
  } as unknown as ChatSession;
271
273
  }
@@ -275,7 +277,7 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
275
277
  }
276
278
 
277
279
  function getSections(sessions: ChatSession[], persistedSessions: ChatSessionMetadata[]): SectionedSessions {
278
- return new SectionsTestProvider(sessions, persistedSessions).sections();
280
+ return new SectionsTestService(sessions, persistedSessions).sections();
279
281
  }
280
282
 
281
283
  it('excludes active sessions without a title', () => {
@@ -304,11 +306,11 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
304
306
  expect(sections.restored.map(s => s.sessionId)).to.deep.equal(['b']);
305
307
  });
306
308
 
307
- it('flags an active session whose last request completed with an error', () => {
309
+ it('flags an active session whose status is failed', () => {
308
310
  const sections = getSections([
309
- makeSession('ok', 'Ok', { lastRequest: { isComplete: true, isError: false } }),
310
- makeSession('err', 'Err', { lastRequest: { isComplete: true, isError: true } }),
311
- makeSession('running', 'Running', { lastRequest: { isComplete: false, isError: false } })
311
+ makeSession('ok', 'Ok', { status: 'idle' }),
312
+ makeSession('err', 'Err', { status: 'failed' }),
313
+ makeSession('running', 'Running', { status: 'running' })
312
314
  ], []);
313
315
  const byId = new Map(sections.active.map(s => [s.sessionId, s.hasError]));
314
316
  expect(byId.get('ok')).to.equal(false);
@@ -324,4 +326,108 @@ describe('ChatSessionsWelcomeMessageProvider', () => {
324
326
  expect(sections.active[0].location).to.equal(ChatAgentLocation.Panel);
325
327
  });
326
328
  });
329
+
330
+ describe('session hierarchy', () => {
331
+
332
+ class HierarchyTestService extends ChatSessionListService {
333
+ constructor(chatService: ChatService, persistedSessions: ChatSessionMetadata[] = []) {
334
+ super();
335
+ (this as unknown as { chatService: ChatService }).chatService = chatService;
336
+ (this as unknown as { _persistedSessions: ChatSessionMetadata[] })._persistedSessions = persistedSessions;
337
+ }
338
+ rows(sections: SectionedSessions): SessionRow[] {
339
+ return this.buildRows(sections);
340
+ }
341
+ subtreeRequiresAction(row: SessionRow): boolean {
342
+ return this.descendantRequiresAction(row);
343
+ }
344
+ expand(session: ChatSession): void {
345
+ this.expandAncestors(session);
346
+ }
347
+ get expanded(): Set<string> {
348
+ return this.expandedRoots;
349
+ }
350
+ }
351
+
352
+ function meta(sessionId: string, opts?: { rootSessionId?: string; parentSessionId?: string }): ChatSessionMetadata {
353
+ return {
354
+ sessionId,
355
+ title: sessionId,
356
+ saveDate: 0,
357
+ location: ChatAgentLocation.Panel,
358
+ rootSessionId: opts?.rootSessionId,
359
+ parentSessionId: opts?.parentSessionId
360
+ };
361
+ }
362
+
363
+ function loadedSession(id: string, status: ChatSessionStatus, opts?: { rootSessionId?: string; parentSessionId?: string }): ChatSession {
364
+ return {
365
+ id,
366
+ rootSessionId: opts?.rootSessionId,
367
+ parentSessionId: opts?.parentSessionId,
368
+ model: { status }
369
+ } as unknown as ChatSession;
370
+ }
371
+
372
+ function findRow(rows: SessionRow[], sessionId: string): SessionRow {
373
+ const row = rows.find(r => r.session.sessionId === sessionId);
374
+ expect(row, `row ${sessionId}`).to.not.be.undefined;
375
+ return row!;
376
+ }
377
+
378
+ it('nests a restored A -> B hierarchy with an active C under the root', () => {
379
+ const service = new HierarchyTestService({ getSession: () => undefined } as unknown as ChatService);
380
+ const sections: SectionedSessions = {
381
+ active: [meta('C', { rootSessionId: 'A', parentSessionId: 'B' })],
382
+ restored: [meta('A'), meta('B', { rootSessionId: 'A', parentSessionId: 'A' })]
383
+ };
384
+
385
+ const rows = service.rows(sections);
386
+ const rowA = findRow(rows, 'A');
387
+ expect(rowA.isRestored).to.equal(true);
388
+ expect(rowA.childSessions.map(c => c.session.sessionId)).to.deep.equal(['B']);
389
+ const rowB = rowA.childSessions[0];
390
+ expect(rowB.isRestored).to.equal(true);
391
+ expect(rowB.childSessions.map(c => c.session.sessionId)).to.deep.equal(['C']);
392
+ expect(rowB.childSessions[0].isRestored).to.equal(false);
393
+ });
394
+
395
+ it('propagates a deep descendant needing action up the tree', () => {
396
+ // Only the leaf C awaits input; both B and the root A must report a descendant needing action.
397
+ const chatService = {
398
+ getSession: (id: string) => id === 'C' ? loadedSession('C', 'awaitingInput') : loadedSession(id, 'idle')
399
+ } as unknown as ChatService;
400
+ const service = new HierarchyTestService(chatService);
401
+ const sections: SectionedSessions = {
402
+ active: [meta('C', { rootSessionId: 'A', parentSessionId: 'B' })],
403
+ restored: [meta('A'), meta('B', { rootSessionId: 'A', parentSessionId: 'A' })]
404
+ };
405
+
406
+ const rows = service.rows(sections);
407
+ const rowA = findRow(rows, 'A');
408
+ const rowB = rowA.childSessions[0];
409
+ const rowC = rowB.childSessions[0];
410
+ expect(service.subtreeRequiresAction(rowA)).to.equal(true);
411
+ expect(service.subtreeRequiresAction(rowB)).to.equal(true);
412
+ expect(service.subtreeRequiresAction(rowC)).to.equal(false);
413
+ });
414
+
415
+ it('expands every ancestor of a session that needs action, falling back to persisted-only ancestors', () => {
416
+ // C is loaded and awaiting input; its ancestors B and A exist only in the persisted index
417
+ // (e.g. C was opened directly from "Browse all chats"). The walk must reach the root A, not
418
+ // stop at the first ancestor `getSession` cannot resolve.
419
+ const c = loadedSession('C', 'awaitingInput', { rootSessionId: 'A', parentSessionId: 'B' });
420
+ const chatService = {
421
+ getSession: (id: string) => id === 'C' ? c : undefined
422
+ } as unknown as ChatService;
423
+ const service = new HierarchyTestService(chatService, [
424
+ meta('A'),
425
+ meta('B', { rootSessionId: 'A', parentSessionId: 'A' })
426
+ ]);
427
+
428
+ service.expand(c);
429
+
430
+ expect([...service.expanded].sort()).to.deep.equal(['A', 'B']);
431
+ });
432
+ });
327
433
  });