some kinda funny ui

This commit is contained in:
Josh Spicer
2026-02-13 19:54:17 -08:00
parent b753932126
commit 9e9d7eb43d
15 changed files with 2065 additions and 352 deletions

0
.entire/logs/entire.log Normal file
View File

View 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

View File

@@ -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,
}));
}

View File

@@ -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. |

View 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');
}));
}
}

View File

@@ -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;
}

View 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;
}

View 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

View 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;
}
}
}
}

View 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;
}
}

View 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
}
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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();
}
});

View File

@@ -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
};