diff --git a/.entire/logs/entire.log b/.entire/logs/entire.log new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/vs/platform/copilotSdk/common/copilotSdkService.ts b/src/vs/platform/copilotSdk/common/copilotSdkService.ts index fdb1bdb71b9..7aad0637639 100644 --- a/src/vs/platform/copilotSdk/common/copilotSdkService.ts +++ b/src/vs/platform/copilotSdk/common/copilotSdkService.ts @@ -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 diff --git a/src/vs/platform/copilotSdk/node/copilotSdkHost.ts b/src/vs/platform/copilotSdk/node/copilotSdkHost.ts index 3700db8ee7d..26ad48ca4f5 100644 --- a/src/vs/platform/copilotSdk/node/copilotSdkHost.ts +++ b/src/vs/platform/copilotSdk/node/copilotSdkHost.ts @@ -185,9 +185,15 @@ class CopilotSdkHost extends Disposable implements ICopilotSdkService { async listSessions(): Promise { 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, })); } diff --git a/src/vs/sessions/COPILOT_SDK_PLAN.md b/src/vs/sessions/COPILOT_SDK_PLAN.md index 604b1ceb81b..3e43d1ca18c 100644 --- a/src/vs/sessions/COPILOT_SDK_PLAN.md +++ b/src/vs/sessions/COPILOT_SDK_PLAN.md @@ -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. | diff --git a/src/vs/sessions/browser/copilotSdkDebugLog.ts b/src/vs/sessions/browser/copilotSdkDebugLog.ts new file mode 100644 index 00000000000..34e5d689e3c --- /dev/null +++ b/src/vs/sessions/browser/copilotSdkDebugLog.ts @@ -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()); + readonly onDidAddEntry: Event = 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'); + })); + } +} diff --git a/src/vs/sessions/browser/copilotSdkDebugPanel.ts b/src/vs/sessions/browser/copilotSdkDebugPanel.ts index 77978486d8e..17d975b96f7 100644 --- a/src/vs/sessions/browser/copilotSdkDebugPanel.ts +++ b/src/vs/sessions/browser/copilotSdkDebugPanel.ts @@ -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 { - 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 { + 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 { 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; } diff --git a/src/vs/sessions/browser/widget/media/sdkChatWidget.css b/src/vs/sessions/browser/widget/media/sdkChatWidget.css new file mode 100644 index 00000000000..846ceb821d1 --- /dev/null +++ b/src/vs/sessions/browser/widget/media/sdkChatWidget.css @@ -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; +} diff --git a/src/vs/sessions/browser/widget/sdkChatModel.ts b/src/vs/sessions/browser/widget/sdkChatModel.ts new file mode 100644 index 00000000000..9de450e9dc6 --- /dev/null +++ b/src/vs/sessions/browser/widget/sdkChatModel.ts @@ -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()); + readonly onDidChange: Event = 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 diff --git a/src/vs/sessions/browser/widget/sdkChatModelBridge.ts b/src/vs/sessions/browser/widget/sdkChatModelBridge.ts new file mode 100644 index 00000000000..609a7644d1f --- /dev/null +++ b/src/vs/sessions/browser/widget/sdkChatModelBridge.ts @@ -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()); + readonly onDidChangeSessionId: Event = 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 { + 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; + } + } + } +} diff --git a/src/vs/sessions/browser/widget/sdkChatViewPane.ts b/src/vs/sessions/browser/widget/sdkChatViewPane.ts new file mode 100644 index 00000000000..fe69e8def4a --- /dev/null +++ b/src/vs/sessions/browser/widget/sdkChatViewPane.ts @@ -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; + } +} diff --git a/src/vs/sessions/browser/widget/sdkChatWidget.ts b/src/vs/sessions/browser/widget/sdkChatWidget.ts new file mode 100644 index 00000000000..724a2fa8e82 --- /dev/null +++ b/src/vs/sessions/browser/widget/sdkChatWidget.ts @@ -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; +} + +/** + * 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(); + + private _sessionId: string | undefined; + private _isStreaming = false; + private _autoScroll = true; + + private readonly _eventDisposables = this._register(new DisposableStore()); + + private readonly _onDidChangeState = this._register(new Emitter()); + readonly onDidChangeState: Event = this._onDidChangeState.event; + + private readonly _onDidChangeSessionId = this._register(new Emitter()); + readonly onDidChangeSessionId: Event = 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index c7304190c78..0f03e3a0e07 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -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(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(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(); diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 4b37cbc4323..145cfe299ef 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -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; } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 1678ef424c9..be2ac2c1e7d 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -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; readonly getSourceCounts?: () => Promise; - /** For items without per-source breakdown (MCP, Models). */ readonly getCount?: () => Promise; 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 { + 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(SdkChatViewId); + if (chatPane?.widget) { + chatPane.widget.loadSession(sessionId); + } + } + + private async createNewSdkSession(): Promise { + const chatPane = this.viewsService.getViewWithId(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 { 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 { 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 { 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(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(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(SessionsViewId); - return view?.sessionsControl?.openFind(); + return view?.refresh(); } }); diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index cff3a563ca5..918bf40e800 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -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 };