Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession {
private readonly _changes: ReturnType<typeof observableValue<readonly IChatSessionFileChange[]>>;
readonly changes: IObservable<readonly IChatSessionFileChange[]>;

readonly isArchived: IObservable<boolean> = observableValue(this, false);
private readonly _isArchived = observableValue(this, false);
readonly isArchived: IObservable<boolean> = this._isArchived;
readonly isRead: IObservable<boolean> = observableValue(this, true);
readonly lastTurnEnd: IObservable<Date | undefined> = observableValue(this, undefined);
readonly gitHubInfo: IObservable<IGitHubInfo | undefined> = observableValue(this, undefined);
Expand Down Expand Up @@ -371,6 +372,10 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession {
this._status.set(status, undefined);
}

setArchived(archived: boolean): void {
this._isArchived.set(archived, undefined);
}

setMode(mode: IChatMode | undefined): void {
if (this._mode?.id !== mode?.id) {
this._mode = mode;
Expand Down Expand Up @@ -450,7 +455,8 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession

readonly loading: IObservable<boolean> = observableValue(this, false);

readonly isArchived: IObservable<boolean> = observableValue(this, false);
private readonly _isArchived = observableValue(this, false);
readonly isArchived: IObservable<boolean> = this._isArchived;
readonly isRead: IObservable<boolean> = observableValue(this, true);
readonly description: IObservable<IMarkdownString | undefined> = constObservable(undefined);
readonly lastTurnEnd: IObservable<Date | undefined> = constObservable(undefined);
Expand Down Expand Up @@ -548,6 +554,10 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession
this._status.set(status, undefined);
}

setArchived(archived: boolean): void {
this._isArchived.set(archived, undefined);
}

setMode(_mode: IChatMode | undefined): void {
// Intentionally a no-op: remote sessions do not support client-side mode selection.
}
Expand Down Expand Up @@ -1186,14 +1196,28 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
return;
}

// Temp session that hasn't been committed — remove it directly
this._cleanupTempSession(sessionId);
// Temp session that hasn't been committed — archive it in-place
// so the user can still review whatever content was produced.
const chatSession = this._findChatSession(sessionId);
if (chatSession && (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession)) {
chatSession.setArchived(true);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] });
return;
}
}

async unarchiveSession(sessionId: string): Promise<void> {
const agentSession = this._findAgentSession(sessionId);
if (agentSession) {
agentSession.setArchived(false);
return;
}

// Temp session that hasn't been committed — unarchive it in-place
const chatSession = this._findChatSession(sessionId);
if (chatSession && (chatSession instanceof CopilotCLISession || chatSession instanceof RemoteNewSession)) {
chatSession.setArchived(false);
this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(chatSession)] });
}
}

Expand Down Expand Up @@ -1382,11 +1406,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions
}

// Send request
this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, {
userSelectedModelId: sendOptions.userSelectedModelId,
modeInfo: sendOptions.modeInfo,
agentIdSilent: sendOptions.agentIdSilent,
});
this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id}`);
const result = await this.chatService.sendRequest(session.resource, query, sendOptions);
if (result.kind === 'rejected') {
throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common
import { IChatAgentData } from '../../../../../workbench/contrib/chat/common/participants/chatAgents.js';
import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js';
import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js';
import { ISessionWorkspace } from '../../../../services/sessions/common/session.js';
import { ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js';
import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js';
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';

Expand Down Expand Up @@ -716,12 +716,12 @@ suite('CopilotChatSessionsProvider', () => {
await provider.deleteSession(sessionId);
assert.strictEqual(provider.getSessions().length, 0, 'session should be removed after deleteSession');

// Clean up in-flight request so _sendFirstChat resolves quickly
// Cancellation after delete should resolve cleanly
cancelRequest();
await sendPromise.catch(() => { /* expected to reject */ });
await assert.doesNotReject(sendPromise);
});

test('archiveSession removes a temp session that is awaiting commit', async () => {
test('archiveSession archives a temp session that is awaiting commit', async () => {
const { provider, cancelRequest } = makeInFlightProvider();

const newSession = provider.createNewSession(workspace);
Expand All @@ -734,10 +734,44 @@ suite('CopilotChatSessionsProvider', () => {
assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');

await provider.archiveSession(sessionId);
assert.strictEqual(provider.getSessions().length, 0, 'session should be removed after archiveSession');
assert.strictEqual(provider.getSessions().length, 1, 'session should still be in the list after archiveSession');
assert.strictEqual(provider.getSessions()[0].isArchived.get(), true, 'session should be archived');

// Cancellation after archive should resolve cleanly
cancelRequest();
await sendPromise.catch(() => { /* expected to reject */ });
await assert.doesNotReject(sendPromise);

// Clean up to avoid leaked disposable
await provider.deleteSession(sessionId);
});

test('archiveSession archives a stopped session that was never committed', async () => {
const { provider, cancelRequest } = makeInFlightProvider();

const newSession = provider.createNewSession(workspace);
const sessionId = newSession.sessionId;

const added = waitForSessionAdded(provider);
const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });
await added;

// Stop before commit arrives — session should stay as completed
cancelRequest();
await sendPromise;

assert.strictEqual(provider.getSessions().length, 1, 'stopped session should remain in the list');
assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed, 'session should be completed');

await provider.archiveSession(sessionId);
assert.strictEqual(provider.getSessions().length, 1, 'session should still be in the list after archiving');
assert.strictEqual(provider.getSessions()[0].isArchived.get(), true, 'session should be archived');

// Unarchive should also work
await provider.unarchiveSession(sessionId);
assert.strictEqual(provider.getSessions()[0].isArchived.get(), false, 'session should be unarchived');

// Clean up to avoid leaked disposable
await provider.deleteSession(sessionId);
});

/**
Expand Down
Loading