chore: bump version to 2.80.27 and update core features
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ApprovalQueue, Approval } from './approvalQueue';
|
||||
|
||||
/**
|
||||
* A small webview view that surfaces the currently pending approval, separate
|
||||
* from the chat. The provider is intentionally thin: state lives in
|
||||
* ApprovalQueue, this class only renders + relays button clicks.
|
||||
*
|
||||
* Mirroring the existing sidebar.html + media/ separation pattern would be
|
||||
* appropriate once the panel grows, but the current UI is small enough
|
||||
* (~40 lines of HTML) that an inline template keeps the diff focused.
|
||||
*/
|
||||
export class ApprovalPanelProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = 'g1nation-approval-panel';
|
||||
|
||||
private _view?: vscode.WebviewView;
|
||||
private _subscription?: vscode.Disposable;
|
||||
|
||||
constructor(
|
||||
private readonly _extensionUri: vscode.Uri,
|
||||
private readonly _queue: ApprovalQueue
|
||||
) {}
|
||||
|
||||
public resolveWebviewView(view: vscode.WebviewView): void {
|
||||
this._view = view;
|
||||
view.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri] };
|
||||
view.webview.html = this._render(this._queue.current());
|
||||
|
||||
view.webview.onDidReceiveMessage((msg: { type: string; id?: string }) => {
|
||||
if (msg?.type === 'approve') void this._queue.approve(msg.id);
|
||||
else if (msg?.type === 'reject') void this._queue.reject(msg.id);
|
||||
else if (msg?.type === 'refresh') view.webview.html = this._render(this._queue.current());
|
||||
});
|
||||
|
||||
this._subscription?.dispose();
|
||||
this._subscription = this._queue.onChange(() => {
|
||||
if (this._view) this._view.webview.html = this._render(this._queue.current());
|
||||
});
|
||||
|
||||
view.onDidDispose(() => {
|
||||
this._subscription?.dispose();
|
||||
this._subscription = undefined;
|
||||
this._view = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Bring the panel into focus; used by the status bar badge. */
|
||||
public focus(): void {
|
||||
void vscode.commands.executeCommand(`${ApprovalPanelProvider.viewType}.focus`);
|
||||
}
|
||||
|
||||
private _render(approval: Approval | null): string {
|
||||
const csp = `default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline';`;
|
||||
const empty = !approval;
|
||||
const body = empty ? this._renderEmpty() : this._renderApproval(approval as Approval);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); font-size: 12px; padding: 12px; color: var(--vscode-foreground); }
|
||||
.empty { color: var(--vscode-descriptionForeground); padding: 24px 8px; text-align: center; }
|
||||
.card { border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 12px; margin-bottom: 8px; background: var(--vscode-editor-background); }
|
||||
.title { font-weight: 600; margin-bottom: 4px; }
|
||||
.summary { color: var(--vscode-descriptionForeground); margin-bottom: 8px; font-size: 11px; }
|
||||
.files { margin: 8px 0; padding: 6px 8px; background: var(--vscode-textCodeBlock-background); border-radius: 4px; max-height: 160px; overflow-y: auto; }
|
||||
.files li { font-family: var(--vscode-editor-font-family); font-size: 11px; line-height: 1.6; word-break: break-all; list-style: none; }
|
||||
.files .badge { display: inline-block; min-width: 56px; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-right: 6px; text-align: center; background: var(--vscode-badge-background); color: var(--vscode-badge-foreground); }
|
||||
.actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
button { flex: 1; padding: 6px 10px; border: 1px solid var(--vscode-button-border, transparent); border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
button.approve { background: var(--vscode-button-background); color: var(--vscode-button-foreground); }
|
||||
button.approve:hover { background: var(--vscode-button-hoverBackground); }
|
||||
button.reject { background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); }
|
||||
button.reject:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
for (const btn of document.querySelectorAll('button[data-action]')) {
|
||||
btn.addEventListener('click', () => {
|
||||
vscode.postMessage({ type: btn.dataset.action, id: btn.dataset.id });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private _renderEmpty(): string {
|
||||
return `<div class="empty">대기 중인 승인이 없습니다.</div>`;
|
||||
}
|
||||
|
||||
private _renderApproval(a: Approval): string {
|
||||
const filesHtml = a.files.length === 0
|
||||
? '<li style="color: var(--vscode-descriptionForeground);">파일 변경 없음</li>'
|
||||
: a.files.map(f => `<li><span class="badge">변경</span>${this._escape(f)}</li>`).join('');
|
||||
const elapsed = Math.max(0, Math.floor((Date.now() - a.createdAt) / 1000));
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="title">${this._escape(a.title)}</div>
|
||||
<div class="summary">${this._escape(a.summary)} · ${elapsed}초 전</div>
|
||||
<ul class="files">${filesHtml}</ul>
|
||||
<div class="actions">
|
||||
<button class="approve" data-action="approve" data-id="${this._escape(a.id)}">승인</button>
|
||||
<button class="reject" data-action="reject" data-id="${this._escape(a.id)}">거부</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _escape(s: string): string {
|
||||
return String(s).replace(/[&<>"']/g, ch => (
|
||||
ch === '&' ? '&' :
|
||||
ch === '<' ? '<' :
|
||||
ch === '>' ? '>' :
|
||||
ch === '"' ? '"' : '''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Pending-approval coordination for ConnectAI.
|
||||
*
|
||||
* Why this module exists:
|
||||
* - The agent already has a transaction-based "dry run" approval flow
|
||||
* (see agent.ts:2362, transactionManager.commit/rollback). The decision
|
||||
* UI lives inside the chat as an inline box, which is fine for a single
|
||||
* prompt but misses the broader problem: the user wants a stable place
|
||||
* to see "what is the agent waiting for me to approve?", with a status
|
||||
* bar badge that pulls them in.
|
||||
*
|
||||
* - Connect_origin solves this with a queue of pending actions in a
|
||||
* dashboard. ConnectAI today only ever has a single active transaction,
|
||||
* so we model the queue as **0..1 current approval** to keep the surface
|
||||
* small. Extending to N approvals later is a list change inside this
|
||||
* module — no consumer needs to switch shapes.
|
||||
*
|
||||
* Wiring (read-only summary):
|
||||
* agent.ts (dryRun) ──enqueue──▶ ApprovalQueue ──onChange──▶ ApprovalPanelProvider (webview)
|
||||
* ──onChange──▶ ApprovalStatusBar (badge)
|
||||
* webview button ──approve/reject──▶ ApprovalQueue ──invokes callback──▶ agent.approveTransaction()
|
||||
*/
|
||||
|
||||
export type ApprovalKind = 'transaction' | 'file-write' | 'file-create' | 'file-delete' | 'command';
|
||||
|
||||
export interface Approval {
|
||||
id: string;
|
||||
kind: ApprovalKind;
|
||||
title: string;
|
||||
summary: string;
|
||||
files: string[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ApprovalCallbacks {
|
||||
approve: () => Promise<void> | void;
|
||||
reject: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ApprovalEntry {
|
||||
approval: Approval;
|
||||
callbacks: ApprovalCallbacks;
|
||||
}
|
||||
|
||||
export class ApprovalQueue {
|
||||
private _current: ApprovalEntry | null = null;
|
||||
private readonly _emitter = new vscode.EventEmitter<void>();
|
||||
/** Fires whenever the current approval changes (set / approved / rejected / cleared). */
|
||||
public readonly onChange = this._emitter.event;
|
||||
|
||||
/**
|
||||
* Replace the currently pending approval, if any. The previous approval is
|
||||
* silently dropped — its callbacks are NOT invoked. This matches the
|
||||
* agent's transaction model: the new dry-run pre-empts whatever was waiting.
|
||||
*/
|
||||
enqueue(approval: Approval, callbacks: ApprovalCallbacks): void {
|
||||
if (this._current) {
|
||||
logInfo('Approval pre-empted by newer pending approval.', {
|
||||
droppedId: this._current.approval.id,
|
||||
newId: approval.id,
|
||||
});
|
||||
}
|
||||
this._current = { approval, callbacks };
|
||||
logInfo('Approval enqueued.', { id: approval.id, kind: approval.kind, fileCount: approval.files.length });
|
||||
this._emitter.fire();
|
||||
}
|
||||
|
||||
/** Returns the currently pending approval, or null. */
|
||||
current(): Approval | null {
|
||||
return this._current?.approval ?? null;
|
||||
}
|
||||
|
||||
/** 0 or 1 with the current model — future-proof for a real queue. */
|
||||
pendingCount(): number {
|
||||
return this._current ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the pending entry whose id matches. If `id` is omitted, approve
|
||||
* the current entry. Mismatched ids are ignored to avoid stale-button
|
||||
* double-fire from the webview.
|
||||
*/
|
||||
async approve(id?: string): Promise<void> {
|
||||
const entry = this._take(id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
await entry.callbacks.approve();
|
||||
} catch (e: any) {
|
||||
logError('Approval approve callback threw.', { id: entry.approval.id, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async reject(id?: string): Promise<void> {
|
||||
const entry = this._take(id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
await entry.callbacks.reject();
|
||||
} catch (e: any) {
|
||||
logError('Approval reject callback threw.', { id: entry.approval.id, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear without firing callbacks (used by host on shutdown). */
|
||||
clear(): void {
|
||||
if (!this._current) return;
|
||||
this._current = null;
|
||||
this._emitter.fire();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._current = null;
|
||||
this._emitter.dispose();
|
||||
}
|
||||
|
||||
private _take(id?: string): ApprovalEntry | null {
|
||||
if (!this._current) return null;
|
||||
if (id !== undefined && id !== this._current.approval.id) {
|
||||
logInfo('Approval id mismatch — ignoring.', { requested: id, current: this._current.approval.id });
|
||||
return null;
|
||||
}
|
||||
const entry = this._current;
|
||||
this._current = null;
|
||||
this._emitter.fire();
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ApprovalQueue } from './approvalQueue';
|
||||
|
||||
/**
|
||||
* Status-bar badge that pulses while an approval is pending. Clicking it
|
||||
* focuses the Approval Panel webview view. Hidden when nothing is pending.
|
||||
*
|
||||
* Lives separately from `src/core/statusBar.ts` (agent run-status indicator)
|
||||
* because the two states change for completely different reasons and the
|
||||
* single-item statusBarManager would lose the agent-status during a long
|
||||
* dry-run review otherwise.
|
||||
*/
|
||||
export class ApprovalStatusBar implements vscode.Disposable {
|
||||
private readonly _item: vscode.StatusBarItem;
|
||||
private readonly _sub: vscode.Disposable;
|
||||
public static readonly focusCommand = 'g1nation.approval.focus';
|
||||
|
||||
constructor(private readonly _queue: ApprovalQueue) {
|
||||
this._item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 95);
|
||||
this._item.command = ApprovalStatusBar.focusCommand;
|
||||
this._item.tooltip = 'Astra: 승인 대기 중인 작업이 있습니다. 클릭해서 검토하세요.';
|
||||
this._sub = this._queue.onChange(() => this._refresh());
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
private _refresh(): void {
|
||||
const count = this._queue.pendingCount();
|
||||
if (count === 0) {
|
||||
this._item.hide();
|
||||
return;
|
||||
}
|
||||
this._item.text = `$(warning) 승인 대기 ${count}`;
|
||||
this._item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
this._item.show();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._sub.dispose();
|
||||
this._item.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user