feat: improve VS Code editor chat actions

Targets active editor chat before falling back to sidebar
Adds session open-in-editor action in the VS Code sidebar
Uses attachment badges for VS Code file and selection context
This commit is contained in:
Bohdan Triapitsyn
2026-05-19 15:53:54 +03:00
parent 25037ccab9
commit c81441ecfc
13 changed files with 389 additions and 30 deletions
+31
View File
@@ -199,6 +199,37 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
}
}
public addContextSelection(selection: { filePath: string; filename: string; text: string }) {
if (!this._view) {
return;
}
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'addContextSelection',
payload: selection,
});
}
public addFileAttachments(files: Array<{ filePath: string; fileName: string; fileSize: number | null }>) {
if (!this._view) {
return;
}
const cleanedFiles = files.filter((entry) => entry.filePath.trim().length > 0 && entry.fileName.trim().length > 0);
if (cleanedFiles.length === 0) {
return;
}
this._view.show(true);
this._view.webview.postMessage({
type: 'command',
command: 'addFileAttachments',
payload: { files: cleanedFiles },
});
}
public addFileMentions(paths: string[]) {
if (!this._view) {
return;
@@ -13,6 +13,26 @@ type SessionPanelState = {
sseStreams: Map<string, AbortController>;
};
type ActiveEditorFilePayload = {
filePath: string;
fileName: string;
relativePath: string;
fileSize: number | null;
selection: { startLine: number; endLine: number; text: string } | null;
};
const isSameActiveEditorFilePayload = (a: ActiveEditorFilePayload | null, b: ActiveEditorFilePayload | null): boolean => {
if (a === b) return true;
if (!a || !b) return false;
return a.filePath === b.filePath
&& a.fileName === b.fileName
&& a.relativePath === b.relativePath
&& a.fileSize === b.fileSize
&& a.selection?.startLine === b.selection?.startLine
&& a.selection?.endLine === b.selection?.endLine
&& a.selection?.text === b.selection?.text;
};
export class SessionEditorPanelProvider {
public static readonly viewType = 'openchamber.sessionEditor';
@@ -20,6 +40,10 @@ export class SessionEditorPanelProvider {
private _cachedError?: string;
private _sseCounter = 0;
private _panels = new Map<string, SessionPanelState>();
private _lastActivePanelId: string | null = null;
private _broadcastSelectionDebounce: ReturnType<typeof setTimeout> | undefined;
private _clearActiveEditorFileTimer: ReturnType<typeof setTimeout> | undefined;
private _lastActiveEditorFilePayload: ActiveEditorFilePayload | null = null;
private readonly _webviewDevServerUrl: string | null;
constructor(
@@ -28,6 +52,11 @@ export class SessionEditorPanelProvider {
private readonly _openCodeManager?: OpenCodeManager
) {
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
this._context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(() => void this._broadcastActiveEditorFile()),
vscode.window.onDidChangeTextEditorSelection(() => this._scheduleBroadcast()),
);
}
public createOrShowNewSession(): void {
@@ -78,22 +107,40 @@ export class SessionEditorPanelProvider {
};
this._panels.set(panelId, state);
this._lastActivePanelId = panelId;
panel.webview.html = this._getHtmlForWebview(panel.webview, initialSessionId);
void this.updateTheme(vscode.window.activeColorTheme.kind);
this._sendCachedStateToPanel(state);
void this._broadcastActiveEditorFile();
panel.onDidDispose(() => {
this._disposePanel(panelId);
}, null, this._context.subscriptions);
panel.onDidChangeViewState((event) => {
if (event.webviewPanel.active) {
this._lastActivePanelId = panelId;
}
}, null, this._context.subscriptions);
panel.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
if (message.type === 'restartApi') {
await this._openCodeManager?.restart();
return;
}
if (message.type === 'vscode:command') {
const { command, args } = (message.payload || {}) as { command?: unknown; args?: unknown[] };
if (command === 'openchamber.updateSessionEditorTitle') {
const title = typeof args?.[1] === 'string' && args[1].trim().length > 0 ? args[1].trim() : 'Session';
state.panel.title = title;
state.panel.webview.postMessage({ id: message.id, type: message.type, success: true, data: { result: true } });
return;
}
}
if (message.type === 'api:sse:start') {
const response = await this._startSseProxy(message, state);
state.panel.webview.postMessage(response);
@@ -159,6 +206,75 @@ export class SessionEditorPanelProvider {
}
}
private _getActivePanelEntry(): SessionPanelState | null {
const activeEntry = Array.from(this._panels.entries()).find(([, entry]) => entry.panel.active);
const panelId = activeEntry?.[0] ?? this._lastActivePanelId;
if (!panelId) {
return null;
}
return this._panels.get(panelId) ?? null;
}
public addContextSelectionToActivePanel(selection: { filePath: string; filename: string; text: string }): boolean {
if (!selection.filePath.trim() || !selection.filename.trim() || !selection.text.trim()) {
return false;
}
const entry = this._getActivePanelEntry();
if (!entry) {
return false;
}
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
void entry.panel.webview.postMessage({
type: 'command',
command: 'addContextSelection',
payload: selection,
});
return true;
}
public createSessionWithPromptInActivePanel(prompt: string): boolean {
if (!prompt.trim()) {
return false;
}
const entry = this._getActivePanelEntry();
if (!entry) {
return false;
}
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
void entry.panel.webview.postMessage({
type: 'command',
command: 'createSessionWithPrompt',
payload: { prompt },
});
return true;
}
public addFileAttachmentsToActivePanel(files: Array<{ filePath: string; fileName: string; fileSize: number | null }>): boolean {
const cleanedFiles = files.filter((entry) => entry.filePath.trim().length > 0 && entry.fileName.trim().length > 0);
if (cleanedFiles.length === 0) {
return false;
}
const entry = this._getActivePanelEntry();
if (!entry) {
return false;
}
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
void entry.panel.webview.postMessage({
type: 'command',
command: 'addFileAttachments',
payload: { files: cleanedFiles },
});
return true;
}
private _sendCachedStateToPanel(entry: SessionPanelState) {
entry.panel.webview.postMessage({
type: 'connectionStatus',
@@ -172,6 +288,93 @@ export class SessionEditorPanelProvider {
});
}
private _postCommandToPanels(command: string, payload: unknown): void {
for (const entry of this._panels.values()) {
entry.panel.webview.postMessage({
type: 'command',
command,
payload,
});
}
}
private _scheduleBroadcast(): void {
if (this._broadcastSelectionDebounce !== undefined) {
clearTimeout(this._broadcastSelectionDebounce);
}
this._broadcastSelectionDebounce = setTimeout(() => {
this._broadcastSelectionDebounce = undefined;
void this._broadcastActiveEditorFile();
}, 150);
}
private _scheduleClearActiveEditorFile(): void {
if (this._clearActiveEditorFileTimer !== undefined) {
clearTimeout(this._clearActiveEditorFileTimer);
}
this._clearActiveEditorFileTimer = setTimeout(() => {
this._clearActiveEditorFileTimer = undefined;
if (this._panels.size === 0 || this._lastActiveEditorFilePayload === null) {
return;
}
this._lastActiveEditorFilePayload = null;
this._postCommandToPanels('activeEditorFile', null);
}, 200);
}
private async _broadcastActiveEditorFile(): Promise<void> {
if (this._panels.size === 0) {
return;
}
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document.uri.scheme !== 'file') {
this._scheduleClearActiveEditorFile();
return;
}
const editorUri = editor.document.uri;
const editorUriKey = editorUri.toString();
if (this._clearActiveEditorFileTimer !== undefined) {
clearTimeout(this._clearActiveEditorFileTimer);
this._clearActiveEditorFileTimer = undefined;
}
const filePath = normalizeWindowsDriveLetter(editorUri.fsPath);
const fileName = editorUri.fsPath.replace(/\\/g, '/').split('/').pop() || '';
const relativePath = vscode.workspace.asRelativePath(editorUri, false);
let fileSize: number | null = null;
try {
const stat = await vscode.workspace.fs.stat(editorUri);
fileSize = stat.size;
} catch {
// File may not be saved yet or inaccessible.
}
if (vscode.window.activeTextEditor?.document.uri.toString() !== editorUriKey) {
return;
}
let selection: ActiveEditorFilePayload['selection'] = null;
if (!editor.selection.isEmpty) {
selection = {
startLine: editor.selection.start.line + 1,
endLine: editor.selection.end.line + 1,
text: editor.document.getText(editor.selection),
};
}
const payload: ActiveEditorFilePayload = { filePath, fileName, relativePath, fileSize, selection };
if (isSameActiveEditorFilePayload(this._lastActiveEditorFilePayload, payload)) {
return;
}
this._lastActiveEditorFilePayload = payload;
this._postCommandToPanels('activeEditorFile', payload);
}
private _disposePanel(sessionId: string) {
const entry = this._panels.get(sessionId);
if (!entry) return;
@@ -182,6 +385,9 @@ export class SessionEditorPanelProvider {
entry.sseStreams.clear();
this._panels.delete(sessionId);
if (this._lastActivePanelId === sessionId) {
this._lastActivePanelId = null;
}
}
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
+39 -21
View File
@@ -288,20 +288,24 @@ export async function activate(context: vscode.ExtensionContext) {
// Get file info for context
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
const languageId = editor.document.languageId;
// Get line numbers (1-based for display)
const startLine = selection.start.line + 1;
const endLine = selection.end.line + 1;
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
// Format as file path with line numbers, followed by markdown code block
const contextText = `${filePath}:${lineRange}\n\`\`\`${languageId}\n${selectedText}\n\`\`\``;
const filename = `${editor.document.fileName.split(/[\\/]/).pop() || filePath}:${lineRange}`;
const contextSelection = {
filePath: editor.document.uri.fsPath,
filename,
text: selectedText,
};
if (!(await revealChatViewForPayload())) {
return;
if (!sessionEditorProvider?.addContextSelectionToActivePanel(contextSelection)) {
if (!(await revealChatViewForPayload())) {
return;
}
chatViewProvider?.addContextSelection(contextSelection);
}
chatViewProvider?.addTextToInput(contextText);
})
);
@@ -322,7 +326,7 @@ export async function activate(context: vscode.ExtensionContext) {
}
const uniqueUris = Array.from(new Map(uriCandidates.map((uri) => [uri.toString(), uri])).values());
const mentionPaths: string[] = [];
const attachedFiles: Array<{ filePath: string; fileName: string; fileSize: number | null }> = [];
const skippedEntries: string[] = [];
for (const uri of uniqueUris) {
@@ -342,23 +346,33 @@ export async function activate(context: vscode.ExtensionContext) {
continue;
}
const relativePath = vscode.workspace.asRelativePath(uri, false).replace(/\\/g, '/').trim();
if (!relativePath) {
const filePath = uri.fsPath.trim();
const fileName = uri.fsPath.replace(/\\/g, '/').split('/').pop() || vscode.workspace.asRelativePath(uri, false).replace(/\\/g, '/').trim();
if (!filePath || !fileName) {
skippedEntries.push(uri.fsPath || uri.toString());
continue;
}
mentionPaths.push(relativePath);
let fileSize: number | null = null;
try {
const stat = await vscode.workspace.fs.stat(uri);
fileSize = stat.size;
} catch {
fileSize = null;
}
attachedFiles.push({ filePath, fileName, fileSize });
}
if (mentionPaths.length === 0) {
if (attachedFiles.length === 0) {
vscode.window.showWarningMessage('OpenChamber: No file selected to mention');
return;
}
if (!(await revealChatViewForPayload())) {
return;
if (!sessionEditorProvider?.addFileAttachmentsToActivePanel(attachedFiles)) {
if (!(await revealChatViewForPayload())) {
return;
}
chatViewProvider?.addFileAttachments(attachedFiles);
}
chatViewProvider?.addFileMentions(mentionPaths);
if (skippedEntries.length > 0) {
vscode.window.showInformationMessage('OpenChamber: Some selected entries were skipped (folders or unsupported resources)');
@@ -392,10 +406,12 @@ export async function activate(context: vscode.ExtensionContext) {
prompt = `Explain the following Code / Text:\n\n${filePath}`;
}
if (!(await revealChatViewForPayload())) {
return;
if (!sessionEditorProvider?.createSessionWithPromptInActivePanel(prompt)) {
if (!(await revealChatViewForPayload())) {
return;
}
chatViewProvider?.createNewSessionWithPrompt(prompt);
}
chatViewProvider?.createNewSessionWithPrompt(prompt);
})
);
@@ -423,10 +439,12 @@ export async function activate(context: vscode.ExtensionContext) {
const prompt = `Improve the following Code:\n\n${filePath}:${lineRange}\n\`\`\`${languageId}\n${selectedText}\n\`\`\``;
if (!(await revealChatViewForPayload())) {
return;
if (!sessionEditorProvider?.createSessionWithPromptInActivePanel(prompt)) {
if (!(await revealChatViewForPayload())) {
return;
}
chatViewProvider?.createNewSessionWithPrompt(prompt);
}
chatViewProvider?.createNewSessionWithPrompt(prompt);
})
);