@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.
- package/lib/browser/ai-allow-all-mode-chat-banner.d.ts +68 -0
- package/lib/browser/ai-allow-all-mode-chat-banner.d.ts.map +1 -0
- package/lib/browser/ai-allow-all-mode-chat-banner.js +184 -0
- package/lib/browser/ai-allow-all-mode-chat-banner.js.map +1 -0
- package/lib/browser/ai-allow-all-mode-chat-banner.spec.d.ts +2 -0
- package/lib/browser/ai-allow-all-mode-chat-banner.spec.d.ts.map +1 -0
- package/lib/browser/ai-allow-all-mode-chat-banner.spec.js +170 -0
- package/lib/browser/ai-allow-all-mode-chat-banner.spec.js.map +1 -0
- package/lib/browser/ai-configuration/tools-configuration-widget.d.ts +3 -4
- package/lib/browser/ai-configuration/tools-configuration-widget.d.ts.map +1 -1
- package/lib/browser/ai-configuration/tools-configuration-widget.js +17 -10
- package/lib/browser/ai-configuration/tools-configuration-widget.js.map +1 -1
- package/lib/browser/ai-first-perspective-contribution.d.ts +6 -0
- package/lib/browser/ai-first-perspective-contribution.d.ts.map +1 -0
- package/lib/browser/ai-first-perspective-contribution.js +50 -0
- package/lib/browser/ai-first-perspective-contribution.js.map +1 -0
- package/lib/browser/ai-sessions-view-contribution.d.ts +13 -0
- package/lib/browser/ai-sessions-view-contribution.d.ts.map +1 -0
- package/lib/browser/ai-sessions-view-contribution.js +69 -0
- package/lib/browser/ai-sessions-view-contribution.js.map +1 -0
- package/lib/browser/ai-sessions-widget.d.ts +27 -0
- package/lib/browser/ai-sessions-widget.d.ts.map +1 -0
- package/lib/browser/ai-sessions-widget.js +131 -0
- package/lib/browser/ai-sessions-widget.js.map +1 -0
- package/lib/browser/chat-session-item-action-contribution.js +2 -2
- package/lib/browser/chat-session-item-action-contribution.js.map +1 -1
- package/lib/browser/chat-session-item.d.ts +15 -0
- package/lib/browser/chat-session-item.d.ts.map +1 -1
- package/lib/browser/chat-session-item.js +39 -18
- package/lib/browser/chat-session-item.js.map +1 -1
- package/lib/browser/chat-session-list-components.d.ts +38 -0
- package/lib/browser/chat-session-list-components.d.ts.map +1 -0
- package/lib/browser/chat-session-list-components.js +87 -0
- package/lib/browser/chat-session-list-components.js.map +1 -0
- package/lib/browser/chat-session-list-service.d.ts +34 -0
- package/lib/browser/chat-session-list-service.d.ts.map +1 -0
- package/lib/browser/chat-session-list-service.js +251 -0
- package/lib/browser/chat-session-list-service.js.map +1 -0
- package/lib/browser/chat-session-list-service.spec.d.ts +2 -0
- package/lib/browser/chat-session-list-service.spec.d.ts.map +1 -0
- package/lib/browser/{chat-sessions-welcome-message-provider.spec.js → chat-session-list-service.spec.js} +133 -39
- package/lib/browser/chat-session-list-service.spec.js.map +1 -0
- package/lib/browser/chat-session-tooltip.d.ts +2 -2
- package/lib/browser/chat-session-tooltip.d.ts.map +1 -1
- package/lib/browser/chat-session-tooltip.js +33 -10
- package/lib/browser/chat-session-tooltip.js.map +1 -1
- package/lib/browser/chat-sessions-welcome-message-provider.d.ts +9 -35
- package/lib/browser/chat-sessions-welcome-message-provider.d.ts.map +1 -1
- package/lib/browser/chat-sessions-welcome-message-provider.js +51 -205
- package/lib/browser/chat-sessions-welcome-message-provider.js.map +1 -1
- package/lib/browser/context-file-validation-service-impl.spec.js +2 -2
- package/lib/browser/context-file-validation-service-impl.spec.js.map +1 -1
- package/lib/browser/frontend-module.d.ts.map +1 -1
- package/lib/browser/frontend-module.js +20 -0
- package/lib/browser/frontend-module.js.map +1 -1
- package/lib/browser/workspace-functions.d.ts +5 -6
- package/lib/browser/workspace-functions.d.ts.map +1 -1
- package/lib/browser/workspace-functions.js +24 -9
- package/lib/browser/workspace-functions.js.map +1 -1
- package/lib/browser/workspace-functions.spec.js +29 -29
- package/lib/browser/workspace-functions.spec.js.map +1 -1
- package/lib/browser/workspace-launch-provider.spec.js +3 -3
- package/lib/browser/workspace-launch-provider.spec.js.map +1 -1
- package/lib/browser/workspace-search-provider.d.ts.map +1 -1
- package/lib/browser/workspace-search-provider.js +28 -20
- package/lib/browser/workspace-search-provider.js.map +1 -1
- package/lib/browser/workspace-search-provider.spec.js +68 -0
- package/lib/browser/workspace-search-provider.spec.js.map +1 -1
- package/lib/browser/workspace-task-provider.spec.js +4 -4
- package/lib/browser/workspace-task-provider.spec.js.map +1 -1
- package/package.json +25 -25
- package/src/browser/ai-allow-all-mode-chat-banner.spec.ts +205 -0
- package/src/browser/ai-allow-all-mode-chat-banner.tsx +217 -0
- package/src/browser/ai-configuration/tools-configuration-widget.tsx +20 -13
- package/src/browser/ai-first-perspective-contribution.ts +48 -0
- package/src/browser/ai-sessions-view-contribution.ts +67 -0
- package/src/browser/ai-sessions-widget.tsx +151 -0
- package/src/browser/chat-session-item-action-contribution.ts +2 -2
- package/src/browser/chat-session-item.tsx +82 -27
- package/src/browser/chat-session-list-components.tsx +136 -0
- package/src/browser/{chat-sessions-welcome-message-provider.spec.ts → chat-session-list-service.spec.ts} +136 -30
- package/src/browser/chat-session-list-service.ts +261 -0
- package/src/browser/chat-session-tooltip.ts +31 -11
- package/src/browser/chat-sessions-welcome-message-provider.tsx +78 -268
- package/src/browser/context-file-validation-service-impl.spec.ts +3 -3
- package/src/browser/frontend-module.ts +23 -0
- package/src/browser/style/index.css +150 -3
- package/src/browser/workspace-functions.spec.ts +36 -37
- package/src/browser/workspace-functions.ts +8 -9
- package/src/browser/workspace-launch-provider.spec.ts +5 -5
- package/src/browser/workspace-search-provider.spec.ts +101 -0
- package/src/browser/workspace-search-provider.ts +34 -23
- package/src/browser/workspace-task-provider.spec.ts +6 -7
- package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts +0 -2
- package/lib/browser/chat-sessions-welcome-message-provider.spec.d.ts.map +0 -1
- 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,
|
|
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 {
|
|
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 [
|
|
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
|
-
|
|
123
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
{
|
|
223
|
-
<span className=
|
|
224
|
-
|
|
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=
|
|
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 {
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
122
|
+
service.watch(session);
|
|
121
123
|
pushRequest(true);
|
|
122
124
|
fire();
|
|
123
125
|
|
|
124
|
-
expect(
|
|
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
|
|
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
|
-
|
|
135
|
+
service.watch(session);
|
|
134
136
|
pushRequest(true);
|
|
135
137
|
fire();
|
|
136
138
|
|
|
137
|
-
expect(
|
|
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
|
|
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
|
-
|
|
152
|
+
service.watch(session);
|
|
151
153
|
pushRequest(true);
|
|
152
154
|
fire();
|
|
153
155
|
|
|
154
|
-
expect(
|
|
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
|
|
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
|
-
|
|
165
|
+
service.watch(session);
|
|
164
166
|
pushRequest(true);
|
|
165
167
|
fire();
|
|
166
168
|
|
|
167
|
-
expect(
|
|
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
|
|
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
|
-
|
|
179
|
+
service.onUnreadChanged(() => { fired++; });
|
|
178
180
|
|
|
179
|
-
|
|
181
|
+
service.watch(session);
|
|
180
182
|
pushRequest(true);
|
|
181
183
|
fire();
|
|
182
184
|
pushRequest(true);
|
|
183
185
|
fire();
|
|
184
186
|
|
|
185
|
-
expect(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
309
|
+
it('flags an active session whose status is failed', () => {
|
|
308
310
|
const sections = getSections([
|
|
309
|
-
makeSession('ok', 'Ok', {
|
|
310
|
-
makeSession('err', 'Err', {
|
|
311
|
-
makeSession('running', 'Running', {
|
|
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
|
});
|