mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
some kinda funny ui
This commit is contained in:
0
.entire/logs/entire.log
Normal file
0
.entire/logs/entire.log
Normal file
@@ -57,7 +57,13 @@ export interface ICopilotAttachment {
|
||||
|
||||
export interface ICopilotSessionMetadata {
|
||||
readonly sessionId: string;
|
||||
readonly summary?: string;
|
||||
readonly startTime?: string;
|
||||
readonly modifiedTime?: string;
|
||||
readonly isRemote?: boolean;
|
||||
readonly workspacePath?: string;
|
||||
readonly repository?: string;
|
||||
readonly branch?: string;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -185,9 +185,15 @@ class CopilotSdkHost extends Disposable implements ICopilotSdkService {
|
||||
async listSessions(): Promise<ICopilotSessionMetadata[]> {
|
||||
const client = await this._ensureClient();
|
||||
const sessions = await client.listSessions();
|
||||
return sessions.map((s: { sessionId: string; workspacePath?: string }) => ({
|
||||
return sessions.map((s: { sessionId: string; summary?: string; startTime?: Date; modifiedTime?: Date; isRemote?: boolean; context?: { cwd?: string; repository?: string; branch?: string } }) => ({
|
||||
sessionId: s.sessionId,
|
||||
workspacePath: s.workspacePath,
|
||||
summary: s.summary,
|
||||
startTime: s.startTime?.toISOString(),
|
||||
modifiedTime: s.modifiedTime?.toISOString(),
|
||||
isRemote: s.isRemote,
|
||||
workspacePath: s.context?.cwd,
|
||||
repository: s.context?.repository,
|
||||
branch: s.context?.branch,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -465,67 +465,71 @@ npm install @github/copilot-sdk
|
||||
|
||||
## 6. Open Questions
|
||||
|
||||
1. **CLI binary packaging:** Does the `@github/copilot-sdk` npm package bundle the CLI, or do we need a separate `@github/copilot-cli` package? Need to check if the SDK can auto-download the CLI.
|
||||
1. ~~**CLI binary packaging:** Does the `@github/copilot-sdk` npm package bundle the CLI?~~ **RESOLVED:** The SDK depends on `@github/copilot` which includes platform-specific native binaries via optional deps (`@github/copilot-darwin-arm64`, etc.). We resolve the native binary at runtime via `import.meta.resolve()`. The SDK's default `index.js` entry spawns an Electron-based JS loader that crashes in our utility process context.
|
||||
|
||||
2. **SDK in utility process:** The SDK is designed for Node.js. Running in an Electron utility process should work, but needs validation — particularly around process lifecycle and cleanup.
|
||||
2. ~~**SDK in utility process:**~~ **RESOLVED:** Works. The utility process is an Electron Chromium process with Node.js integration. It spawns GPU/network sub-processes (normal Electron behavior, causes noisy but harmless stderr). The SDK runs fine inside it.
|
||||
|
||||
3. **Event streaming over IPC:** Session events (especially `assistant.message_delta` at high frequency) need efficient IPC. MessagePort should handle this, but may need batching for performance.
|
||||
3. ~~**Event streaming over IPC:**~~ **RESOLVED:** `ProxyChannel` auto-forwards events. Works correctly.
|
||||
|
||||
4. **Authentication:** The SDK accepts `githubToken`. VS Code has `IAuthenticationService` with Copilot-specific token flows. Need to bridge these — get the token from the auth service and pass it to the SDK.
|
||||
4. **Authentication:** The SDK accepts `githubToken`. VS Code has `IAuthenticationService`. Need to bridge these -- get the token from the auth service and call `sdk.setGitHubToken()`. Not yet implemented.
|
||||
|
||||
5. **MCP servers:** The sessions window currently has MCP server support via the extension. The SDK also supports MCP servers natively (`mcpServers` config). Need to decide if we use the SDK's MCP support or keep the existing VS Code MCP infrastructure.
|
||||
5. **MCP servers:** Need to decide if we use the SDK's native MCP support (`mcpServers` config) or keep the existing VS Code MCP infrastructure.
|
||||
|
||||
6. **Existing tool infrastructure:** Copilot CLI has built-in tools (file I/O, bash, etc.) that operate directly on the filesystem. We may not need to re-register these as SDK tools. Need to understand which tools are built-in vs need to be provided by the host.
|
||||
6. **Existing tool infrastructure:** The CLI has built-in tools (file I/O, bash, etc.) that operate directly on the filesystem. We may not need to re-register these as SDK tools. Need to understand which are built-in vs need to be provided by the host.
|
||||
|
||||
7. **SDK maturity:** The SDK is in Technical Preview (v0.1.23). API may change. Need to pin a version and track updates carefully.
|
||||
7. **SDK maturity:** The SDK is in Technical Preview (v0.1.23). API may change.
|
||||
|
||||
8. **File changes / diff support:** The sessions view currently shows file insertions/deletions per session. The SDK doesn't directly provide this. We'd need to compute it from git or rely on the CLI's workspace state.
|
||||
8. **File changes / diff support:** The sessions view currently shows file insertions/deletions per session. The SDK doesn't directly provide this. Compute from git or the CLI's workspace state.
|
||||
|
||||
9. **`CopilotSdkChannel` formatters:** TypeScript formatters/organizers repeatedly merge the `CopilotSdkChannel` value import into `import type {}` blocks, which strips it at compile time and causes a runtime `ReferenceError`. Use inline `type` keywords on individual type imports to prevent this: `import { CopilotSdkChannel, type ICopilotSdkService, ... }`.
|
||||
|
||||
---
|
||||
|
||||
## 7. File Structure (New)
|
||||
## 7. File Structure (Implemented)
|
||||
|
||||
```
|
||||
src/vs/sessions/
|
||||
src/vs/platform/copilotSdk/ ← Platform layer (not vs/sessions/)
|
||||
├── common/
|
||||
│ ├── contextkeys.ts (existing)
|
||||
│ └── copilotSdkService.ts ← NEW: ICopilotSdkService interface + types
|
||||
│ └── copilotSdkService.ts ICopilotSdkService + ICopilotSdkMainService interfaces
|
||||
├── node/
|
||||
│ └── copilotSdkHost.ts ← NEW: Utility process entry point (SDK host)
|
||||
│ └── copilotSdkHost.ts Utility process entry point (SDK host)
|
||||
├── electron-main/
|
||||
│ ├── sessions.main.ts (existing)
|
||||
│ └── copilotSdkMainService.ts ← NEW: Main process proxy + utility process spawner
|
||||
│ └── copilotSdkStarter.ts Main process service (spawns utility process)
|
||||
|
||||
src/vs/sessions/ ← Sessions window layer (consumers)
|
||||
├── electron-browser/
|
||||
│ ├── sessions.ts (existing)
|
||||
│ └── copilotSdkService.ts ← NEW: One-liner registerMainProcessRemoteService
|
||||
│ └── copilotSdkService.ts One-liner: registerMainProcessRemoteService
|
||||
├── browser/
|
||||
│ ├── workbench.ts (existing)
|
||||
│ ├── menus.ts (existing)
|
||||
│ ├── layoutActions.ts (existing)
|
||||
│ ├── style.css (existing)
|
||||
│ ├── chatRenderer.ts ← NEW: Streaming chat renderer
|
||||
│ ├── copilotSdkDebugPanel.ts RPC debug panel (temporary)
|
||||
│ ├── media/copilotSdkDebugPanel.css Debug panel styles
|
||||
│ ├── parts/
|
||||
│ │ ├── chatbar/
|
||||
│ │ │ ├── chatBarPart.ts (existing, will be modified)
|
||||
│ │ │ ├── chatInputEditor.ts ← NEW: Input editor widget
|
||||
│ │ │ ├── chatToolCallView.ts ← NEW: Tool call visualization
|
||||
│ │ │ └── chatConfirmationView.ts ← NEW: User input / tool approval
|
||||
│ │ ├── titlebarPart.ts (existing)
|
||||
│ │ ├── editorModal.ts (existing)
|
||||
│ │ └── ... (existing)
|
||||
│ └── widget/ (TO BE REMOVED eventually)
|
||||
│ ├── agentSessionsChatWidget.ts ← REMOVE: replaced by SDK integration
|
||||
│ ├── agentSessionsChatTargetConfig.ts ← REMOVE: target concept simplified
|
||||
│ │ │ ├── chatBarPart.ts (existing, will be modified for Milestone 2)
|
||||
│ │ │ ├── chatInputEditor.ts ← TODO: New input editor widget
|
||||
│ │ │ ├── chatToolCallView.ts ← TODO: Tool call visualization
|
||||
│ │ │ └── chatConfirmationView.ts ← TODO: User input / tool approval
|
||||
│ │ └── ... (existing)
|
||||
│ └── widget/ (TO BE REMOVED once SDK UI replaces it)
|
||||
│ ├── agentSessionsChatWidget.ts ← REMOVE: replaced by SDK integration
|
||||
│ └── ...
|
||||
├── contrib/
|
||||
│ ├── sessions/browser/
|
||||
│ │ ├── sessionsViewPane.ts (existing, will consume ICopilotSdkService)
|
||||
│ │ └── activeSessionService.ts (existing, will track SDK sessions)
|
||||
│ ├── chat/browser/ (TO BE REMOVED eventually)
|
||||
│ └── ... (existing)
|
||||
└── sessions.desktop.main.ts (existing, will import copilotSdkService.ts)
|
||||
│ │ ├── sessionsViewPane.ts (will consume ICopilotSdkService)
|
||||
│ │ └── activeSessionService.ts (will track SDK sessions)
|
||||
│ └── chat/browser/
|
||||
│ └── chat.contribution.ts (has debug panel command + existing actions)
|
||||
└── sessions.desktop.main.ts (imports copilotSdkService.ts registration)
|
||||
|
||||
src/vs/code/electron-main/app.ts Registers ICopilotSdkMainService + channel
|
||||
```
|
||||
|
||||
### Key: Platform vs Sessions Layer
|
||||
|
||||
| Layer | Location | Contains |
|
||||
|-------|----------|----------|
|
||||
| `vs/platform/copilotSdk/` | Service interface, utility process host, main process starter | Imported by `vs/code/electron-main/app.ts` -- must be in `vs/platform/` for layering |
|
||||
| `vs/sessions/` | Renderer registration, debug panel, chat UI, session views | Consumes `ICopilotSdkService` via DI |
|
||||
|
||||
---
|
||||
|
||||
## 8. Worktree Strategy
|
||||
@@ -568,7 +572,84 @@ The sessions window **keeps its own extension host** — it already has one for
|
||||
|
||||
---
|
||||
|
||||
## 8. Concrete Implementation Plan
|
||||
## 8. Current UI Architecture (Research Findings)
|
||||
|
||||
### How the Chat Bar Works Today
|
||||
|
||||
The chat flows through 7 layers:
|
||||
|
||||
```
|
||||
ChatBarPart (AbstractPaneCompositePart)
|
||||
-> ViewPaneContainer ('workbench.panel.chat')
|
||||
-> ChatViewPane (ViewPane)
|
||||
-> AgentSessionsChatWidget (sessions window only)
|
||||
-> ChatWidget (core chat rendering)
|
||||
-> ChatService -> Extension Host -> copilot-chat extension
|
||||
```
|
||||
|
||||
Key findings:
|
||||
|
||||
1. **ChatBarPart** is a standard `AbstractPaneCompositePart` -- it has NO direct chat imports. It delegates entirely to the pane composite framework.
|
||||
|
||||
2. **The chat view container** is registered at `ViewContainerLocation.ChatBar` with `isDefault: true` in `chatParticipant.contribution.ts`. On startup, `restoreParts()` opens the default container, which instantiates `ChatViewPane`.
|
||||
|
||||
3. **ChatViewPane** has an `if (isSessionsWindow)` branch that creates either `AgentSessionsChatWidget` (sessions) or plain `ChatWidget` (regular VS Code).
|
||||
|
||||
4. **AgentSessionsChatWidget** wraps `ChatWidget` and adds deferred session creation. On first send, it creates a session via `chatService.loadSessionForResource()` and then delegates to `ChatWidget.acceptInput()`.
|
||||
|
||||
### Dependency Map: sessions/ -> workbench/contrib/chat/
|
||||
|
||||
| Category | Count | Examples |
|
||||
|----------|-------|---------|
|
||||
| **UI** (must replace) | 12+ symbols | `ChatWidget`, `ChatInputPart`, `AgentSessionsControl`, `AgentSessionsPicker`, `ChatViewPane`, `IChatWidgetService` |
|
||||
| **Data** (must replace) | 20+ symbols | `IChatService`, `IChatSessionsService`, `IAgentSessionsService`, `IChatModel`, `IAgentSession`, `AgentSessionProviders`, `getChatSessionType` |
|
||||
| **Can keep** (shared utilities) | 15+ symbols | `ChatAgentLocation`, `ChatModeKind`, `IPromptsService`, `PromptsType`, `ChatContextKeys`, `ILanguageModelsService` |
|
||||
|
||||
### Heaviest Files (by chat dependency count)
|
||||
|
||||
1. `agentSessionsChatWidget.ts` -- 10 imports (heart of chat UI, TO BE REPLACED)
|
||||
2. `agentSessionsChatWelcomePart.ts` -- 7 imports (welcome UI, TO BE REPLACED)
|
||||
3. `changesView.ts` -- 7 imports (file changes, KEEP for now)
|
||||
4. `sessionsTitleBarWidget.ts` -- 6 imports (title bar, MODIFY later)
|
||||
5. `activeSessionService.ts` -- 5 imports (session tracking, MODIFY later)
|
||||
6. `aiCustomizationManagementEditor.ts` -- 7 imports (customizations, KEEP for now)
|
||||
|
||||
### Adoption Strategy: Replace from the Inside Out
|
||||
|
||||
Rather than rewriting everything at once, we replace the **widget layer** inside `ChatViewPane`:
|
||||
|
||||
**Option C (recommended):** Create a NEW `SdkChatViewPane` class in `vs/sessions/` that hosts `SdkChatWidget` directly. Register it as an alternative view in the ChatBar container. Zero changes to `ChatViewPane` or `ChatWidget`. The existing UI keeps working as fallback.
|
||||
|
||||
```
|
||||
ChatBarPart (UNCHANGED)
|
||||
-> ViewPaneContainer (UNCHANGED)
|
||||
-> SdkChatViewPane (NEW, in vs/sessions/)
|
||||
-> SdkChatWidget (NEW, in vs/sessions/)
|
||||
-> ICopilotSdkService -> Utility Process -> SDK -> CLI
|
||||
```
|
||||
|
||||
### Phase 2 File Plan
|
||||
|
||||
| File | Action | Phase |
|
||||
|------|--------|-------|
|
||||
| `src/vs/sessions/browser/widget/sdkChatWidget.ts` | **NEW** -- Core SDK chat widget | 2A |
|
||||
| `src/vs/sessions/browser/widget/sdkChatWidget.css` | **NEW** -- Styles | 2A |
|
||||
| `src/vs/sessions/browser/widget/sdkChatViewPane.ts` | **NEW** -- View pane hosting the SDK widget | 2B |
|
||||
| `src/vs/sessions/contrib/chat/browser/chat.contribution.ts` | **MODIFY** -- Register `SdkChatViewPane` | 2B |
|
||||
| `src/vs/sessions/contrib/sessions/browser/activeSessionService.ts` | **MODIFY** -- Add SDK data source | 2D |
|
||||
| `src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts` | **MODIFY** -- Show SDK session info | 2D |
|
||||
|
||||
### What We Don't Touch (Yet)
|
||||
|
||||
- `ChatBarPart` -- pane composite infrastructure, no changes needed
|
||||
- `ChatViewPane` -- no changes, stays for fallback
|
||||
- `changesView.ts` -- keeps existing data flow
|
||||
- `aiCustomization*` -- keeps existing flow
|
||||
- `sessions.desktop.main.ts` -- SDK service already registered
|
||||
|
||||
---
|
||||
|
||||
## 9. Concrete Implementation Plan
|
||||
|
||||
This section is the **actionable build order**. Each step produces a compilable, testable checkpoint. Steps within a milestone can be developed in parallel; milestones are sequential.
|
||||
|
||||
@@ -830,3 +911,5 @@ Each milestone produces a working, demoable checkpoint. M1+M2 gets us to "type a
|
||||
| 2026-02-13 | Initial plan created. Covers SDK adoption, CLI bundling, utility process hosting, new chat UI, session list migration, and copilot-chat dependency removal. Based on Copilot SDK official documentation. |
|
||||
| 2026-02-13 | Added concrete implementation plan (Section 9) with 5 milestones and detailed step-by-step build order. |
|
||||
| 2026-02-13 | Refined architecture to terminal (pty host) pattern: `registerMainProcessRemoteService` + main process proxy + utility process. Renderer-side code is one line DI. Added worktree strategy (Section 8): CLI creates worktrees, git extension handles post-creation operations. Extension host is kept for git, not removed. |
|
||||
| 2026-02-13 | **Milestone 1 complete.** Moved service to `vs/platform/copilotSdk/` (fixes layering violation). Created `ICopilotSdkMainService` for proper DI registration in `app.ts`. Native CLI binary resolved via `import.meta.resolve('@github/copilot-{platform}-{arch}')`. Stripped `ELECTRON_*`/`VSCODE_*` env vars from CLI child process env. Added RPC debug panel with event stream logging. |
|
||||
| 2026-02-13 | Added Section 8: Current UI Architecture research findings. Mapped all `vs/sessions/` -> `vs/workbench/contrib/chat/` dependencies (12+ UI symbols, 20+ data symbols, 15+ keepable utilities). Designed Phase 2 adoption strategy: new `SdkChatViewPane` + `SdkChatWidget` registered as alternative view in ChatBar, zero changes to existing ChatViewPane/ChatWidget. |
|
||||
|
||||
103
src/vs/sessions/browser/copilotSdkDebugLog.ts
Normal file
103
src/vs/sessions/browser/copilotSdkDebugLog.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Always-on debug log for the Copilot SDK. Subscribes to all SDK events
|
||||
* at startup and buffers them so the debug panel can show the full history
|
||||
* regardless of when it is opened.
|
||||
*
|
||||
* Registered as a workbench contribution in `chat.contribution.ts`.
|
||||
*/
|
||||
|
||||
import { Disposable } from '../../base/common/lifecycle.js';
|
||||
import { Emitter, Event } from '../../base/common/event.js';
|
||||
import { ICopilotSdkService } from '../../platform/copilotSdk/common/copilotSdkService.js';
|
||||
|
||||
const MAX_LOG_ENTRIES = 5000;
|
||||
|
||||
export interface IDebugLogEntry {
|
||||
readonly id: number;
|
||||
readonly timestamp: string;
|
||||
readonly direction: string; // '→' request, '←' response, '!' event, 'X' error
|
||||
readonly method: string;
|
||||
readonly detail: string;
|
||||
readonly tag?: string;
|
||||
readonly stream: 'rpc' | 'process';
|
||||
}
|
||||
|
||||
export class CopilotSdkDebugLog extends Disposable {
|
||||
|
||||
static readonly ID = 'copilotSdk.debugLog';
|
||||
|
||||
private static _instance: CopilotSdkDebugLog | undefined;
|
||||
static get instance(): CopilotSdkDebugLog | undefined { return CopilotSdkDebugLog._instance; }
|
||||
|
||||
private _nextId = 1;
|
||||
private readonly _entries: IDebugLogEntry[] = [];
|
||||
|
||||
private readonly _onDidAddEntry = this._register(new Emitter<IDebugLogEntry>());
|
||||
readonly onDidAddEntry: Event<IDebugLogEntry> = this._onDidAddEntry.event;
|
||||
|
||||
constructor(
|
||||
@ICopilotSdkService private readonly _sdk: ICopilotSdkService,
|
||||
) {
|
||||
super();
|
||||
CopilotSdkDebugLog._instance = this;
|
||||
this._subscribe();
|
||||
}
|
||||
|
||||
get entries(): readonly IDebugLogEntry[] {
|
||||
return this._entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a log entry programmatically (used by the debug panel for manual RPC calls).
|
||||
*/
|
||||
addEntry(direction: string, method: string, detail: string, tag?: string, stream: 'rpc' | 'process' = 'rpc'): void {
|
||||
const entry: IDebugLogEntry = {
|
||||
id: this._nextId++,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
direction,
|
||||
method,
|
||||
detail,
|
||||
tag,
|
||||
stream,
|
||||
};
|
||||
this._entries.push(entry);
|
||||
if (this._entries.length > MAX_LOG_ENTRIES) {
|
||||
this._entries.splice(0, this._entries.length - MAX_LOG_ENTRIES);
|
||||
}
|
||||
this._onDidAddEntry.fire(entry);
|
||||
}
|
||||
|
||||
clear(stream?: 'rpc' | 'process'): void {
|
||||
if (stream) {
|
||||
// Remove only entries of the given stream
|
||||
for (let i = this._entries.length - 1; i >= 0; i--) {
|
||||
if (this._entries[i].stream === stream) {
|
||||
this._entries.splice(i, 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._entries.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribe(): void {
|
||||
this._register(this._sdk.onSessionEvent(event => {
|
||||
const data = JSON.stringify(event.data ?? {});
|
||||
const truncated = data.length > 300 ? data.substring(0, 300) + '...' : data;
|
||||
this.addEntry('!', `event:${event.type}`, truncated, event.sessionId.substring(0, 8));
|
||||
}));
|
||||
|
||||
this._register(this._sdk.onSessionLifecycle(event => {
|
||||
this.addEntry('!', `lifecycle:${event.type}`, '', event.sessionId.substring(0, 8));
|
||||
}));
|
||||
|
||||
this._register(this._sdk.onProcessOutput(output => {
|
||||
this.addEntry('', output.stream, output.data, undefined, 'process');
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import * as dom from '../../base/browser/dom.js';
|
||||
import { Disposable, DisposableStore } from '../../base/common/lifecycle.js';
|
||||
import { ICopilotSdkService } from '../../platform/copilotSdk/common/copilotSdkService.js';
|
||||
import { IClipboardService } from '../../platform/clipboard/common/clipboardService.js';
|
||||
import { CopilotSdkDebugLog, IDebugLogEntry } from './copilotSdkDebugLog.js';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
@@ -28,15 +29,13 @@ export class CopilotSdkDebugPanel extends Disposable {
|
||||
private readonly _cwdInput: HTMLInputElement;
|
||||
private readonly _modelSelect: HTMLSelectElement;
|
||||
private _sessionId: string | undefined;
|
||||
private _logCount = 0;
|
||||
private readonly _logLines: string[] = [];
|
||||
private readonly _processLines: string[] = [];
|
||||
private _activeTab: 'rpc' | 'process' = 'rpc';
|
||||
|
||||
private readonly _eventDisposables = this._register(new DisposableStore());
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
private readonly _debugLog: CopilotSdkDebugLog,
|
||||
@ICopilotSdkService private readonly _sdk: ICopilotSdkService,
|
||||
@IClipboardService private readonly _clipboardService: IClipboardService,
|
||||
) {
|
||||
@@ -52,9 +51,11 @@ export class CopilotSdkDebugPanel extends Disposable {
|
||||
clearBtn.style.cssText = 'margin-left:auto;font-size:11px;padding:2px 8px;background:var(--vscode-button-secondaryBackground);color:var(--vscode-button-secondaryForeground);border:none;border-radius:3px;cursor:pointer;';
|
||||
this._register(dom.addDisposableListener(clearBtn, 'click', () => {
|
||||
if (this._activeTab === 'rpc') {
|
||||
dom.clearNode(this._rpcLogContainer); this._logCount = 0; this._logLines.length = 0;
|
||||
dom.clearNode(this._rpcLogContainer);
|
||||
this._debugLog.clear('rpc');
|
||||
} else {
|
||||
dom.clearNode(this._processLogContainer); this._processLines.length = 0;
|
||||
dom.clearNode(this._processLogContainer);
|
||||
this._debugLog.clear('process');
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -62,7 +63,10 @@ export class CopilotSdkDebugPanel extends Disposable {
|
||||
copyBtn.textContent = 'Copy All';
|
||||
copyBtn.style.cssText = 'margin-left:4px;font-size:11px;padding:2px 8px;background:var(--vscode-button-secondaryBackground);color:var(--vscode-button-secondaryForeground);border:none;border-radius:3px;cursor:pointer;';
|
||||
this._register(dom.addDisposableListener(copyBtn, 'click', () => {
|
||||
const lines = this._activeTab === 'rpc' ? this._logLines : this._processLines;
|
||||
const stream = this._activeTab === 'rpc' ? 'rpc' : 'process';
|
||||
const lines = this._debugLog.entries
|
||||
.filter(e => e.stream === stream)
|
||||
.map(e => `${String(e.id).padStart(3, '0')} ${e.direction} ${e.tag ? `[${e.tag}] ` : ''}${e.method} ${e.detail} ${e.timestamp}`);
|
||||
this._clipboardService.writeText(lines.join('\n'));
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy All'; }, 1500);
|
||||
@@ -84,18 +88,28 @@ export class CopilotSdkDebugPanel extends Disposable {
|
||||
this._cwdInput.placeholder = '/path/to/project';
|
||||
this._cwdInput.value = '/tmp';
|
||||
|
||||
// Helper buttons
|
||||
// Helper buttons - organized in rows
|
||||
const helpers = dom.append(this.element, $('.debug-panel-helpers'));
|
||||
// allow-any-unicode-next-line
|
||||
const btns: Array<{ label: string; fn: () => void }> = [
|
||||
// Lifecycle
|
||||
{ label: '> Start', fn: () => this._rpc('start') },
|
||||
{ label: 'Stop', fn: () => this._rpc('stop') },
|
||||
// Discovery
|
||||
{ label: 'List Models', fn: () => this._rpc('listModels') },
|
||||
{ label: 'List Sessions', fn: () => this._rpc('listSessions') },
|
||||
// Session management
|
||||
{ label: '+ Create Session', fn: () => this._rpc('createSession') },
|
||||
{ label: 'Send Message', fn: () => this._rpc('send') },
|
||||
{ label: 'Abort', fn: () => this._rpc('abort') },
|
||||
{ label: 'Resume Session', fn: () => this._rpc('resumeSession') },
|
||||
{ label: 'Get Messages', fn: () => this._rpc('getMessages') },
|
||||
{ label: 'Destroy Session', fn: () => this._rpc('destroySession') },
|
||||
{ label: 'Stop', fn: () => this._rpc('stop') },
|
||||
{ label: 'Delete Session', fn: () => this._rpc('deleteSession') },
|
||||
// Messaging
|
||||
{ label: 'Send', fn: () => this._rpc('send') },
|
||||
{ label: 'Send + Wait', fn: () => this._rpc('sendAndWait') },
|
||||
{ label: 'Abort', fn: () => this._rpc('abort') },
|
||||
// Auth
|
||||
{ label: 'Set Token', fn: () => this._rpc('setGitHubToken') },
|
||||
];
|
||||
for (const { label, fn } of btns) {
|
||||
const btn = dom.append(helpers, $('button.debug-helper-btn')) as HTMLButtonElement;
|
||||
@@ -132,21 +146,79 @@ export class CopilotSdkDebugPanel extends Disposable {
|
||||
this._inputArea.placeholder = 'Message prompt (used by Send Message)...';
|
||||
this._inputArea.rows = 2;
|
||||
|
||||
// Initialize: subscribe to events immediately
|
||||
this._subscribeToEvents();
|
||||
this._initialize();
|
||||
// Replay buffered log entries, then subscribe for new ones
|
||||
this._replayAndSubscribe();
|
||||
this._initializeModels();
|
||||
}
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
try {
|
||||
this._setStatus('Starting...');
|
||||
this._logRpc('→', 'start', '');
|
||||
await this._sdk.start();
|
||||
this._logRpc('←', 'start', 'OK');
|
||||
/**
|
||||
* Render all buffered log entries then subscribe for new ones.
|
||||
*/
|
||||
private _replayAndSubscribe(): void {
|
||||
for (const entry of this._debugLog.entries) {
|
||||
this._renderEntry(entry);
|
||||
}
|
||||
|
||||
this._logRpc('→', 'listModels', '');
|
||||
this._eventDisposables.clear();
|
||||
this._eventDisposables.add(this._debugLog.onDidAddEntry(entry => {
|
||||
this._renderEntry(entry);
|
||||
}));
|
||||
}
|
||||
|
||||
private _renderEntry(entry: IDebugLogEntry): void {
|
||||
if (entry.stream === 'process') {
|
||||
this._renderProcessEntry(entry);
|
||||
} else {
|
||||
this._renderRpcEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderRpcEntry(entry: IDebugLogEntry): void {
|
||||
const el = dom.append(this._rpcLogContainer, $('.debug-rpc-entry'));
|
||||
|
||||
const num = dom.append(el, $('span.debug-rpc-num'));
|
||||
num.textContent = String(entry.id).padStart(3, '0');
|
||||
|
||||
const dir = dom.append(el, $('span.debug-rpc-dir'));
|
||||
dir.textContent = entry.direction;
|
||||
|
||||
if (entry.tag) {
|
||||
const tagEl = dom.append(el, $('span.debug-rpc-tag'));
|
||||
tagEl.textContent = entry.tag;
|
||||
}
|
||||
|
||||
const meth = dom.append(el, $('span.debug-rpc-method'));
|
||||
meth.textContent = entry.method;
|
||||
|
||||
if (entry.detail) {
|
||||
const det = dom.append(el, $('span.debug-rpc-detail'));
|
||||
det.textContent = entry.detail;
|
||||
}
|
||||
|
||||
const time = dom.append(el, $('span.debug-rpc-time'));
|
||||
time.textContent = entry.timestamp;
|
||||
|
||||
this._rpcLogContainer.scrollTop = this._rpcLogContainer.scrollHeight;
|
||||
}
|
||||
|
||||
private _renderProcessEntry(entry: IDebugLogEntry): void {
|
||||
const el = dom.append(this._processLogContainer, $('.debug-rpc-entry'));
|
||||
const time = dom.append(el, $('span.debug-rpc-time'));
|
||||
time.textContent = entry.timestamp;
|
||||
const streamTag = dom.append(el, $('span.debug-rpc-tag'));
|
||||
streamTag.textContent = entry.method; // method holds the stream name for process entries
|
||||
const content = dom.append(el, $('span.debug-rpc-detail'));
|
||||
content.textContent = entry.detail;
|
||||
content.style.whiteSpace = 'pre-wrap';
|
||||
content.style.flex = '1';
|
||||
|
||||
this._processLogContainer.scrollTop = this._processLogContainer.scrollHeight;
|
||||
}
|
||||
|
||||
private async _initializeModels(): Promise<void> {
|
||||
try {
|
||||
this._setStatus('Loading models...');
|
||||
const models = await this._sdk.listModels();
|
||||
this._logRpc('←', 'listModels', `${models.length} models`);
|
||||
|
||||
dom.clearNode(this._modelSelect);
|
||||
for (const m of models) {
|
||||
@@ -160,147 +232,124 @@ export class CopilotSdkDebugPanel extends Disposable {
|
||||
|
||||
this._setStatus('Ready');
|
||||
} catch (err) {
|
||||
this._logRpc('X', 'init', String(err));
|
||||
this._debugLog.addEntry('X', 'init', String(err));
|
||||
this._setStatus('Error');
|
||||
}
|
||||
}
|
||||
|
||||
private _subscribeToEvents(): void {
|
||||
this._eventDisposables.clear();
|
||||
this._eventDisposables.add(this._sdk.onSessionEvent(event => {
|
||||
const data = JSON.stringify(event.data ?? {});
|
||||
const truncated = data.length > 300 ? data.substring(0, 300) + '…' : data;
|
||||
this._logRpc('!', `event:${event.type}`, truncated, event.sessionId.substring(0, 8));
|
||||
}));
|
||||
this._eventDisposables.add(this._sdk.onSessionLifecycle(event => {
|
||||
this._logRpc('!', `lifecycle:${event.type}`, '', event.sessionId.substring(0, 8));
|
||||
}));
|
||||
this._eventDisposables.add(this._sdk.onProcessOutput(output => {
|
||||
this._logProcess(output.stream, output.data);
|
||||
}));
|
||||
}
|
||||
|
||||
private async _rpc(method: string): Promise<void> {
|
||||
try {
|
||||
switch (method) {
|
||||
case 'start': {
|
||||
this._logRpc('→', 'start', '');
|
||||
this._debugLog.addEntry('\u2192', 'start', '');
|
||||
await this._sdk.start();
|
||||
this._logRpc('←', 'start', 'OK');
|
||||
this._debugLog.addEntry('\u2190', 'start', 'OK');
|
||||
break;
|
||||
}
|
||||
case 'stop': {
|
||||
this._logRpc('→', 'stop', '');
|
||||
this._debugLog.addEntry('\u2192', 'stop', '');
|
||||
await this._sdk.stop();
|
||||
this._sessionId = undefined;
|
||||
this._logRpc('←', 'stop', 'OK');
|
||||
this._debugLog.addEntry('\u2190', 'stop', 'OK');
|
||||
break;
|
||||
}
|
||||
case 'listModels': {
|
||||
this._logRpc('→', 'listModels', '');
|
||||
this._debugLog.addEntry('\u2192', 'listModels', '');
|
||||
const models = await this._sdk.listModels();
|
||||
this._logRpc('←', 'listModels', JSON.stringify(models.map(m => m.id)));
|
||||
this._debugLog.addEntry('\u2190', 'listModels', JSON.stringify(models.map(m => m.id)));
|
||||
break;
|
||||
}
|
||||
case 'listSessions': {
|
||||
this._logRpc('→', 'listSessions', '');
|
||||
this._debugLog.addEntry('\u2192', 'listSessions', '');
|
||||
const sessions = await this._sdk.listSessions();
|
||||
this._logRpc('←', 'listSessions', JSON.stringify(sessions));
|
||||
this._debugLog.addEntry('\u2190', 'listSessions', JSON.stringify(sessions));
|
||||
break;
|
||||
}
|
||||
case 'createSession': {
|
||||
const model = this._modelSelect.value;
|
||||
const cwd = this._cwdInput.value.trim() || undefined;
|
||||
this._logRpc('→', 'createSession', JSON.stringify({ model, streaming: true, workingDirectory: cwd }));
|
||||
this._debugLog.addEntry('\u2192', 'createSession', JSON.stringify({ model, streaming: true, workingDirectory: cwd }));
|
||||
this._sessionId = await this._sdk.createSession({ model, streaming: true, workingDirectory: cwd });
|
||||
this._logRpc('←', 'createSession', this._sessionId);
|
||||
this._debugLog.addEntry('\u2190', 'createSession', this._sessionId);
|
||||
this._setStatus(`Session: ${this._sessionId.substring(0, 8)}...`);
|
||||
break;
|
||||
}
|
||||
case 'send': {
|
||||
if (!this._sessionId) {
|
||||
this._logRpc('X', 'send', 'No session -- create one first');
|
||||
this._debugLog.addEntry('X', 'send', 'No session -- create one first');
|
||||
return;
|
||||
}
|
||||
const prompt = this._inputArea.value.trim() || 'What is 2+2? Answer in one word.';
|
||||
this._logRpc('→', 'send', JSON.stringify({ sessionId: this._sessionId.substring(0, 8), prompt: prompt.substring(0, 100) }));
|
||||
this._debugLog.addEntry('\u2192', 'send', JSON.stringify({ sessionId: this._sessionId.substring(0, 8), prompt: prompt.substring(0, 100) }));
|
||||
this._setStatus('Sending...');
|
||||
await this._sdk.send(this._sessionId, prompt);
|
||||
this._logRpc('←', 'send', 'queued');
|
||||
this._debugLog.addEntry('\u2190', 'send', 'queued');
|
||||
break;
|
||||
}
|
||||
case 'abort': {
|
||||
if (!this._sessionId) { this._logRpc('X', 'abort', 'No session'); return; }
|
||||
this._logRpc('→', 'abort', this._sessionId.substring(0, 8));
|
||||
if (!this._sessionId) { this._debugLog.addEntry('X', 'abort', 'No session'); return; }
|
||||
this._debugLog.addEntry('\u2192', 'abort', this._sessionId.substring(0, 8));
|
||||
await this._sdk.abort(this._sessionId);
|
||||
this._logRpc('←', 'abort', 'OK');
|
||||
this._debugLog.addEntry('\u2190', 'abort', 'OK');
|
||||
break;
|
||||
}
|
||||
case 'destroySession': {
|
||||
if (!this._sessionId) { this._logRpc('X', 'destroySession', 'No session'); return; }
|
||||
this._logRpc('→', 'destroySession', this._sessionId.substring(0, 8));
|
||||
if (!this._sessionId) { this._debugLog.addEntry('X', 'destroySession', 'No session'); return; }
|
||||
this._debugLog.addEntry('\u2192', 'destroySession', this._sessionId.substring(0, 8));
|
||||
await this._sdk.destroySession(this._sessionId);
|
||||
this._logRpc('←', 'destroySession', 'OK');
|
||||
this._debugLog.addEntry('\u2190', 'destroySession', 'OK');
|
||||
this._sessionId = undefined;
|
||||
this._setStatus('Ready');
|
||||
break;
|
||||
}
|
||||
case 'deleteSession': {
|
||||
if (!this._sessionId) { this._debugLog.addEntry('X', 'deleteSession', 'No session'); return; }
|
||||
this._debugLog.addEntry('\u2192', 'deleteSession', this._sessionId.substring(0, 8));
|
||||
await this._sdk.deleteSession(this._sessionId);
|
||||
this._debugLog.addEntry('\u2190', 'deleteSession', 'OK');
|
||||
this._sessionId = undefined;
|
||||
this._setStatus('Ready');
|
||||
break;
|
||||
}
|
||||
case 'resumeSession': {
|
||||
if (!this._sessionId) { this._debugLog.addEntry('X', 'resumeSession', 'No session'); return; }
|
||||
this._debugLog.addEntry('\u2192', 'resumeSession', this._sessionId.substring(0, 8));
|
||||
await this._sdk.resumeSession(this._sessionId, { streaming: true });
|
||||
this._debugLog.addEntry('\u2190', 'resumeSession', 'OK');
|
||||
break;
|
||||
}
|
||||
case 'getMessages': {
|
||||
if (!this._sessionId) { this._debugLog.addEntry('X', 'getMessages', 'No session'); return; }
|
||||
this._debugLog.addEntry('\u2192', 'getMessages', this._sessionId.substring(0, 8));
|
||||
const messages = await this._sdk.getMessages(this._sessionId);
|
||||
const summary = messages.map(m => `${m.type}${m.data.deltaContent ? ':' + (m.data.deltaContent as string).substring(0, 30) : ''}`).join(', ');
|
||||
this._debugLog.addEntry('\u2190', 'getMessages', `${messages.length} events: ${summary.substring(0, 200)}`);
|
||||
break;
|
||||
}
|
||||
case 'sendAndWait': {
|
||||
if (!this._sessionId) { this._debugLog.addEntry('X', 'sendAndWait', 'No session'); return; }
|
||||
const swPrompt = this._inputArea.value.trim() || 'What is 2+2? Answer in one word.';
|
||||
this._debugLog.addEntry('\u2192', 'sendAndWait', JSON.stringify({ sessionId: this._sessionId.substring(0, 8), prompt: swPrompt.substring(0, 100) }));
|
||||
this._setStatus('Sending (wait)...');
|
||||
const result = await this._sdk.sendAndWait(this._sessionId, swPrompt);
|
||||
this._debugLog.addEntry('\u2190', 'sendAndWait', result ? result.content.substring(0, 200) : 'undefined');
|
||||
this._setStatus(`Session: ${this._sessionId.substring(0, 8)}...`);
|
||||
break;
|
||||
}
|
||||
case 'setGitHubToken': {
|
||||
const token = this._inputArea.value.trim();
|
||||
if (!token) { this._debugLog.addEntry('X', 'setGitHubToken', 'Enter token in the text area'); return; }
|
||||
this._debugLog.addEntry('\u2192', 'setGitHubToken', `${token.substring(0, 4)}...`);
|
||||
await this._sdk.setGitHubToken(token);
|
||||
this._debugLog.addEntry('\u2190', 'setGitHubToken', 'OK');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this._logRpc('X', method, String(err instanceof Error ? err.message : err));
|
||||
this._debugLog.addEntry('X', method, String(err instanceof Error ? err.message : err));
|
||||
}
|
||||
}
|
||||
|
||||
private _logRpc(direction: string, method: string, detail: string, tag?: string): void {
|
||||
this._logCount++;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const line = `${String(this._logCount).padStart(3, '0')} ${direction} ${tag ? `[${tag}] ` : ''}${method} ${detail} ${timestamp}`;
|
||||
this._logLines.push(line);
|
||||
|
||||
const el = dom.append(this._rpcLogContainer, $('.debug-rpc-entry'));
|
||||
|
||||
const num = dom.append(el, $('span.debug-rpc-num'));
|
||||
num.textContent = String(this._logCount).padStart(3, '0');
|
||||
|
||||
const dir = dom.append(el, $('span.debug-rpc-dir'));
|
||||
dir.textContent = direction;
|
||||
|
||||
if (tag) {
|
||||
const tagEl = dom.append(el, $('span.debug-rpc-tag'));
|
||||
tagEl.textContent = tag;
|
||||
}
|
||||
|
||||
const meth = dom.append(el, $('span.debug-rpc-method'));
|
||||
meth.textContent = method;
|
||||
|
||||
if (detail) {
|
||||
const det = dom.append(el, $('span.debug-rpc-detail'));
|
||||
det.textContent = detail;
|
||||
}
|
||||
|
||||
const time = dom.append(el, $('span.debug-rpc-time'));
|
||||
time.textContent = new Date().toLocaleTimeString();
|
||||
|
||||
this._rpcLogContainer.scrollTop = this._rpcLogContainer.scrollHeight;
|
||||
}
|
||||
|
||||
private _logProcess(stream: string, data: string): void {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
this._processLines.push(`[${timestamp}] [${stream}] ${data}`);
|
||||
|
||||
const el = dom.append(this._processLogContainer, $('.debug-rpc-entry'));
|
||||
const time = dom.append(el, $('span.debug-rpc-time'));
|
||||
time.textContent = timestamp;
|
||||
const streamTag = dom.append(el, $('span.debug-rpc-tag'));
|
||||
streamTag.textContent = stream;
|
||||
const content = dom.append(el, $('span.debug-rpc-detail'));
|
||||
content.textContent = data;
|
||||
content.style.whiteSpace = 'pre-wrap';
|
||||
content.style.flex = '1';
|
||||
|
||||
this._processLogContainer.scrollTop = this._processLogContainer.scrollHeight;
|
||||
}
|
||||
|
||||
private _setStatus(text: string): void {
|
||||
this._statusBar.textContent = text;
|
||||
}
|
||||
|
||||
290
src/vs/sessions/browser/widget/media/sdkChatWidget.css
Normal file
290
src/vs/sessions/browser/widget/media/sdkChatWidget.css
Normal file
@@ -0,0 +1,290 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
.sdk-chat-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--vscode-sideBar-background);
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
/* --- Messages area --- */
|
||||
.sdk-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
/* --- Turns --- */
|
||||
.sdk-chat-message {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sdk-chat-message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sdk-chat-message-header .codicon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* --- Markdown body --- */
|
||||
.sdk-chat-message-body {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.sdk-chat-message-body p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.sdk-chat-message-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sdk-chat-message-body code {
|
||||
font-family: var(--monaco-monospace-font);
|
||||
font-size: var(--editor-font-size);
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sdk-chat-message-body pre {
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.sdk-chat-message-body pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* --- Tool calls --- */
|
||||
.sdk-chat-tool-call {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
margin: 6px 0;
|
||||
background-color: var(--vscode-textCodeBlock-background);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: var(--monaco-monospace-font);
|
||||
}
|
||||
|
||||
.sdk-chat-tool-call .codicon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sdk-chat-tool-call.running .codicon-loading {
|
||||
animation: sdk-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sdk-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.sdk-chat-tool-call.complete {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sdk-chat-tool-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sdk-chat-tool-status {
|
||||
margin-left: auto;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* --- Thinking/reasoning --- */
|
||||
.sdk-chat-reasoning {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid var(--vscode-textBlockQuote-border);
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- Progress messages --- */
|
||||
.sdk-chat-progress {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
padding: 4px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Streaming cursor --- */
|
||||
.sdk-chat-streaming-cursor::after {
|
||||
content: '\25AE';
|
||||
animation: sdk-blink 1s step-end infinite;
|
||||
margin-left: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes sdk-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* --- Welcome --- */
|
||||
.sdk-chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sdk-chat-welcome-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sdk-chat-welcome-subtitle {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* --- Input area --- */
|
||||
.sdk-chat-input-area {
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sdk-chat-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.sdk-chat-input-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sdk-chat-textarea {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
max-height: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
line-height: 1.4;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sdk-chat-textarea:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.sdk-chat-textarea::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
.sdk-chat-send-btn,
|
||||
.sdk-chat-abort-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sdk-chat-send-btn {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.sdk-chat-send-btn:hover {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.sdk-chat-send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sdk-chat-abort-btn {
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
|
||||
.sdk-chat-abort-btn:hover {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.sdk-chat-send-btn .codicon,
|
||||
.sdk-chat-abort-btn .codicon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* --- Model picker --- */
|
||||
.sdk-chat-model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sdk-chat-model-label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sdk-chat-model-select {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--vscode-dropdown-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--vscode-dropdown-background);
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --- Status --- */
|
||||
.sdk-chat-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 16px;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
316
src/vs/sessions/browser/widget/sdkChatModel.ts
Normal file
316
src/vs/sessions/browser/widget/sdkChatModel.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Lightweight chat model that converts Copilot SDK session events into typed
|
||||
* chat parts. Mirrors the structure of VS Code's `IChatMarkdownContent`,
|
||||
* `IChatToolInvocation`, `IChatThinkingPart`, etc. but without the heavy
|
||||
* infrastructure (observables, code block collections, editor pools).
|
||||
*
|
||||
* This model owns the data; the `SdkChatWidget` renders from it.
|
||||
*/
|
||||
|
||||
import { Emitter, Event } from '../../../base/common/event.js';
|
||||
import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js';
|
||||
import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { ICopilotSessionEvent, type CopilotSessionEventType } from '../../../platform/copilotSdk/common/copilotSdkService.js';
|
||||
|
||||
// #region Part Types
|
||||
|
||||
/**
|
||||
* Discriminated union of all SDK chat part types.
|
||||
* The `kind` field mirrors the VS Code chat part convention.
|
||||
*/
|
||||
export type SdkChatPart =
|
||||
| ISdkMarkdownPart
|
||||
| ISdkThinkingPart
|
||||
| ISdkToolCallPart
|
||||
| ISdkProgressPart;
|
||||
|
||||
export interface ISdkMarkdownPart {
|
||||
readonly kind: 'markdownContent';
|
||||
content: IMarkdownString;
|
||||
/** True while streaming deltas are still arriving. */
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export interface ISdkThinkingPart {
|
||||
readonly kind: 'thinking';
|
||||
content: string;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export interface ISdkToolCallPart {
|
||||
readonly kind: 'toolInvocation';
|
||||
readonly toolName: string;
|
||||
readonly toolCallId?: string;
|
||||
state: 'running' | 'complete';
|
||||
result?: string;
|
||||
}
|
||||
|
||||
export interface ISdkProgressPart {
|
||||
readonly kind: 'progress';
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Turn Types
|
||||
|
||||
export interface ISdkChatTurn {
|
||||
readonly id: string;
|
||||
readonly role: 'user' | 'assistant';
|
||||
readonly parts: SdkChatPart[];
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Change Events
|
||||
|
||||
export interface ISdkChatModelChange {
|
||||
readonly type: 'turnAdded' | 'partAdded' | 'partUpdated' | 'turnCompleted';
|
||||
readonly turnId: string;
|
||||
readonly partIndex?: number;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Model
|
||||
|
||||
export class SdkChatModel extends Disposable {
|
||||
|
||||
private readonly _turns: ISdkChatTurn[] = [];
|
||||
private _turnCounter = 0;
|
||||
|
||||
private readonly _onDidChange = this._register(new Emitter<ISdkChatModelChange>());
|
||||
readonly onDidChange: Event<ISdkChatModelChange> = this._onDidChange.event;
|
||||
|
||||
get turns(): readonly ISdkChatTurn[] {
|
||||
return this._turns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user message turn.
|
||||
*/
|
||||
addUserMessage(content: string): ISdkChatTurn {
|
||||
const turn: ISdkChatTurn = {
|
||||
id: `user-${++this._turnCounter}`,
|
||||
role: 'user',
|
||||
parts: [{
|
||||
kind: 'markdownContent',
|
||||
content: new MarkdownString(content),
|
||||
isStreaming: false,
|
||||
}],
|
||||
isComplete: true,
|
||||
};
|
||||
this._turns.push(turn);
|
||||
this._onDidChange.fire({ type: 'turnAdded', turnId: turn.id });
|
||||
return turn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an SDK session event and update the model accordingly.
|
||||
* Returns the affected turn (creates a new assistant turn if needed).
|
||||
*/
|
||||
handleEvent(event: ICopilotSessionEvent): ISdkChatTurn | undefined {
|
||||
const type = event.type as CopilotSessionEventType;
|
||||
|
||||
switch (type) {
|
||||
case 'user.message':
|
||||
// Usually handled by addUserMessage before send, but handle replays
|
||||
return this._ensureUserTurn(event.data.content as string ?? '');
|
||||
|
||||
case 'assistant.message_delta':
|
||||
return this._handleAssistantDelta(event.data.deltaContent ?? '');
|
||||
|
||||
case 'assistant.message':
|
||||
return this._handleAssistantComplete(event.data.content ?? '');
|
||||
|
||||
case 'assistant.reasoning_delta':
|
||||
return this._handleReasoningDelta(event.data.deltaContent ?? '');
|
||||
|
||||
case 'assistant.reasoning':
|
||||
return this._handleReasoningComplete();
|
||||
|
||||
case 'tool.execution_start':
|
||||
return this._handleToolStart(event.data.toolName ?? 'unknown', event.data.toolCallId as string | undefined);
|
||||
|
||||
case 'tool.execution_complete':
|
||||
return this._handleToolComplete(event.data.toolName ?? 'unknown');
|
||||
|
||||
case 'session.idle':
|
||||
return this._handleSessionIdle();
|
||||
|
||||
case 'session.compaction_start':
|
||||
return this._addProgressToAssistantTurn('Compacting context...');
|
||||
|
||||
case 'session.compaction_complete':
|
||||
return this._addProgressToAssistantTurn('Context compacted');
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all turns.
|
||||
*/
|
||||
clear(): void {
|
||||
this._turns.length = 0;
|
||||
this._turnCounter = 0;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private _ensureUserTurn(content: string): ISdkChatTurn {
|
||||
// If the last turn is already a user turn with matching content, skip
|
||||
const last = this._turns[this._turns.length - 1];
|
||||
if (last?.role === 'user') {
|
||||
return last;
|
||||
}
|
||||
return this.addUserMessage(content);
|
||||
}
|
||||
|
||||
private _getOrCreateAssistantTurn(): ISdkChatTurn {
|
||||
const last = this._turns[this._turns.length - 1];
|
||||
if (last?.role === 'assistant' && !last.isComplete) {
|
||||
return last;
|
||||
}
|
||||
const turn: ISdkChatTurn = {
|
||||
id: `assistant-${++this._turnCounter}`,
|
||||
role: 'assistant',
|
||||
parts: [],
|
||||
isComplete: false,
|
||||
};
|
||||
this._turns.push(turn);
|
||||
this._onDidChange.fire({ type: 'turnAdded', turnId: turn.id });
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _handleAssistantDelta(delta: string): ISdkChatTurn {
|
||||
const turn = this._getOrCreateAssistantTurn();
|
||||
const lastPart = turn.parts[turn.parts.length - 1];
|
||||
|
||||
if (lastPart?.kind === 'markdownContent' && lastPart.isStreaming) {
|
||||
// Append to existing streaming markdown part
|
||||
const current = lastPart.content.value;
|
||||
lastPart.content = new MarkdownString(current + delta, { supportThemeIcons: true });
|
||||
this._onDidChange.fire({ type: 'partUpdated', turnId: turn.id, partIndex: turn.parts.length - 1 });
|
||||
} else {
|
||||
// Start a new markdown part
|
||||
const part: ISdkMarkdownPart = {
|
||||
kind: 'markdownContent',
|
||||
content: new MarkdownString(delta, { supportThemeIcons: true }),
|
||||
isStreaming: true,
|
||||
};
|
||||
turn.parts.push(part);
|
||||
this._onDidChange.fire({ type: 'partAdded', turnId: turn.id, partIndex: turn.parts.length - 1 });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _handleAssistantComplete(content: string): ISdkChatTurn {
|
||||
const turn = this._getOrCreateAssistantTurn();
|
||||
const lastPart = turn.parts[turn.parts.length - 1];
|
||||
|
||||
if (lastPart?.kind === 'markdownContent') {
|
||||
lastPart.isStreaming = false;
|
||||
if (content) {
|
||||
lastPart.content = new MarkdownString(content, { supportThemeIcons: true });
|
||||
}
|
||||
this._onDidChange.fire({ type: 'partUpdated', turnId: turn.id, partIndex: turn.parts.length - 1 });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _handleReasoningDelta(delta: string): ISdkChatTurn {
|
||||
const turn = this._getOrCreateAssistantTurn();
|
||||
const lastPart = turn.parts[turn.parts.length - 1];
|
||||
|
||||
if (lastPart?.kind === 'thinking' && lastPart.isStreaming) {
|
||||
lastPart.content += delta;
|
||||
this._onDidChange.fire({ type: 'partUpdated', turnId: turn.id, partIndex: turn.parts.length - 1 });
|
||||
} else {
|
||||
const part: ISdkThinkingPart = {
|
||||
kind: 'thinking',
|
||||
content: delta,
|
||||
isStreaming: true,
|
||||
};
|
||||
turn.parts.push(part);
|
||||
this._onDidChange.fire({ type: 'partAdded', turnId: turn.id, partIndex: turn.parts.length - 1 });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _handleReasoningComplete(): ISdkChatTurn | undefined {
|
||||
const turn = this._turns[this._turns.length - 1];
|
||||
if (!turn || turn.role !== 'assistant') {
|
||||
return undefined;
|
||||
}
|
||||
const thinkingPart = turn.parts.findLast(p => p.kind === 'thinking' && p.isStreaming);
|
||||
if (thinkingPart && thinkingPart.kind === 'thinking') {
|
||||
thinkingPart.isStreaming = false;
|
||||
const idx = turn.parts.indexOf(thinkingPart);
|
||||
this._onDidChange.fire({ type: 'partUpdated', turnId: turn.id, partIndex: idx });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _handleToolStart(toolName: string, toolCallId?: string): ISdkChatTurn {
|
||||
const turn = this._getOrCreateAssistantTurn();
|
||||
const part: ISdkToolCallPart = {
|
||||
kind: 'toolInvocation',
|
||||
toolName,
|
||||
toolCallId,
|
||||
state: 'running',
|
||||
};
|
||||
turn.parts.push(part);
|
||||
this._onDidChange.fire({ type: 'partAdded', turnId: turn.id, partIndex: turn.parts.length - 1 });
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _handleToolComplete(toolName: string): ISdkChatTurn | undefined {
|
||||
const turn = this._turns[this._turns.length - 1];
|
||||
if (!turn || turn.role !== 'assistant') {
|
||||
return undefined;
|
||||
}
|
||||
const toolPart = turn.parts.findLast(p => p.kind === 'toolInvocation' && p.toolName === toolName && p.state === 'running');
|
||||
if (toolPart && toolPart.kind === 'toolInvocation') {
|
||||
toolPart.state = 'complete';
|
||||
const idx = turn.parts.indexOf(toolPart);
|
||||
this._onDidChange.fire({ type: 'partUpdated', turnId: turn.id, partIndex: idx });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _handleSessionIdle(): ISdkChatTurn | undefined {
|
||||
const turn = this._turns[this._turns.length - 1];
|
||||
if (turn?.role === 'assistant' && !turn.isComplete) {
|
||||
turn.isComplete = true;
|
||||
// Finalize any streaming parts
|
||||
for (const part of turn.parts) {
|
||||
if (part.kind === 'markdownContent' && part.isStreaming) {
|
||||
part.isStreaming = false;
|
||||
}
|
||||
if (part.kind === 'thinking' && part.isStreaming) {
|
||||
part.isStreaming = false;
|
||||
}
|
||||
}
|
||||
this._onDidChange.fire({ type: 'turnCompleted', turnId: turn.id });
|
||||
}
|
||||
return turn;
|
||||
}
|
||||
|
||||
private _addProgressToAssistantTurn(message: string): ISdkChatTurn {
|
||||
const turn = this._getOrCreateAssistantTurn();
|
||||
const part: ISdkProgressPart = { kind: 'progress', message };
|
||||
turn.parts.push(part);
|
||||
this._onDidChange.fire({ type: 'partAdded', turnId: turn.id, partIndex: turn.parts.length - 1 });
|
||||
return turn;
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
288
src/vs/sessions/browser/widget/sdkChatModelBridge.ts
Normal file
288
src/vs/sessions/browser/widget/sdkChatModelBridge.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* Bridges the Copilot SDK event stream into a real `ChatModel`.
|
||||
*
|
||||
* Instead of faking view model interfaces, we use the actual `ChatModel`
|
||||
* + `ChatViewModel` + `ChatListWidget` from `vs/workbench/contrib/chat/`.
|
||||
* SDK events are converted into `IChatProgress` objects which are fed into
|
||||
* `ChatModel.acceptResponseProgress()` -- the same path used by the real
|
||||
* chat system. This gives us the full rendering infrastructure for free:
|
||||
* markdown, code blocks, tool invocations, thinking, etc.
|
||||
*
|
||||
* What we bypass: `ChatService.sendRequest()`, `ChatInputPart`, and all
|
||||
* copilot-chat extension business logic. The SDK handles the LLM
|
||||
* communication; this bridge handles the model plumbing.
|
||||
*/
|
||||
|
||||
import { Disposable } from '../../../base/common/lifecycle.js';
|
||||
import { MarkdownString } from '../../../base/common/htmlContent.js';
|
||||
import { URI } from '../../../base/common/uri.js';
|
||||
import { Emitter, Event } from '../../../base/common/event.js';
|
||||
import { OffsetRange } from '../../../editor/common/core/ranges/offsetRange.js';
|
||||
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../platform/log/common/log.js';
|
||||
import { ChatModel, ChatRequestModel } from '../../../workbench/contrib/chat/common/model/chatModel.js';
|
||||
import { ChatViewModel } from '../../../workbench/contrib/chat/common/model/chatViewModel.js';
|
||||
import { ChatRequestTextPart, IParsedChatRequest } from '../../../workbench/contrib/chat/common/requestParser/chatParserTypes.js';
|
||||
import { ChatAgentLocation } from '../../../workbench/contrib/chat/common/constants.js';
|
||||
import { CodeBlockModelCollection } from '../../../workbench/contrib/chat/common/widget/codeBlockModelCollection.js';
|
||||
import { ICopilotSdkService, ICopilotSessionEvent, type CopilotSessionEventType } from '../../../platform/copilotSdk/common/copilotSdkService.js';
|
||||
import { CopilotSdkDebugLog } from '../copilotSdkDebugLog.js';
|
||||
|
||||
function makeParsedRequest(text: string): IParsedChatRequest {
|
||||
return {
|
||||
text,
|
||||
parts: [new ChatRequestTextPart(
|
||||
new OffsetRange(0, text.length),
|
||||
{ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: text.length + 1 },
|
||||
text
|
||||
)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges SDK session events into a real `ChatModel` + `ChatViewModel`
|
||||
* that can drive `ChatListWidget`.
|
||||
*/
|
||||
export class SdkChatModelBridge extends Disposable {
|
||||
|
||||
private readonly _chatModel: ChatModel;
|
||||
private readonly _chatViewModel: ChatViewModel;
|
||||
private readonly _codeBlockModelCollection: CodeBlockModelCollection;
|
||||
|
||||
/** The current in-flight request (set between send and session.idle). */
|
||||
private _activeRequest: ChatRequestModel | undefined;
|
||||
private _sessionId: string | undefined;
|
||||
|
||||
private readonly _onDidChangeSessionId = this._register(new Emitter<string | undefined>());
|
||||
readonly onDidChangeSessionId: Event<string | undefined> = this._onDidChangeSessionId.event;
|
||||
|
||||
constructor(
|
||||
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||
@ICopilotSdkService private readonly _sdk: ICopilotSdkService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) {
|
||||
super();
|
||||
|
||||
// Create a real ChatModel -- no serialized data, fresh session
|
||||
this._chatModel = this._register(this._instantiationService.createInstance(
|
||||
ChatModel,
|
||||
undefined, // no serialized data
|
||||
{
|
||||
initialLocation: ChatAgentLocation.Chat,
|
||||
canUseTools: true,
|
||||
disableBackgroundKeepAlive: true, // prevents ChatService.getActiveSessionReference calls
|
||||
}
|
||||
));
|
||||
|
||||
// Create CodeBlockModelCollection for the view model
|
||||
this._codeBlockModelCollection = this._register(
|
||||
this._instantiationService.createInstance(CodeBlockModelCollection, 'sdkChatBridge')
|
||||
);
|
||||
|
||||
// Create a real ChatViewModel wrapping the model
|
||||
this._chatViewModel = this._register(this._instantiationService.createInstance(
|
||||
ChatViewModel,
|
||||
this._chatModel,
|
||||
this._codeBlockModelCollection,
|
||||
undefined, // no options
|
||||
));
|
||||
|
||||
// Subscribe to SDK events
|
||||
this._register(this._sdk.onSessionEvent(event => {
|
||||
if (this._sessionId && event.sessionId !== this._sessionId) {
|
||||
return;
|
||||
}
|
||||
this._handleSdkEvent(event);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ChatViewModel` to pass to `ChatListWidget.setViewModel()`.
|
||||
*/
|
||||
get viewModel(): ChatViewModel {
|
||||
return this._chatViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ChatModel` for direct access.
|
||||
*/
|
||||
get chatModel(): ChatModel {
|
||||
return this._chatModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CodeBlockModelCollection` for the `ChatListWidget`.
|
||||
*/
|
||||
get codeBlockModelCollection(): CodeBlockModelCollection {
|
||||
return this._codeBlockModelCollection;
|
||||
}
|
||||
|
||||
get sessionResource(): URI {
|
||||
return this._chatModel.sessionResource;
|
||||
}
|
||||
|
||||
get sessionId(): string | undefined {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user message and start expecting a response.
|
||||
*/
|
||||
addUserMessage(text: string): ChatRequestModel {
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'bridge.addUserMessage', text.substring(0, 80));
|
||||
const parsed = makeParsedRequest(text);
|
||||
const request = this._chatModel.addRequest(
|
||||
parsed,
|
||||
{ variables: [] },
|
||||
0, // attempt
|
||||
);
|
||||
this._activeRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active SDK session ID.
|
||||
*/
|
||||
setSessionId(sessionId: string | undefined): void {
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'bridge.setSessionId', sessionId?.substring(0, 8) ?? 'none');
|
||||
this._sessionId = sessionId;
|
||||
this._onDidChangeSessionId.fire(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the model for a new session.
|
||||
*/
|
||||
clear(): void {
|
||||
this._activeRequest = undefined;
|
||||
this._sessionId = undefined;
|
||||
|
||||
// Remove all requests from the model
|
||||
for (const request of [...this._chatModel.getRequests()]) {
|
||||
this._chatModel.removeRequest(request.id);
|
||||
}
|
||||
this._onDidChangeSessionId.fire(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a session's history by replaying SDK events through the model.
|
||||
*/
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'bridge.loadSession', sessionId.substring(0, 8));
|
||||
this.clear();
|
||||
this._sessionId = sessionId;
|
||||
this._onDidChangeSessionId.fire(sessionId);
|
||||
|
||||
try {
|
||||
await this._sdk.resumeSession(sessionId, { streaming: true });
|
||||
const events = await this._sdk.getMessages(sessionId);
|
||||
|
||||
for (const event of events) {
|
||||
this._handleSdkEvent(event);
|
||||
}
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2190', 'bridge.loadSession', `${events.length} events replayed`);
|
||||
} catch (err) {
|
||||
this._logService.error(`[SdkChatModelBridge] Failed to load session ${sessionId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- SDK Event → ChatModel Progress ---
|
||||
|
||||
private _handleSdkEvent(event: ICopilotSessionEvent): void {
|
||||
const type = event.type as CopilotSessionEventType;
|
||||
|
||||
switch (type) {
|
||||
case 'user.message': {
|
||||
// During replay, add user messages we haven't added yet
|
||||
const text = (event.data.content as string) ?? '';
|
||||
if (text) {
|
||||
this.addUserMessage(text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assistant.message_delta': {
|
||||
const delta = event.data.deltaContent ?? '';
|
||||
if (delta && this._activeRequest) {
|
||||
this._chatModel.acceptResponseProgress(this._activeRequest, {
|
||||
kind: 'markdownContent',
|
||||
content: new MarkdownString(delta),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assistant.message': {
|
||||
// Final complete message -- the deltas already built the content,
|
||||
// so we just ensure the response is finalized
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assistant.reasoning_delta': {
|
||||
const delta = event.data.deltaContent ?? '';
|
||||
if (delta && this._activeRequest) {
|
||||
this._chatModel.acceptResponseProgress(this._activeRequest, {
|
||||
kind: 'thinking',
|
||||
value: delta,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assistant.reasoning': {
|
||||
// Reasoning complete -- no action needed, the deltas already built it
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool.execution_start': {
|
||||
const toolName = event.data.toolName ?? 'unknown';
|
||||
if (this._activeRequest) {
|
||||
this._chatModel.acceptResponseProgress(this._activeRequest, {
|
||||
kind: 'progressMessage',
|
||||
content: new MarkdownString(`Running ${toolName}...`),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool.execution_complete': {
|
||||
const toolName = event.data.toolName ?? 'unknown';
|
||||
if (this._activeRequest) {
|
||||
this._chatModel.acceptResponseProgress(this._activeRequest, {
|
||||
kind: 'progressMessage',
|
||||
content: new MarkdownString(`${toolName} completed`),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session.idle': {
|
||||
if (this._activeRequest?.response) {
|
||||
this._activeRequest.response.setResult({ metadata: {} });
|
||||
this._activeRequest.response.complete();
|
||||
}
|
||||
this._activeRequest = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session.compaction_start': {
|
||||
if (this._activeRequest) {
|
||||
this._chatModel.acceptResponseProgress(this._activeRequest, {
|
||||
kind: 'progressMessage',
|
||||
content: new MarkdownString('Compacting context...'),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session.compaction_complete': {
|
||||
// No action needed
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/vs/sessions/browser/widget/sdkChatViewPane.ts
Normal file
80
src/vs/sessions/browser/widget/sdkChatViewPane.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { $, append } from '../../../base/browser/dom.js';
|
||||
import { localize2 } from '../../../nls.js';
|
||||
import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js';
|
||||
import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js';
|
||||
import { IConfigurationService } from '../../../platform/configuration/common/configuration.js';
|
||||
import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js';
|
||||
import { IViewDescriptorService } from '../../../workbench/common/views.js';
|
||||
import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js';
|
||||
import { IOpenerService } from '../../../platform/opener/common/opener.js';
|
||||
import { IHoverService } from '../../../platform/hover/browser/hover.js';
|
||||
import { IThemeService } from '../../../platform/theme/common/themeService.js';
|
||||
import { ViewPane, IViewPaneOptions } from '../../../workbench/browser/parts/views/viewPane.js';
|
||||
import { SdkChatWidget } from './sdkChatWidget.js';
|
||||
|
||||
export const SdkChatViewId = 'workbench.panel.chat.view.sdkChat';
|
||||
|
||||
export class SdkChatViewPane extends ViewPane {
|
||||
|
||||
static readonly ID = SdkChatViewId;
|
||||
static readonly TITLE = localize2('sdkChatViewPane.title', "Chat");
|
||||
|
||||
private _widget: SdkChatWidget | undefined;
|
||||
|
||||
get widget(): SdkChatWidget | undefined {
|
||||
return this._widget;
|
||||
}
|
||||
|
||||
constructor(
|
||||
options: IViewPaneOptions,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
@IContextMenuService contextMenuService: IContextMenuService,
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IContextKeyService contextKeyService: IContextKeyService,
|
||||
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IOpenerService openerService: IOpenerService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IHoverService hoverService: IHoverService,
|
||||
) {
|
||||
super(
|
||||
{ ...options },
|
||||
keybindingService,
|
||||
contextMenuService,
|
||||
configurationService,
|
||||
contextKeyService,
|
||||
viewDescriptorService,
|
||||
instantiationService,
|
||||
openerService,
|
||||
themeService,
|
||||
hoverService,
|
||||
);
|
||||
}
|
||||
|
||||
protected override renderBody(container: HTMLElement): void {
|
||||
super.renderBody(container);
|
||||
|
||||
const widgetContainer = append(container, $('.sdk-chat-view-pane-container'));
|
||||
widgetContainer.style.height = '100%';
|
||||
this._widget = this._register(this.instantiationService.createInstance(SdkChatWidget, widgetContainer));
|
||||
}
|
||||
|
||||
protected override layoutBody(height: number, width: number): void {
|
||||
super.layoutBody(height, width);
|
||||
this._widget?.layout(width, height);
|
||||
}
|
||||
|
||||
override focus(): void {
|
||||
super.focus();
|
||||
this._widget?.focus();
|
||||
}
|
||||
|
||||
override shouldShowWelcome(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
419
src/vs/sessions/browser/widget/sdkChatWidget.ts
Normal file
419
src/vs/sessions/browser/widget/sdkChatWidget.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import './media/sdkChatWidget.css';
|
||||
import * as dom from '../../../base/browser/dom.js';
|
||||
import { Codicon } from '../../../base/common/codicons.js';
|
||||
import { Emitter, Event } from '../../../base/common/event.js';
|
||||
import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js';
|
||||
import { renderMarkdown } from '../../../base/browser/markdownRenderer.js';
|
||||
import { localize } from '../../../nls.js';
|
||||
import { ThemeIcon } from '../../../base/common/themables.js';
|
||||
import { type ICopilotModelInfo, ICopilotSdkService } from '../../../platform/copilotSdk/common/copilotSdkService.js';
|
||||
import { ILogService } from '../../../platform/log/common/log.js';
|
||||
import { SdkChatModel, type ISdkMarkdownPart, type ISdkThinkingPart, type ISdkToolCallPart, type ISdkChatModelChange, type SdkChatPart } from './sdkChatModel.js';
|
||||
import { CopilotSdkDebugLog } from '../copilotSdkDebugLog.js';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
interface IRenderedTurn {
|
||||
readonly turnId: string;
|
||||
readonly element: HTMLElement;
|
||||
readonly partElements: Map<number, HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat widget powered by the Copilot SDK. Uses `SdkChatModel` for data and
|
||||
* renders using VS Code's `renderMarkdown` for rich content. No dependency
|
||||
* on `ChatWidget`, `ChatInputPart`, `ChatService`, or the copilot-chat extension.
|
||||
*/
|
||||
export class SdkChatWidget extends Disposable {
|
||||
|
||||
readonly element: HTMLElement;
|
||||
|
||||
private readonly _messagesContainer: HTMLElement;
|
||||
private readonly _welcomeContainer: HTMLElement;
|
||||
private readonly _inputArea: HTMLElement;
|
||||
private readonly _textarea: HTMLTextAreaElement;
|
||||
private readonly _sendBtn: HTMLButtonElement;
|
||||
private readonly _abortBtn: HTMLButtonElement;
|
||||
private readonly _modelSelect: HTMLSelectElement;
|
||||
private readonly _statusBar: HTMLElement;
|
||||
|
||||
private readonly _model: SdkChatModel;
|
||||
private readonly _renderedTurns = new Map<string, IRenderedTurn>();
|
||||
|
||||
private _sessionId: string | undefined;
|
||||
private _isStreaming = false;
|
||||
private _autoScroll = true;
|
||||
|
||||
private readonly _eventDisposables = this._register(new DisposableStore());
|
||||
|
||||
private readonly _onDidChangeState = this._register(new Emitter<void>());
|
||||
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
|
||||
|
||||
private readonly _onDidChangeSessionId = this._register(new Emitter<string | undefined>());
|
||||
readonly onDidChangeSessionId: Event<string | undefined> = this._onDidChangeSessionId.event;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
@ICopilotSdkService private readonly _sdk: ICopilotSdkService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._model = this._register(new SdkChatModel());
|
||||
|
||||
this.element = dom.append(container, $('.sdk-chat-widget'));
|
||||
|
||||
// Welcome
|
||||
this._welcomeContainer = dom.append(this.element, $('.sdk-chat-welcome'));
|
||||
dom.append(this._welcomeContainer, $('.sdk-chat-welcome-title')).textContent = localize('sdkChat.welcome.title', "Copilot Agent");
|
||||
dom.append(this._welcomeContainer, $('.sdk-chat-welcome-subtitle')).textContent = localize('sdkChat.welcome.subtitle', "Ask me anything. Powered by the Copilot SDK.");
|
||||
|
||||
// Messages
|
||||
this._messagesContainer = dom.append(this.element, $('.sdk-chat-messages'));
|
||||
this._messagesContainer.style.display = 'none';
|
||||
this._register(dom.addDisposableListener(this._messagesContainer, 'scroll', () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this._messagesContainer;
|
||||
this._autoScroll = scrollHeight - scrollTop - clientHeight < 50;
|
||||
}));
|
||||
|
||||
// Status
|
||||
this._statusBar = dom.append(this.element, $('.sdk-chat-status'));
|
||||
this._setStatus(localize('sdkChat.status.initializing', "Initializing..."));
|
||||
|
||||
// Input area
|
||||
this._inputArea = dom.append(this.element, $('.sdk-chat-input-area'));
|
||||
|
||||
const modelRow = dom.append(this._inputArea, $('.sdk-chat-model-row'));
|
||||
dom.append(modelRow, $('.sdk-chat-model-label')).textContent = localize('sdkChat.model', "Model:");
|
||||
this._modelSelect = dom.append(modelRow, $('select.sdk-chat-model-select')) as HTMLSelectElement;
|
||||
|
||||
const inputRow = dom.append(this._inputArea, $('.sdk-chat-input-row'));
|
||||
const inputWrapper = dom.append(inputRow, $('.sdk-chat-input-wrapper'));
|
||||
this._textarea = dom.append(inputWrapper, $('textarea.sdk-chat-textarea')) as HTMLTextAreaElement;
|
||||
this._textarea.placeholder = localize('sdkChat.placeholder', "Ask Copilot...");
|
||||
this._textarea.rows = 1;
|
||||
|
||||
this._sendBtn = dom.append(inputRow, $('button.sdk-chat-send-btn')) as HTMLButtonElement;
|
||||
dom.append(this._sendBtn, $(`span${ThemeIcon.asCSSSelector(Codicon.send)}`)).classList.add('codicon');
|
||||
|
||||
this._abortBtn = dom.append(inputRow, $('button.sdk-chat-abort-btn')) as HTMLButtonElement;
|
||||
dom.append(this._abortBtn, $(`span${ThemeIcon.asCSSSelector(Codicon.debugStop)}`)).classList.add('codicon');
|
||||
this._abortBtn.style.display = 'none';
|
||||
|
||||
// Wire events
|
||||
this._register(dom.addDisposableListener(this._textarea, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._handleSend(); }
|
||||
}));
|
||||
this._register(dom.addDisposableListener(this._textarea, 'input', () => this._autoResizeTextarea()));
|
||||
this._register(dom.addDisposableListener(this._sendBtn, 'click', () => this._handleSend()));
|
||||
this._register(dom.addDisposableListener(this._abortBtn, 'click', () => this._handleAbort()));
|
||||
|
||||
// Model changes -> rendering
|
||||
this._register(this._model.onDidChange(change => this._onModelChange(change)));
|
||||
|
||||
// SDK events -> model
|
||||
this._subscribeToSdkEvents();
|
||||
|
||||
// Init
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
focus(): void { this._textarea.focus(); }
|
||||
get sessionId(): string | undefined { return this._sessionId; }
|
||||
get isStreaming(): boolean { return this._isStreaming; }
|
||||
get model(): SdkChatModel { return this._model; }
|
||||
|
||||
// --- Init ---
|
||||
|
||||
private async _initialize(): Promise<void> {
|
||||
try {
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'widget.start', '');
|
||||
await this._sdk.start();
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2190', 'widget.start', 'OK');
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'widget.listModels', '');
|
||||
const models = await this._sdk.listModels();
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2190', 'widget.listModels', `${models.length} models`);
|
||||
this._populateModelSelect(models);
|
||||
this._setStatus(localize('sdkChat.status.ready', "Ready"));
|
||||
this._textarea.disabled = false;
|
||||
this._sendBtn.disabled = false;
|
||||
} catch (err) {
|
||||
this._logService.error('[SdkChatWidget] Init failed:', err);
|
||||
this._setStatus(localize('sdkChat.status.error', "Failed to connect to Copilot SDK"));
|
||||
}
|
||||
}
|
||||
|
||||
private _populateModelSelect(models: ICopilotModelInfo[]): void {
|
||||
dom.clearNode(this._modelSelect);
|
||||
for (const m of models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.id;
|
||||
opt.textContent = m.name ?? m.id;
|
||||
this._modelSelect.appendChild(opt);
|
||||
}
|
||||
const preferred = models.find(m => m.id === 'claude-sonnet-4') ?? models.find(m => m.id === 'gpt-4.1') ?? models[0];
|
||||
if (preferred) { this._modelSelect.value = preferred.id; }
|
||||
}
|
||||
|
||||
// --- SDK events -> model ---
|
||||
|
||||
private _subscribeToSdkEvents(): void {
|
||||
this._eventDisposables.clear();
|
||||
this._eventDisposables.add(this._sdk.onSessionEvent(event => {
|
||||
if (this._sessionId && event.sessionId !== this._sessionId) { return; }
|
||||
this._model.handleEvent(event);
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Model -> DOM ---
|
||||
|
||||
private _onModelChange(change: ISdkChatModelChange): void {
|
||||
// Ensure messages visible
|
||||
if (this._model.turns.length > 0) {
|
||||
this._welcomeContainer.style.display = 'none';
|
||||
this._messagesContainer.style.display = '';
|
||||
}
|
||||
|
||||
switch (change.type) {
|
||||
case 'turnAdded': this._renderTurn(change.turnId); break;
|
||||
case 'partAdded': this._renderPart(change.turnId, change.partIndex!); break;
|
||||
case 'partUpdated': this._updatePart(change.turnId, change.partIndex!); break;
|
||||
case 'turnCompleted': this._finalizeTurn(change.turnId); break;
|
||||
}
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
private _renderTurn(turnId: string): void {
|
||||
const turn = this._model.turns.find(t => t.id === turnId);
|
||||
if (!turn) { return; }
|
||||
|
||||
const turnEl = dom.append(this._messagesContainer, $(`.sdk-chat-message.${turn.role}`));
|
||||
|
||||
const header = dom.append(turnEl, $('.sdk-chat-message-header'));
|
||||
const headerIcon = turn.role === 'user' ? Codicon.account : Codicon.sparkle;
|
||||
dom.append(header, $(`span${ThemeIcon.asCSSSelector(headerIcon)}`)).classList.add('codicon');
|
||||
dom.append(header, $('span')).textContent = turn.role === 'user'
|
||||
? localize('sdkChat.you', "You")
|
||||
: localize('sdkChat.copilot', "Copilot");
|
||||
|
||||
const rendered: IRenderedTurn = { turnId, element: turnEl, partElements: new Map() };
|
||||
this._renderedTurns.set(turnId, rendered);
|
||||
|
||||
for (let i = 0; i < turn.parts.length; i++) {
|
||||
this._appendPart(turn.parts[i], turnEl, rendered, i);
|
||||
}
|
||||
}
|
||||
|
||||
private _renderPart(turnId: string, partIndex: number): void {
|
||||
const rendered = this._renderedTurns.get(turnId);
|
||||
const turn = this._model.turns.find(t => t.id === turnId);
|
||||
if (!rendered || !turn) { return; }
|
||||
this._appendPart(turn.parts[partIndex], rendered.element, rendered, partIndex);
|
||||
}
|
||||
|
||||
private _appendPart(part: SdkChatPart, container: HTMLElement, rendered: IRenderedTurn, index: number): void {
|
||||
if (!part) { return; }
|
||||
const el = this._createPartElement(part);
|
||||
container.appendChild(el);
|
||||
rendered.partElements.set(index, el);
|
||||
}
|
||||
|
||||
private _createPartElement(part: SdkChatPart): HTMLElement {
|
||||
switch (part.kind) {
|
||||
case 'markdownContent': return this._createMarkdownEl(part);
|
||||
case 'thinking': return this._createThinkingEl(part);
|
||||
case 'toolInvocation': return this._createToolCallEl(part);
|
||||
case 'progress': return this._createProgressEl(part.message);
|
||||
}
|
||||
}
|
||||
|
||||
private _createMarkdownEl(part: ISdkMarkdownPart): HTMLElement {
|
||||
const el = $('.sdk-chat-message-body');
|
||||
if (part.isStreaming) { el.classList.add('sdk-chat-streaming-cursor'); }
|
||||
el.appendChild(renderMarkdown(part.content).element);
|
||||
return el;
|
||||
}
|
||||
|
||||
private _createThinkingEl(part: ISdkThinkingPart): HTMLElement {
|
||||
const el = $('.sdk-chat-reasoning');
|
||||
el.textContent = part.content;
|
||||
return el;
|
||||
}
|
||||
|
||||
private _createToolCallEl(part: ISdkToolCallPart): HTMLElement {
|
||||
const el = $(`.sdk-chat-tool-call.${part.state === 'running' ? 'running' : 'complete'}`);
|
||||
const iconCodicon = part.state === 'running' ? Codicon.loading : Codicon.check;
|
||||
const iconEl = dom.append(el, $(`span${ThemeIcon.asCSSSelector(iconCodicon)}`));
|
||||
iconEl.classList.add('codicon');
|
||||
if (part.state === 'running') { iconEl.classList.add('codicon-loading'); }
|
||||
dom.append(el, $('span.sdk-chat-tool-name')).textContent = part.toolName;
|
||||
dom.append(el, $('span.sdk-chat-tool-status')).textContent = part.state === 'running'
|
||||
? localize('sdkChat.tool.running', "Running...")
|
||||
: localize('sdkChat.tool.done', "Done");
|
||||
return el;
|
||||
}
|
||||
|
||||
private _createProgressEl(message: string): HTMLElement {
|
||||
const el = $('.sdk-chat-progress');
|
||||
el.textContent = message;
|
||||
return el;
|
||||
}
|
||||
|
||||
private _updatePart(turnId: string, partIndex: number): void {
|
||||
const rendered = this._renderedTurns.get(turnId);
|
||||
const turn = this._model.turns.find(t => t.id === turnId);
|
||||
if (!rendered || !turn) { return; }
|
||||
const part = turn.parts[partIndex];
|
||||
const existingEl = rendered.partElements.get(partIndex);
|
||||
if (!part || !existingEl) { return; }
|
||||
|
||||
switch (part.kind) {
|
||||
case 'markdownContent': {
|
||||
dom.clearNode(existingEl);
|
||||
existingEl.appendChild(renderMarkdown(part.content).element);
|
||||
existingEl.classList.toggle('sdk-chat-streaming-cursor', part.isStreaming);
|
||||
break;
|
||||
}
|
||||
case 'thinking': {
|
||||
existingEl.textContent = part.content;
|
||||
break;
|
||||
}
|
||||
case 'toolInvocation': {
|
||||
const newEl = this._createToolCallEl(part);
|
||||
existingEl.replaceWith(newEl);
|
||||
rendered.partElements.set(partIndex, newEl);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _finalizeTurn(turnId: string): void {
|
||||
const rendered = this._renderedTurns.get(turnId);
|
||||
if (rendered) {
|
||||
for (const el of rendered.partElements.values()) {
|
||||
el.classList.remove('sdk-chat-streaming-cursor');
|
||||
}
|
||||
}
|
||||
this._setStreaming(false);
|
||||
this._setStatus(localize('sdkChat.status.ready', "Ready"));
|
||||
}
|
||||
|
||||
// --- Send / Abort ---
|
||||
|
||||
private async _handleSend(): Promise<void> {
|
||||
const prompt = this._textarea.value.trim();
|
||||
if (!prompt || this._isStreaming) { return; }
|
||||
|
||||
this._textarea.value = '';
|
||||
this._autoResizeTextarea();
|
||||
this._model.addUserMessage(prompt);
|
||||
this._setStreaming(true);
|
||||
|
||||
try {
|
||||
if (!this._sessionId) {
|
||||
const model = this._modelSelect.value;
|
||||
this._setStatus(localize('sdkChat.status.creating', "Creating session..."));
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'widget.createSession', JSON.stringify({ model }));
|
||||
this._sessionId = await this._sdk.createSession({ model, streaming: true });
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2190', 'widget.createSession', this._sessionId.substring(0, 8));
|
||||
this._onDidChangeSessionId.fire(this._sessionId);
|
||||
}
|
||||
|
||||
this._setStatus(localize('sdkChat.status.thinking', "Thinking..."));
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'widget.send', prompt.substring(0, 80));
|
||||
await this._sdk.send(this._sessionId, prompt);
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2190', 'widget.send', 'queued');
|
||||
} catch (err) {
|
||||
this._logService.error('[SdkChatWidget] Send failed:', err);
|
||||
this._setStreaming(false);
|
||||
this._setStatus(localize('sdkChat.status.sendFailed', "Send failed"));
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleAbort(): Promise<void> {
|
||||
if (!this._sessionId) { return; }
|
||||
try {
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'widget.abort', this._sessionId.substring(0, 8));
|
||||
await this._sdk.abort(this._sessionId);
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2190', 'widget.abort', 'OK');
|
||||
} catch (err) {
|
||||
this._logService.error('[SdkChatWidget] Abort failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI helpers ---
|
||||
|
||||
private _setStreaming(streaming: boolean): void {
|
||||
this._isStreaming = streaming;
|
||||
this._sendBtn.style.display = streaming ? 'none' : '';
|
||||
this._abortBtn.style.display = streaming ? '' : 'none';
|
||||
this._textarea.disabled = streaming;
|
||||
this._onDidChangeState.fire();
|
||||
}
|
||||
|
||||
private _setStatus(text: string): void { this._statusBar.textContent = text; }
|
||||
|
||||
private _scrollToBottom(): void {
|
||||
if (this._autoScroll) { this._messagesContainer.scrollTop = this._messagesContainer.scrollHeight; }
|
||||
}
|
||||
|
||||
private _autoResizeTextarea(): void {
|
||||
this._textarea.style.height = 'auto';
|
||||
this._textarea.style.height = `${Math.min(this._textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
async newSession(): Promise<void> {
|
||||
if (this._sessionId) {
|
||||
try { await this._sdk.destroySession(this._sessionId); } catch { /* best-effort */ }
|
||||
}
|
||||
this._sessionId = undefined;
|
||||
this._model.clear();
|
||||
this._renderedTurns.clear();
|
||||
dom.clearNode(this._messagesContainer);
|
||||
this._messagesContainer.style.display = 'none';
|
||||
this._welcomeContainer.style.display = '';
|
||||
this._setStreaming(false);
|
||||
this._setStatus(localize('sdkChat.status.ready', "Ready"));
|
||||
this._onDidChangeSessionId.fire(undefined);
|
||||
this._textarea.focus();
|
||||
}
|
||||
|
||||
async loadSession(sessionId: string): Promise<void> {
|
||||
if (this._sessionId === sessionId) { return; }
|
||||
|
||||
this._model.clear();
|
||||
this._renderedTurns.clear();
|
||||
dom.clearNode(this._messagesContainer);
|
||||
|
||||
this._sessionId = sessionId;
|
||||
this._onDidChangeSessionId.fire(sessionId);
|
||||
|
||||
try {
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2192', 'widget.loadSession', sessionId.substring(0, 8));
|
||||
await this._sdk.resumeSession(sessionId, { streaming: true });
|
||||
const events = await this._sdk.getMessages(sessionId);
|
||||
CopilotSdkDebugLog.instance?.addEntry('\u2190', 'widget.loadSession', `${events.length} events`);
|
||||
|
||||
this._welcomeContainer.style.display = 'none';
|
||||
this._messagesContainer.style.display = '';
|
||||
|
||||
for (const event of events) { this._model.handleEvent(event); }
|
||||
this._setStatus(localize('sdkChat.status.ready', "Ready"));
|
||||
} catch (err) {
|
||||
this._logService.error(`[SdkChatWidget] Load session failed:`, err);
|
||||
this._setStatus(localize('sdkChat.status.loadFailed', "Failed to load session"));
|
||||
}
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
layout(_width: number, _height: number): void {
|
||||
// CSS flexbox handles layout
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,14 @@ import { IHostService } from '../../../../workbench/services/host/browser/host.j
|
||||
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
|
||||
import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
|
||||
import { Registry } from '../../../../platform/registry/common/platform.js';
|
||||
import { CopilotSdkDebugPanel } from '../../../browser/copilotSdkDebugPanel.js';
|
||||
import { CopilotSdkDebugLog } from '../../../browser/copilotSdkDebugLog.js';
|
||||
import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, Extensions as ViewExtensions, WindowVisibility } from '../../../../workbench/common/views.js';
|
||||
import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js';
|
||||
import { ChatViewContainerId } from '../../../../workbench/contrib/chat/browser/chat.js';
|
||||
import { SdkChatViewPane, SdkChatViewId } from '../../../browser/widget/sdkChatViewPane.js';
|
||||
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
|
||||
import { isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
|
||||
import { IActiveSessionService } from '../../sessions/browser/activeSessionService.js';
|
||||
@@ -115,6 +122,29 @@ registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution,
|
||||
// register services
|
||||
registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed);
|
||||
|
||||
// --- SDK Chat View Registration (sessions window only) ---
|
||||
// Replaces the default ChatViewPane (which uses ChatInputPart + copilot-chat extension)
|
||||
// with SdkChatViewPane (which uses ChatListWidget renderer + our own input + Copilot SDK)
|
||||
const sdkChatViewContainer = Registry.as<IViewContainersRegistry>(ViewExtensions.ViewContainersRegistry).get(ChatViewContainerId);
|
||||
if (sdkChatViewContainer) {
|
||||
const sdkChatViewDescriptor: IViewDescriptor = {
|
||||
id: SdkChatViewId,
|
||||
containerIcon: sdkChatViewContainer.icon,
|
||||
containerTitle: sdkChatViewContainer.title.value,
|
||||
singleViewPaneContainerTitle: sdkChatViewContainer.title.value,
|
||||
name: localize2('sdkChat.viewContainer.label', "Chat"),
|
||||
canToggleVisibility: false,
|
||||
canMoveView: false,
|
||||
ctorDescriptor: new SyncDescriptor(SdkChatViewPane),
|
||||
when: IsSessionsWindowContext,
|
||||
windowVisibility: WindowVisibility.Both,
|
||||
};
|
||||
Registry.as<IViewsRegistry>(ViewExtensions.ViewsRegistry).registerViews([sdkChatViewDescriptor], sdkChatViewContainer);
|
||||
}
|
||||
|
||||
// Register the debug log contribution so it captures all SDK events from startup
|
||||
registerWorkbenchContribution2(CopilotSdkDebugLog.ID, CopilotSdkDebugLog, WorkbenchPhase.AfterRestored);
|
||||
|
||||
// --- Temporary debug panel for Copilot SDK (delete this block + copilotSdkDebugPanel.ts to remove) ---
|
||||
let activeDebugBackdrop: HTMLElement | undefined;
|
||||
registerAction2(class CopilotSdkDebugPanelAction extends Action2 {
|
||||
@@ -150,7 +180,7 @@ registerAction2(class CopilotSdkDebugPanelAction extends Action2 {
|
||||
modal.style.cssText = 'width:560px;height:80%;max-height:700px;border-radius:8px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.4);';
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const panel = instantiationService.createInstance(CopilotSdkDebugPanel, modal);
|
||||
const panel = instantiationService.createInstance(CopilotSdkDebugPanel, modal, CopilotSdkDebugLog.instance!);
|
||||
|
||||
const close = () => {
|
||||
panel.dispose();
|
||||
|
||||
@@ -205,11 +205,92 @@
|
||||
|
||||
.agent-sessions-control-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Override section header padding to align with dot indicators */
|
||||
.agent-session-section {
|
||||
padding-left: 12px;
|
||||
}
|
||||
/* SDK Session list items */
|
||||
.sdk-session-list-empty {
|
||||
padding: 16px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sdk-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
margin: 0 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sdk-session-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.sdk-session-item:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.sdk-session-item.selected {
|
||||
background-color: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
.sdk-session-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sdk-session-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sdk-session-label {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sdk-session-path {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sdk-session-item.selected .sdk-session-path {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sdk-session-time {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sdk-session-item.selected .sdk-session-time {
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
|
||||
import { autorun } from '../../../../base/common/observable.js';
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
|
||||
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
|
||||
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
@@ -18,22 +18,15 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi
|
||||
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
|
||||
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
|
||||
import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js';
|
||||
import { IViewDescriptorService, ViewContainerLocation } from '../../../../workbench/common/views.js';
|
||||
import { IViewDescriptorService } from '../../../../workbench/common/views.js';
|
||||
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
||||
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
|
||||
import { localize, localize2 } from '../../../../nls.js';
|
||||
import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js';
|
||||
import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js';
|
||||
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
|
||||
import { IActiveSessionService } from './activeSessionService.js';
|
||||
import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
|
||||
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
|
||||
import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';
|
||||
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
|
||||
import { Button } from '../../../../base/browser/ui/button/button.js';
|
||||
import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js';
|
||||
import { ICommandService } from '../../../../platform/commands/common/commands.js';
|
||||
import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
|
||||
import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
|
||||
import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js';
|
||||
import { AICustomizationManagementSection } from '../../aiCustomizationManagement/browser/aiCustomizationManagement.js';
|
||||
import { AICustomizationManagementEditorInput } from '../../aiCustomizationManagement/browser/aiCustomizationManagementEditorInput.js';
|
||||
@@ -46,14 +39,13 @@ import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.j
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
|
||||
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
|
||||
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
|
||||
import { ICopilotSdkService, type ICopilotSessionMetadata } from '../../../../platform/copilotSdk/common/copilotSdkService.js';
|
||||
import { ILogService } from '../../../../platform/log/common/log.js';
|
||||
import { SdkChatViewPane, SdkChatViewId } from '../../../browser/widget/sdkChatViewPane.js';
|
||||
|
||||
const $ = DOM.$;
|
||||
export const SessionsViewId = 'agentic.workbench.view.sessionsView';
|
||||
const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu');
|
||||
|
||||
/**
|
||||
* Per-source breakdown of item counts.
|
||||
*/
|
||||
interface ISourceCounts {
|
||||
readonly workspace: number;
|
||||
readonly user: number;
|
||||
@@ -65,22 +57,24 @@ interface IShortcutItem {
|
||||
readonly icon: ThemeIcon;
|
||||
readonly action: () => Promise<void>;
|
||||
readonly getSourceCounts?: () => Promise<ISourceCounts>;
|
||||
/** For items without per-source breakdown (MCP, Models). */
|
||||
readonly getCount?: () => Promise<number>;
|
||||
countContainer?: HTMLElement;
|
||||
}
|
||||
|
||||
const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed';
|
||||
const NEW_SDK_SESSION_ID = 'sdkSessions.newSession';
|
||||
|
||||
export class AgenticSessionsViewPane extends ViewPane {
|
||||
|
||||
private viewPaneContainer: HTMLElement | undefined;
|
||||
private newSessionButtonContainer: HTMLElement | undefined;
|
||||
private sessionsControlContainer: HTMLElement | undefined;
|
||||
sessionsControl: AgentSessionsControl | undefined;
|
||||
private sessionsListContainer: HTMLElement | undefined;
|
||||
private aiCustomizationContainer: HTMLElement | undefined;
|
||||
private readonly shortcuts: IShortcutItem[] = [];
|
||||
|
||||
private _sdkSessions: ICopilotSessionMetadata[] = [];
|
||||
private _selectedSessionId: string | undefined;
|
||||
private readonly _sessionListDisposables = new DisposableStore();
|
||||
|
||||
constructor(
|
||||
options: IViewPaneOptions,
|
||||
@IKeybindingService keybindingService: IKeybindingService,
|
||||
@@ -92,8 +86,6 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
@IOpenerService openerService: IOpenerService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IHoverService hoverService: IHoverService,
|
||||
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
|
||||
@ICommandService private readonly commandService: ICommandService,
|
||||
@IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService,
|
||||
@IPromptsService private readonly promptsService: IPromptsService,
|
||||
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
|
||||
@@ -101,10 +93,12 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
@IStorageService private readonly storageService: IStorageService,
|
||||
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
|
||||
@IActiveSessionService private readonly activeSessionService: IActiveSessionService,
|
||||
@ICopilotSdkService private readonly copilotSdkService: ICopilotSdkService,
|
||||
@ILogService private readonly logService: ILogService,
|
||||
@IViewsService private readonly viewsService: IViewsService,
|
||||
) {
|
||||
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
|
||||
|
||||
// Initialize shortcuts
|
||||
this.shortcuts = [
|
||||
{ label: localize('agents', "Agents"), icon: agentIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Agents), getSourceCounts: () => this.getPromptSourceCounts(PromptsType.agent) },
|
||||
{ label: localize('skills', "Skills"), icon: skillIcon, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Skills), getSourceCounts: () => this.getSkillSourceCounts() },
|
||||
@@ -115,134 +109,176 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
{ label: localize('models', "Models"), icon: Codicon.vm, action: () => this.openAICustomizationSection(AICustomizationManagementSection.Models), getCount: () => Promise.resolve(this.languageModelsService.getLanguageModelIds().length) },
|
||||
];
|
||||
|
||||
// Listen to changes to update counts
|
||||
this._register(this.promptsService.onDidChangeCustomAgents(() => this.updateCounts()));
|
||||
this._register(this.promptsService.onDidChangeSlashCommands(() => this.updateCounts()));
|
||||
this._register(this.languageModelsService.onDidChangeLanguageModels(() => this.updateCounts()));
|
||||
this._register(autorun(reader => {
|
||||
this.mcpService.servers.read(reader);
|
||||
this.updateCounts();
|
||||
}));
|
||||
|
||||
// Listen to workspace folder changes to update counts
|
||||
this._register(autorun(reader => { this.mcpService.servers.read(reader); this.updateCounts(); }));
|
||||
this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this.updateCounts()));
|
||||
this._register(autorun(reader => {
|
||||
this.activeSessionService.activeSession.read(reader);
|
||||
this.updateCounts();
|
||||
}));
|
||||
|
||||
this._register(autorun(reader => { this.activeSessionService.activeSession.read(reader); this.updateCounts(); }));
|
||||
this._register(this.copilotSdkService.onSessionLifecycle(() => { this.refreshSessionList(); }));
|
||||
this._register(this._sessionListDisposables);
|
||||
}
|
||||
|
||||
protected override renderBody(parent: HTMLElement): void {
|
||||
super.renderBody(parent);
|
||||
|
||||
this.viewPaneContainer = parent;
|
||||
this.viewPaneContainer.classList.add('agent-sessions-viewpane');
|
||||
|
||||
this.createControls(parent);
|
||||
}
|
||||
|
||||
private createControls(parent: HTMLElement): void {
|
||||
const sessionsContainer = DOM.append(parent, $('.agent-sessions-container'));
|
||||
|
||||
// Sessions Filter (actions go to view title bar via menu registration)
|
||||
const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, {
|
||||
filterMenuId: SessionsViewFilterSubMenu,
|
||||
groupResults: () => AgentSessionsGrouping.Date,
|
||||
showProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud],
|
||||
}));
|
||||
|
||||
// Sessions section (top, fills available space)
|
||||
const sessionsSection = DOM.append(sessionsContainer, $('.agent-sessions-section'));
|
||||
|
||||
// Sessions content container
|
||||
const sessionsContent = DOM.append(sessionsSection, $('.agent-sessions-content'));
|
||||
|
||||
// New Session Button
|
||||
const newSessionButtonContainer = this.newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container'));
|
||||
const newSessionButtonContainer = DOM.append(sessionsContent, $('.agent-sessions-new-button-container'));
|
||||
const newSessionButton = this._register(new Button(newSessionButtonContainer, { ...defaultButtonStyles, secondary: true }));
|
||||
newSessionButton.label = localize('newSession', "New Session");
|
||||
this._register(newSessionButton.onDidClick(() => this.commandService.executeCommand(ACTION_ID_NEW_CHAT)));
|
||||
this._register(newSessionButton.onDidClick(() => this.createNewSdkSession()));
|
||||
|
||||
// Keybinding hint inside the button
|
||||
const keybinding = this.keybindingService.lookupKeybinding(ACTION_ID_NEW_CHAT);
|
||||
const keybinding = this.keybindingService.lookupKeybinding(NEW_SDK_SESSION_ID);
|
||||
if (keybinding) {
|
||||
const keybindingHint = DOM.append(newSessionButton.element, $('span.new-session-keybinding-hint'));
|
||||
keybindingHint.textContent = keybinding.getLabel() ?? '';
|
||||
}
|
||||
|
||||
// Sessions Control
|
||||
this.sessionsControlContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container'));
|
||||
const sessionsControl = this.sessionsControl = this._register(this.instantiationService.createInstance(AgentSessionsControl, this.sessionsControlContainer, {
|
||||
source: 'agentSessionsViewPane',
|
||||
filter: sessionsFilter,
|
||||
overrideStyles: this.getLocationBasedColors().listOverrideStyles,
|
||||
getHoverPosition: () => this.getSessionHoverPosition(),
|
||||
trackActiveEditorSession: () => true,
|
||||
collapseOlderSections: () => true,
|
||||
notifySessionOpened: (resource) => this.onSessionOpened(resource),
|
||||
}));
|
||||
this._register(this.onDidChangeBodyVisibility(visible => sessionsControl.setVisible(visible)));
|
||||
// Sessions list (SDK-powered)
|
||||
this.sessionsListContainer = DOM.append(sessionsContent, $('.agent-sessions-control-container'));
|
||||
this.renderSessionList();
|
||||
|
||||
// Listen to tree updates and restore selection if nothing is selected
|
||||
this._register(sessionsControl.onDidUpdate(() => {
|
||||
if (!sessionsControl.hasFocusOrSelection()) {
|
||||
this.restoreLastSelectedSession();
|
||||
}
|
||||
}));
|
||||
|
||||
// When the active session changes, select it in the tree
|
||||
this._register(autorun(reader => {
|
||||
const activeSession = this.activeSessionService.activeSession.read(reader);
|
||||
if (activeSession) {
|
||||
if (!sessionsControl.reveal(activeSession.resource)) {
|
||||
sessionsControl.clearFocus();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// AI Customization shortcuts (bottom, fixed height)
|
||||
// AI Customization shortcuts
|
||||
this.aiCustomizationContainer = DOM.append(sessionsContainer, $('.ai-customization-shortcuts'));
|
||||
this.createAICustomizationShortcuts(this.aiCustomizationContainer);
|
||||
|
||||
this.refreshSessionList();
|
||||
}
|
||||
|
||||
private onSessionOpened(_sessionResource: URI): void {
|
||||
// The active session is now tracked by the ActiveSessionService
|
||||
// This callback is still needed for the AgentSessionsControl
|
||||
private async refreshSessionList(): Promise<void> {
|
||||
try {
|
||||
this._sdkSessions = await this.copilotSdkService.listSessions();
|
||||
} catch (err) {
|
||||
this.logService.error('[SessionsViewPane] Failed to list SDK sessions:', err);
|
||||
this._sdkSessions = [];
|
||||
}
|
||||
this.renderSessionList();
|
||||
}
|
||||
|
||||
private restoreLastSelectedSession(): void {
|
||||
const activeSession = this.activeSessionService.getActiveSession();
|
||||
if (activeSession && this.sessionsControl) {
|
||||
this.sessionsControl.reveal(activeSession.resource);
|
||||
private renderSessionList(): void {
|
||||
if (!this.sessionsListContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._sessionListDisposables.clear();
|
||||
DOM.clearNode(this.sessionsListContainer);
|
||||
|
||||
if (this._sdkSessions.length === 0) {
|
||||
const empty = DOM.append(this.sessionsListContainer, $('.sdk-session-list-empty'));
|
||||
empty.textContent = localize('noSessions', "No sessions yet");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const session of this._sdkSessions) {
|
||||
const item = DOM.append(this.sessionsListContainer, $('.sdk-session-item'));
|
||||
item.tabIndex = 0;
|
||||
item.setAttribute('role', 'listitem');
|
||||
item.setAttribute('data-session-id', session.sessionId);
|
||||
|
||||
if (session.sessionId === this._selectedSessionId) {
|
||||
item.classList.add('selected');
|
||||
}
|
||||
|
||||
const icon = DOM.append(item, $('span.sdk-session-icon'));
|
||||
icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.commentDiscussion));
|
||||
|
||||
const details = DOM.append(item, $('span.sdk-session-details'));
|
||||
const label = DOM.append(details, $('span.sdk-session-label'));
|
||||
label.textContent = session.summary || localize('untitledSession', "Untitled Session");
|
||||
|
||||
// Subtitle: repo/branch or workspace path or session ID
|
||||
const subtitle = session.repository
|
||||
? (session.branch ? `${session.repository} (${session.branch})` : session.repository)
|
||||
: session.workspacePath ?? session.sessionId.substring(0, 8);
|
||||
const pathEl = DOM.append(details, $('span.sdk-session-path'));
|
||||
pathEl.textContent = subtitle;
|
||||
|
||||
// Relative time
|
||||
if (session.modifiedTime || session.startTime) {
|
||||
const timeStr = session.modifiedTime ?? session.startTime;
|
||||
const timeEl = DOM.append(item, $('span.sdk-session-time'));
|
||||
const date = new Date(timeStr!);
|
||||
const ago = this._relativeTime(date);
|
||||
timeEl.textContent = ago;
|
||||
}
|
||||
|
||||
this._sessionListDisposables.add(DOM.addDisposableListener(item, 'click', () => {
|
||||
this.selectSession(session.sessionId);
|
||||
}));
|
||||
this._sessionListDisposables.add(DOM.addDisposableListener(item, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.selectSession(session.sessionId);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private selectSession(sessionId: string): void {
|
||||
this._selectedSessionId = sessionId;
|
||||
|
||||
if (this.sessionsListContainer) {
|
||||
for (const child of this.sessionsListContainer.children) {
|
||||
child.classList.toggle('selected', (child as HTMLElement).getAttribute('data-session-id') === sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const chatPane = this.viewsService.getViewWithId<SdkChatViewPane>(SdkChatViewId);
|
||||
if (chatPane?.widget) {
|
||||
chatPane.widget.loadSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private async createNewSdkSession(): Promise<void> {
|
||||
const chatPane = this.viewsService.getViewWithId<SdkChatViewPane>(SdkChatViewId);
|
||||
if (chatPane?.widget) {
|
||||
await chatPane.widget.newSession();
|
||||
this._selectedSessionId = undefined;
|
||||
this.renderSessionList();
|
||||
}
|
||||
}
|
||||
|
||||
private _relativeTime(date: Date): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) { return localize('justNow', "just now"); }
|
||||
if (diffMins < 60) { return localize('minutesAgo', "{0}m ago", diffMins); }
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) { return localize('hoursAgo', "{0}h ago", diffHours); }
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 7) { return localize('daysAgo', "{0}d ago", diffDays); }
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
private createAICustomizationShortcuts(container: HTMLElement): void {
|
||||
// Get initial collapsed state
|
||||
const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false);
|
||||
|
||||
// Header (clickable to toggle)
|
||||
const header = DOM.append(container, $('.ai-customization-header'));
|
||||
header.tabIndex = 0;
|
||||
header.setAttribute('role', 'button');
|
||||
header.setAttribute('aria-expanded', String(!isCollapsed));
|
||||
|
||||
// Header text
|
||||
const headerText = DOM.append(header, $('span'));
|
||||
headerText.textContent = localize('customizations', "CUSTOMIZATIONS");
|
||||
|
||||
// Chevron icon (right-aligned, shown on hover)
|
||||
const chevron = DOM.append(header, $('.ai-customization-chevron'));
|
||||
chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown));
|
||||
|
||||
// Links container
|
||||
const linksContainer = DOM.append(container, $('.ai-customization-links'));
|
||||
if (isCollapsed) {
|
||||
linksContainer.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// Toggle collapse on header click
|
||||
const toggleCollapse = () => {
|
||||
const collapsed = linksContainer.classList.toggle('collapsed');
|
||||
this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER);
|
||||
@@ -250,7 +286,6 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown));
|
||||
chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown));
|
||||
|
||||
// Re-layout after the transition so sessions control gets the right height
|
||||
const onTransitionEnd = () => {
|
||||
linksContainer.removeEventListener('transitionend', onTransitionEnd);
|
||||
if (this.viewPaneContainer) {
|
||||
@@ -275,15 +310,12 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
link.setAttribute('role', 'button');
|
||||
link.setAttribute('aria-label', shortcut.label);
|
||||
|
||||
// Icon
|
||||
const iconElement = DOM.append(link, $('.link-icon'));
|
||||
iconElement.classList.add(...ThemeIcon.asClassNameArray(shortcut.icon));
|
||||
|
||||
// Label
|
||||
const labelElement = DOM.append(link, $('.link-label'));
|
||||
labelElement.textContent = shortcut.label;
|
||||
|
||||
// Count container (right-aligned, shows per-source badges)
|
||||
const countContainer = DOM.append(link, $('.link-counts'));
|
||||
shortcut.countContainer = countContainer;
|
||||
|
||||
@@ -300,16 +332,12 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
}));
|
||||
}
|
||||
|
||||
// Load initial counts
|
||||
this.updateCounts();
|
||||
}
|
||||
|
||||
private async updateCounts(): Promise<void> {
|
||||
for (const shortcut of this.shortcuts) {
|
||||
if (!shortcut.countContainer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shortcut.countContainer) { continue; }
|
||||
if (shortcut.getSourceCounts) {
|
||||
const counts = await shortcut.getSourceCounts();
|
||||
this.renderSourceCounts(shortcut.countContainer, counts);
|
||||
@@ -324,9 +352,7 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
DOM.clearNode(container);
|
||||
const total = counts.workspace + counts.user + counts.extension;
|
||||
container.classList.toggle('hidden', total === 0);
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
if (total === 0) { return; }
|
||||
|
||||
const sources: { count: number; icon: ThemeIcon; title: string }[] = [
|
||||
{ count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) },
|
||||
@@ -335,13 +361,11 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.count === 0) {
|
||||
continue;
|
||||
}
|
||||
if (source.count === 0) { continue; }
|
||||
const badge = DOM.append(container, $('.source-count-badge'));
|
||||
badge.title = source.title;
|
||||
const icon = DOM.append(badge, $('.source-count-icon'));
|
||||
icon.classList.add(...ThemeIcon.asClassNameArray(source.icon));
|
||||
const badgeIcon = DOM.append(badge, $('.source-count-icon'));
|
||||
badgeIcon.classList.add(...ThemeIcon.asClassNameArray(source.icon));
|
||||
const num = DOM.append(badge, $('.source-count-num'));
|
||||
num.textContent = `${source.count}`;
|
||||
}
|
||||
@@ -363,24 +387,14 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.user, CancellationToken.None),
|
||||
this.promptsService.listPromptFilesForStorage(promptType, PromptsStorage.extension, CancellationToken.None),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspace: workspaceItems.length,
|
||||
user: userItems.length,
|
||||
extension: extensionItems.length,
|
||||
};
|
||||
return { workspace: workspaceItems.length, user: userItems.length, extension: extensionItems.length };
|
||||
}
|
||||
|
||||
private async getSkillSourceCounts(): Promise<ISourceCounts> {
|
||||
const skills = await this.promptsService.findAgentSkills(CancellationToken.None);
|
||||
if (!skills || skills.length === 0) {
|
||||
return { workspace: 0, user: 0, extension: 0 };
|
||||
}
|
||||
|
||||
const workspaceSkills = skills.filter(s => s.storage === PromptsStorage.local);
|
||||
|
||||
if (!skills || skills.length === 0) { return { workspace: 0, user: 0, extension: 0 }; }
|
||||
return {
|
||||
workspace: workspaceSkills.length,
|
||||
workspace: skills.filter(s => s.storage === PromptsStorage.local).length,
|
||||
user: skills.filter(s => s.storage === PromptsStorage.user).length,
|
||||
extension: skills.filter(s => s.storage === PromptsStorage.extension).length,
|
||||
};
|
||||
@@ -389,67 +403,41 @@ export class AgenticSessionsViewPane extends ViewPane {
|
||||
private async openAICustomizationSection(sectionId: AICustomizationManagementSection): Promise<void> {
|
||||
const input = AICustomizationManagementEditorInput.getOrCreate();
|
||||
const editor = await this.editorGroupsService.activeGroup.openEditor(input, { pinned: true });
|
||||
|
||||
if (editor instanceof AICustomizationManagementEditor) {
|
||||
editor.selectSectionById(sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionHoverPosition(): HoverPosition {
|
||||
const viewLocation = this.viewDescriptorService.getViewLocationById(this.id);
|
||||
const sideBarPosition = this.layoutService.getSideBarPosition();
|
||||
|
||||
return {
|
||||
[ViewContainerLocation.Sidebar]: sideBarPosition === 0 ? HoverPosition.RIGHT : HoverPosition.LEFT,
|
||||
[ViewContainerLocation.AuxiliaryBar]: sideBarPosition === 0 ? HoverPosition.LEFT : HoverPosition.RIGHT,
|
||||
[ViewContainerLocation.ChatBar]: HoverPosition.RIGHT,
|
||||
[ViewContainerLocation.Panel]: HoverPosition.ABOVE
|
||||
}[viewLocation ?? ViewContainerLocation.AuxiliaryBar];
|
||||
}
|
||||
|
||||
protected override layoutBody(height: number, width: number): void {
|
||||
super.layoutBody(height, width);
|
||||
|
||||
if (!this.sessionsControl || !this.newSessionButtonContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buttonHeight = this.newSessionButtonContainer.offsetHeight;
|
||||
const customizationHeight = this.aiCustomizationContainer?.offsetHeight || 0;
|
||||
const availableSessionsHeight = height - buttonHeight - customizationHeight;
|
||||
this.sessionsControl.layout(availableSessionsHeight, width);
|
||||
}
|
||||
|
||||
override focus(): void {
|
||||
super.focus();
|
||||
|
||||
this.sessionsControl?.focus();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.sessionsControl?.refresh();
|
||||
}
|
||||
|
||||
openFind(): void {
|
||||
this.sessionsControl?.openFind();
|
||||
this.refreshSessionList();
|
||||
}
|
||||
}
|
||||
|
||||
// Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window
|
||||
// Register Cmd+N / Ctrl+N keybinding for new session
|
||||
KeybindingsRegistry.registerKeybindingRule({
|
||||
id: ACTION_ID_NEW_CHAT,
|
||||
id: NEW_SDK_SESSION_ID,
|
||||
weight: KeybindingWeight.WorkbenchContrib + 1,
|
||||
primary: KeyMod.CtrlCmd | KeyCode.KeyN,
|
||||
});
|
||||
|
||||
MenuRegistry.appendMenuItem(MenuId.ViewTitle, {
|
||||
submenu: SessionsViewFilterSubMenu,
|
||||
title: localize2('filterAgentSessions', "Filter Agent Sessions"),
|
||||
group: 'navigation',
|
||||
order: 3,
|
||||
icon: Codicon.filter,
|
||||
when: ContextKeyExpr.equals('view', SessionsViewId)
|
||||
} satisfies ISubmenuItem);
|
||||
registerAction2(class NewSdkSessionAction extends Action2 {
|
||||
constructor() {
|
||||
super({ id: NEW_SDK_SESSION_ID, title: localize2('newSdkSession', "New Session"), icon: Codicon.add, f1: true });
|
||||
}
|
||||
override async run(accessor: ServicesAccessor) {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const chatPane = viewsService.getViewWithId<SdkChatViewPane>(SdkChatViewId);
|
||||
if (chatPane?.widget) { await chatPane.widget.newSession(); }
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class RefreshAgentSessionsViewerAction extends Action2 {
|
||||
constructor() {
|
||||
@@ -457,40 +445,12 @@ registerAction2(class RefreshAgentSessionsViewerAction extends Action2 {
|
||||
id: 'sessionsView.refresh',
|
||||
title: localize2('refresh', "Refresh Agent Sessions"),
|
||||
icon: Codicon.refresh,
|
||||
menu: [{
|
||||
id: MenuId.ViewTitle,
|
||||
group: 'navigation',
|
||||
order: 1,
|
||||
when: ContextKeyExpr.equals('view', SessionsViewId),
|
||||
}],
|
||||
menu: [{ id: MenuId.ViewTitle, group: 'navigation', order: 1, when: ContextKeyExpr.equals('view', SessionsViewId) }],
|
||||
});
|
||||
}
|
||||
override run(accessor: ServicesAccessor) {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = viewsService.getViewWithId<AgenticSessionsViewPane>(SessionsViewId);
|
||||
return view?.sessionsControl?.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
registerAction2(class FindAgentSessionInViewerAction extends Action2 {
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'sessionsView.find',
|
||||
title: localize2('find', "Find Agent Session"),
|
||||
icon: Codicon.search,
|
||||
menu: [{
|
||||
id: MenuId.ViewTitle,
|
||||
group: 'navigation',
|
||||
order: 2,
|
||||
when: ContextKeyExpr.equals('view', SessionsViewId),
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
override run(accessor: ServicesAccessor) {
|
||||
const viewsService = accessor.get(IViewsService);
|
||||
const view = viewsService.getViewWithId<AgenticSessionsViewPane>(SessionsViewId);
|
||||
return view?.sessionsControl?.openFind();
|
||||
return view?.refresh();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,14 +70,16 @@ const chatViewDescriptor: IViewDescriptor = {
|
||||
order: 1
|
||||
},
|
||||
ctorDescriptor: new SyncDescriptor(ChatViewPane),
|
||||
when: ContextKeyExpr.or(
|
||||
IsSessionsWindowContext,
|
||||
when: ContextKeyExpr.and(
|
||||
IsSessionsWindowContext.negate(),
|
||||
ContextKeyExpr.or(
|
||||
ChatContextKeys.Setup.hidden,
|
||||
ChatContextKeys.Setup.disabled
|
||||
)?.negate(),
|
||||
ChatContextKeys.panelParticipantRegistered,
|
||||
ChatContextKeys.extensionInvalid
|
||||
ContextKeyExpr.or(
|
||||
ChatContextKeys.Setup.hidden,
|
||||
ChatContextKeys.Setup.disabled
|
||||
)?.negate(),
|
||||
ChatContextKeys.panelParticipantRegistered,
|
||||
ChatContextKeys.extensionInvalid
|
||||
)
|
||||
),
|
||||
windowVisibility: WindowVisibility.Both
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user