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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user