Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken } from '../../../../base/common/cancellation.js';
7
import { Codicon } from '../../../../base/common/codicons.js';
8
import { Emitter } from '../../../../base/common/event.js';
9
import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
10
import { Schemas } from '../../../../base/common/network.js';
11
import { isEqual } from '../../../../base/common/resources.js';
12
import { ThemeIcon } from '../../../../base/common/themables.js';
13
import { URI } from '../../../../base/common/uri.js';
14
import * as nls from '../../../../nls.js';
15
import { ConfirmResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
16
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
17
import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';
18
import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntypedEditorInput } from '../../../common/editor.js';
19
import { EditorInput, IEditorCloseHandler } from '../../../common/editor/editorInput.js';
20
import { IChatEditingSession, ModifiedFileEntryState } from '../common/chatEditingService.js';
21
import { IChatModel } from '../common/chatModel.js';
22
import { IChatService } from '../common/chatService.js';
23
import { ChatAgentLocation } from '../common/constants.js';
24
import { IClearEditingSessionConfirmationOptions } from './actions/chatActions.js';
25
import type { IChatEditorOptions } from './chatEditor.js';
26
27
const ChatEditorIcon = registerIcon('chat-editor-label-icon', Codicon.chatSparkle, nls.localize('chatEditorLabelIcon', 'Icon of the chat editor label.'));
28
29
export class ChatEditorInput extends EditorInput implements IEditorCloseHandler {
30
static readonly countsInUse = new Set<number>();
31
32
static readonly TypeID: string = 'workbench.input.chatSession';
33
static readonly EditorID: string = 'workbench.editor.chatSession';
34
35
private readonly inputCount: number;
36
public sessionId: string | undefined;
37
private hasCustomTitle: boolean = false;
38
39
private model: IChatModel | undefined;
40
41
static getNewEditorUri(): URI {
42
const handle = Math.floor(Math.random() * 1e9);
43
return ChatEditorUri.generate(handle);
44
}
45
46
static getNextCount(): number {
47
let count = 0;
48
while (ChatEditorInput.countsInUse.has(count)) {
49
count++;
50
}
51
52
return count;
53
}
54
55
constructor(
56
readonly resource: URI,
57
readonly options: IChatEditorOptions,
58
@IChatService private readonly chatService: IChatService,
59
@IDialogService private readonly dialogService: IDialogService,
60
) {
61
super();
62
63
if (resource.scheme === Schemas.vscodeChatEditor) {
64
const parsed = ChatEditorUri.parse(resource);
65
if (!parsed || typeof parsed !== 'number') {
66
throw new Error('Invalid chat URI');
67
}
68
} else if (resource.scheme !== Schemas.vscodeChatSession) {
69
throw new Error('Invalid chat URI');
70
}
71
72
this.sessionId = (options.target && 'sessionId' in options.target) ?
73
options.target.sessionId :
74
undefined;
75
76
// Check if we already have a custom title for this session
77
const hasExistingCustomTitle = this.sessionId && (
78
this.chatService.getSession(this.sessionId)?.title ||
79
this.chatService.getPersistedSessionTitle(this.sessionId)?.trim()
80
);
81
82
this.hasCustomTitle = Boolean(hasExistingCustomTitle);
83
84
// Only allocate a count if we don't already have a custom title
85
if (!this.hasCustomTitle) {
86
this.inputCount = ChatEditorInput.getNextCount();
87
ChatEditorInput.countsInUse.add(this.inputCount);
88
this._register(toDisposable(() => {
89
// Only remove if we haven't already removed it due to custom title
90
if (!this.hasCustomTitle) {
91
ChatEditorInput.countsInUse.delete(this.inputCount);
92
}
93
}));
94
} else {
95
this.inputCount = 0; // Not used when we have a custom title
96
}
97
}
98
99
override closeHandler = this;
100
101
showConfirm(): boolean {
102
return this.model?.editingSession ? shouldShowClearEditingSessionConfirmation(this.model.editingSession) : false;
103
}
104
105
async confirm(editors: ReadonlyArray<IEditorIdentifier>): Promise<ConfirmResult> {
106
if (!this.model?.editingSession) {
107
return ConfirmResult.SAVE;
108
}
109
110
const titleOverride = nls.localize('chatEditorConfirmTitle', "Close Chat Editor");
111
const messageOverride = nls.localize('chat.startEditing.confirmation.pending.message.default', "Closing the chat editor will end your current edit session.");
112
const result = await showClearEditingSessionConfirmation(this.model.editingSession, this.dialogService, { titleOverride, messageOverride });
113
return result ? ConfirmResult.SAVE : ConfirmResult.CANCEL;
114
}
115
116
override get editorId(): string | undefined {
117
return ChatEditorInput.EditorID;
118
}
119
120
override get capabilities(): EditorInputCapabilities {
121
return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.CanDropIntoEditor;
122
}
123
124
override matches(otherInput: EditorInput | IUntypedEditorInput): boolean {
125
if (!(otherInput instanceof ChatEditorInput)) {
126
return false;
127
}
128
129
if (this.resource.scheme === Schemas.vscodeChatSession) {
130
return isEqual(this.resource, otherInput.resource);
131
}
132
133
if (this.resource.scheme === Schemas.vscodeChatEditor && otherInput.resource.scheme === Schemas.vscodeChatEditor) {
134
return this.sessionId === otherInput.sessionId;
135
}
136
137
return false;
138
}
139
140
override get typeId(): string {
141
return ChatEditorInput.TypeID;
142
}
143
144
override getName(): string {
145
// If we have a resolved model, use its title
146
if (this.model?.title) {
147
return this.model.title;
148
}
149
150
// If we have a sessionId but no resolved model, try to get the title from persisted sessions
151
if (this.sessionId) {
152
// First try the active session registry
153
const existingSession = this.chatService.getSession(this.sessionId);
154
if (existingSession?.title) {
155
return existingSession.title;
156
}
157
158
// If not in active registry, try persisted session data
159
const persistedTitle = this.chatService.getPersistedSessionTitle(this.sessionId);
160
if (persistedTitle && persistedTitle.trim()) { // Only use non-empty persisted titles
161
return persistedTitle;
162
}
163
}
164
165
// Fall back to default naming pattern
166
const defaultName = nls.localize('chatEditorName', "Chat") + (this.inputCount > 0 ? ` ${this.inputCount + 1}` : '');
167
return defaultName;
168
}
169
170
override getIcon(): ThemeIcon {
171
return ChatEditorIcon;
172
}
173
174
override async resolve(): Promise<ChatEditorModel | null> {
175
if (this.resource.scheme === Schemas.vscodeChatSession) {
176
this.model = await this.chatService.loadSessionForResource(this.resource, ChatAgentLocation.Editor, CancellationToken.None);
177
} else if (typeof this.sessionId === 'string') {
178
this.model = await this.chatService.getOrRestoreSession(this.sessionId)
179
?? this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None);
180
} else if (!this.options.target) {
181
this.model = this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None);
182
} else if ('data' in this.options.target) {
183
this.model = this.chatService.loadSessionFromContent(this.options.target.data);
184
}
185
186
if (!this.model || this.isDisposed()) {
187
return null;
188
}
189
190
this.sessionId = this.model.sessionId;
191
this._register(this.model.onDidChange((e) => {
192
// When a custom title is set, we no longer need the numeric count
193
if (e && e.kind === 'setCustomTitle' && !this.hasCustomTitle) {
194
this.hasCustomTitle = true;
195
ChatEditorInput.countsInUse.delete(this.inputCount);
196
}
197
this._onDidChangeLabel.fire();
198
}));
199
200
return this._register(new ChatEditorModel(this.model));
201
}
202
203
override dispose(): void {
204
super.dispose();
205
if (this.sessionId) {
206
this.chatService.clearSession(this.sessionId);
207
}
208
}
209
}
210
211
export class ChatEditorModel extends Disposable {
212
private _onWillDispose = this._register(new Emitter<void>());
213
readonly onWillDispose = this._onWillDispose.event;
214
215
private _isDisposed = false;
216
private _isResolved = false;
217
218
constructor(
219
readonly model: IChatModel
220
) { super(); }
221
222
async resolve(): Promise<void> {
223
this._isResolved = true;
224
}
225
226
isResolved(): boolean {
227
return this._isResolved;
228
}
229
230
isDisposed(): boolean {
231
return this._isDisposed;
232
}
233
234
override dispose(): void {
235
super.dispose();
236
this._isDisposed = true;
237
}
238
}
239
240
241
export namespace ChatEditorUri {
242
243
export const scheme = Schemas.vscodeChatEditor;
244
245
export function generate(handle: number): URI {
246
return URI.from({ scheme, path: `chat-${handle}` });
247
}
248
249
export function parse(resource: URI): number | undefined {
250
if (resource.scheme !== scheme) {
251
return undefined;
252
}
253
254
const match = resource.path.match(/chat-(\d+)/);
255
const handleStr = match?.[1];
256
if (typeof handleStr !== 'string') {
257
return undefined;
258
}
259
260
const handle = parseInt(handleStr);
261
if (isNaN(handle)) {
262
return undefined;
263
}
264
265
return handle;
266
}
267
}
268
269
interface ISerializedChatEditorInput {
270
options: IChatEditorOptions;
271
sessionId: string;
272
resource: URI;
273
}
274
275
export class ChatEditorInputSerializer implements IEditorSerializer {
276
canSerialize(input: EditorInput): input is ChatEditorInput & { readonly sessionId: string } {
277
return input instanceof ChatEditorInput && typeof input.sessionId === 'string';
278
}
279
280
serialize(input: EditorInput): string | undefined {
281
if (!this.canSerialize(input)) {
282
return undefined;
283
}
284
285
const obj: ISerializedChatEditorInput = {
286
options: input.options,
287
sessionId: input.sessionId,
288
resource: input.resource
289
};
290
return JSON.stringify(obj);
291
}
292
293
deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined {
294
try {
295
const parsed: ISerializedChatEditorInput = JSON.parse(serializedEditor);
296
const resource = URI.revive(parsed.resource);
297
return instantiationService.createInstance(ChatEditorInput, resource, { ...parsed.options, target: { sessionId: parsed.sessionId } });
298
} catch (err) {
299
return undefined;
300
}
301
}
302
}
303
304
export async function showClearEditingSessionConfirmation(editingSession: IChatEditingSession, dialogService: IDialogService, options?: IClearEditingSessionConfirmationOptions): Promise<boolean> {
305
const defaultPhrase = nls.localize('chat.startEditing.confirmation.pending.message.default1', "Starting a new chat will end your current edit session.");
306
const defaultTitle = nls.localize('chat.startEditing.confirmation.title', "Start new chat?");
307
const phrase = options?.messageOverride ?? defaultPhrase;
308
const title = options?.titleOverride ?? defaultTitle;
309
310
const currentEdits = editingSession.entries.get();
311
const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified);
312
313
const { result } = await dialogService.prompt({
314
title,
315
message: phrase + ' ' + nls.localize('chat.startEditing.confirmation.pending.message.2', "Do you want to keep pending edits to {0} files?", undecidedEdits.length),
316
type: 'info',
317
cancelButton: true,
318
buttons: [
319
{
320
label: nls.localize('chat.startEditing.confirmation.acceptEdits', "Keep & Continue"),
321
run: async () => {
322
await editingSession.accept();
323
return true;
324
}
325
},
326
{
327
label: nls.localize('chat.startEditing.confirmation.discardEdits', "Undo & Continue"),
328
run: async () => {
329
await editingSession.reject();
330
return true;
331
}
332
}
333
],
334
});
335
336
return Boolean(result);
337
}
338
339
export function shouldShowClearEditingSessionConfirmation(editingSession: IChatEditingSession): boolean {
340
const currentEdits = editingSession.entries.get();
341
const currentEditCount = currentEdits.length;
342
343
if (currentEditCount) {
344
const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified);
345
return !!undecidedEdits.length;
346
}
347
348
return false;
349
}
350
351