Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-plan-artifacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": minor
---

Added a session-scoped artifact lifecycle to the CLI and VS Code: implementation plans with explicit review and prose-plan fallback persistence, persistent task tracking, completion walkthroughs enforced after an approved plan, clickable artifact shortcuts that survive session resume, and reliable cancellation recovery. Thanks to @2409324124. Closes #805.
Binary file modified assets/nanocoder-vscode.vsix
Binary file not shown.
2 changes: 1 addition & 1 deletion plugins/vscode/media/chat-panel.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions plugins/vscode/media/chat-panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
</div>

<div class="p-4 pt-2 bg-vscode-bg shrink-0">
<div id="artifact-bar" class="hidden mb-2 px-3 py-2 bg-vscode-widget-bg border border-vscode-widget-border rounded-lg items-center gap-2">
<span class="font-vscode text-[0.78em] uppercase tracking-[0.04em] opacity-60 shrink-0">Artifacts</span>
<div id="artifact-links" class="flex flex-wrap gap-1.5 min-w-0"></div>
</div>
<div id="composer-box" class="flex flex-col bg-vscode-input-bg border border-vscode-input-border rounded-2xl focus-within:border-vscode-input-focus transition-colors shadow-sm relative">
<div id="image-preview-container" class="flex flex-wrap gap-2 px-3 pt-3 empty:hidden"></div>
<!-- NEW: Attached context chips row -->
Expand Down
121 changes: 121 additions & 0 deletions plugins/vscode/media/chat-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
const messagesContainer = document.getElementById('messages-container');
const chatInput = document.getElementById('chat-input');
const composerBox = document.getElementById('composer-box');
const artifactBar = document.getElementById('artifact-bar');
const artifactLinks = document.getElementById('artifact-links');
const contextChipsContainer = document.getElementById('context-chips');
const addMenuBtn = document.getElementById('add-menu-btn');
const addMenuDropdown = document.getElementById('add-menu-dropdown');
Expand Down Expand Up @@ -522,6 +524,110 @@
}
}

function setPlanReviewActive(active) {
if (active) {
if (typeof closeMention === 'function') closeMention();
if (typeof closeAllDropdowns === 'function') closeAllDropdowns();
}
chatInput.disabled = active;
composerBox.classList.toggle('opacity-60', active);
composerBox.classList.toggle('pointer-events-none', active);
}

function removePlanReview() {
const existing = document.getElementById('plan-review-card');
if (existing) existing.remove();
setPlanReviewActive(false);
}

function renderArtifacts(artifacts) {
if (!artifactBar || !artifactLinks) return;
artifactLinks.innerHTML = '';
const labels = {
implementation_plan: 'Plan',
task: 'Tasks',
walkthrough: 'Walkthrough',
};
for (const artifact of Array.isArray(artifacts) ? artifacts : []) {
if (!artifact || !labels[artifact.kind] || typeof artifact.path !== 'string') continue;
const button = document.createElement('button');
button.type = 'button';
button.className = 'bg-vscode-editor-bg border border-vscode-widget-border hover:border-vscode-focusBorder rounded px-2 py-1 cursor-pointer font-vscode text-[0.78em] text-vscode-fg';
button.textContent = labels[artifact.kind];
button.title = artifact.path;
button.onclick = () => {
vscode.postMessage({type: 'openPath', path: artifact.path, kind: 'file'});
};
artifactLinks.appendChild(button);
}
const hasArtifacts = artifactLinks.childElementCount > 0;
artifactBar.classList.toggle('hidden', !hasArtifacts);
artifactBar.classList.toggle('flex', hasArtifacts);
}

function renderPlanReview(artifactPath) {
removePlanReview();
endCurrentTextBlock();

const card = document.createElement('div');
card.id = 'plan-review-card';
card.className = 'my-3 border border-vscode-focusBorder rounded-lg bg-vscode-widget-bg overflow-hidden shrink-0';

const header = document.createElement('div');
header.className = 'px-3 py-2 bg-vscode-widget-header border-b border-vscode-widget-border';
const title = document.createElement('div');
title.className = 'font-vscode text-[0.95em] font-semibold';
title.textContent = 'Implementation plan ready';
const subtitle = document.createElement('div');
subtitle.className = 'font-vscode text-[0.82em] opacity-65 mt-0.5';
subtitle.textContent = 'Review the saved plan before implementation begins.';
header.appendChild(title);
header.appendChild(subtitle);

const body = document.createElement('div');
body.className = 'px-3 py-3 flex flex-col gap-2.5';
const openButton = document.createElement('button');
openButton.type = 'button';
openButton.className = 'w-full text-left bg-vscode-editor-bg border border-vscode-widget-border hover:border-vscode-focusBorder rounded px-3 py-2 cursor-pointer font-vscode text-[0.9em] transition-colors';
openButton.textContent = 'Open implementation_plan.md';
openButton.title = artifactPath;
openButton.onclick = () => {
vscode.postMessage({type: 'openPath', path: artifactPath, kind: 'file'});
};

const actions = document.createElement('div');
actions.className = 'flex flex-col gap-1.5';
const approveButton = document.createElement('button');
approveButton.type = 'button';
approveButton.className = 'w-full border-none rounded px-3 py-2 cursor-pointer font-vscode text-[0.9em] bg-vscode-button-bg text-vscode-button-fg hover:bg-vscode-button-hover';
approveButton.textContent = 'Yes, execute this plan';
approveButton.onclick = () => {
removePlanReview();
setProcessing(true);
vscode.postMessage({type: 'approvePlan'});
};

const reviseButton = document.createElement('button');
reviseButton.type = 'button';
reviseButton.className = 'w-full bg-transparent border border-vscode-button-secondary text-vscode-fg hover:bg-vscode-button-secondaryHover rounded px-3 py-2 cursor-pointer font-vscode text-[0.9em]';
reviseButton.textContent = 'No, tell Nanocoder what to change';
reviseButton.onclick = () => {
removePlanReview();
vscode.postMessage({type: 'revisePlan'});
chatInput.focus();
};

actions.appendChild(approveButton);
actions.appendChild(reviseButton);
body.appendChild(openButton);
body.appendChild(actions);
card.appendChild(header);
card.appendChild(body);
messagesContainer.appendChild(card);
setPlanReviewActive(true);
scrollToBottom();
}

// Shared by the Stop button and Escape so the two can't drift apart.
function requestCancel() {
vscode.postMessage({ type: 'cancel' });
Expand Down Expand Up @@ -808,6 +914,10 @@
return;
}

// Keep typed text queued in the composer while the current response is
// still running. The Stop button remains available for cancellation.
if (isProcessing) return;

// Append attached paths as context lines
if (attachedPaths.length > 0) {
const contextText = attachedPaths
Expand Down Expand Up @@ -1437,6 +1547,7 @@
currentTurnEl = null;
currentTextEl = null;
currentTurnText = '';
removePlanReview();
toolKinds.clear();
agentTurnId = 0;
lastAgentRawTurnId = -1;
Expand All @@ -1463,6 +1574,16 @@
case 'permissionRequested':
handlePermissionRequested(message.toolCallId, message.toolCall, message.options);
break;
case 'planReviewRequested':
setProcessing(false);
renderPlanReview(message.artifactPath);
break;
case 'planReviewError':
setProcessing(false);
break;
case 'artifactsUpdated':
renderArtifacts(message.artifacts);
break;
case 'permissionsCancelled':
handlePermissionsCancelled(message.toolCallIds);
break;
Expand Down
41 changes: 25 additions & 16 deletions plugins/vscode/src/acp-client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import {ClientSideConnection} from '@agentclientprotocol/sdk';
import {AcpStateManager, ACPStatus} from './acp-state';
import {PromptAttempt} from './prompt-attempt';

// We expect at least the version of the CLI where ACP was introduced
const MINIMUM_CLI_VERSION = '0.4.0';
Expand All @@ -24,6 +25,7 @@ export class NanocoderAcpClient {
/** Fires with the tool call ids whose approval cards should be dismissed. */
public onPermissionsCancelled?: (toolCallIds: string[]) => void;
public onStateSync?: (state: StateSyncPayload) => void;
public onSessionArtifacts?: (meta: unknown) => void;
public onConnectionReady?: () => void;

public currentMode?: string;
Expand All @@ -34,15 +36,7 @@ export class NanocoderAcpClient {
public availableProviders: string[] = [];

private pendingPermissions = new Map<string, (response: unknown) => void>();
/**
* Set while a cancel is in flight for the current turn. A cancelled prompt()
* rejects (the agent throws to abort its stream), but that's the user's own
* request succeeding, not a failure, so we swallow the toast for it here.
* This is a client-side backstop: older/unrelinked CLI builds may not yet
* resolve cancellation cleanly on their end, so we can't rely solely on the
* agent reporting it as a non-error.
*/
private cancelRequested = false;
private activePrompt?: PromptAttempt;

constructor(outputChannel: vscode.OutputChannel, stateManager: AcpStateManager) {
this.outputChannel = outputChannel;
Expand Down Expand Up @@ -193,6 +187,7 @@ export class NanocoderAcpClient {

const result = await this.connection.newSession({ cwd, mcpServers: [] });
this._sessionId = result.sessionId;
this.onSessionArtifacts?.(result._meta);

// Parse modes and configOptions
if (result.modes) {
Expand Down Expand Up @@ -305,6 +300,7 @@ export class NanocoderAcpClient {
// Abandoning the conversation abandons its approval prompts too.
this._clearPendingPermissions();
this._sessionId = undefined;
this.onSessionArtifacts?.(undefined);
}
/**
* Send a prompt and return the agent's PromptResponse (carries the
Expand All @@ -313,7 +309,8 @@ export class NanocoderAcpClient {
*/
async prompt(text: string, images?: { data: string, mimeType: string }[]): Promise<import('@agentclientprotocol/sdk').PromptResponse | undefined> {
if (!this.connection || !this._sessionId) return undefined;
this.cancelRequested = false;
const attempt = new PromptAttempt();
this.activePrompt = attempt;
try {
const promptData: import('@agentclientprotocol/sdk').ContentBlock[] = [{ type: 'text', text }];
if (images && images.length > 0) {
Expand All @@ -326,20 +323,23 @@ export class NanocoderAcpClient {
prompt: promptData
});
} catch (error) {
this.outputChannel.appendLine(`Prompt failed: ${error}`);
if (!this.cancelRequested) {
if (attempt.cancelRequested) {
this.outputChannel.appendLine('Prompt cancelled by user.');
} else {
this.outputChannel.appendLine(`Prompt failed: ${error}`);
vscode.window.showErrorMessage(`Nanocoder prompt failed: ${error}`);
}
return undefined;
} finally {
this.cancelRequested = false;
if (this.activePrompt === attempt) {
this.activePrompt = undefined;
}
}
}

async cancel(): Promise<void> {
if (!this.connection || !this._sessionId) return;
this.cancelRequested = true;
// Before the notification, so the map is emptied even if cancel() throws.
this.activePrompt?.cancel();
this._clearPendingPermissions();
try {
await this.connection.cancel({
Expand Down Expand Up @@ -429,7 +429,16 @@ export class NanocoderAcpClient {
this._sessionId = sessionId;
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const cwd = workspaceFolder?.uri.fsPath || process.cwd();
await this.connection.resumeSession({sessionId, cwd});
const result = await this.connection.resumeSession({sessionId, cwd});
if (result.modes) {
this.currentMode = result.modes.currentModeId;
this.availableModes = result.modes.availableModes.map((mode: any) => mode.id);
}
if (result.configOptions) {
this._parseConfigOptions(result.configOptions);
}
this.onSessionArtifacts?.(result._meta);
this.notifyStateSync();
} catch (error) {
this.outputChannel.appendLine(`resumeSession failed: ${error}`);
vscode.window.showErrorMessage(`Failed to resume session: ${error}`);
Expand Down
68 changes: 68 additions & 0 deletions plugins/vscode/src/artifact-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import test from 'ava';
import {ArtifactController} from './artifact-controller';

test('ArtifactController collects lifecycle artifacts and replaces them on resume', t => {
const controller = new ArtifactController();
controller.observeSessionUpdate({
update: {
sessionUpdate: 'tool_call_update',
_meta: {
'nanocoder/artifact': {
kind: 'walkthrough',
path: '/tmp/walkthrough.md',
},
},
},
});

t.deepEqual(controller.artifacts, [
{kind: 'walkthrough', path: '/tmp/walkthrough.md'},
]);

controller.replaceFromMeta({
'nanocoder/artifacts': [
{kind: 'task', path: '/tmp/task.md'},
{kind: 'implementation_plan', path: '/tmp/implementation_plan.md'},
],
});

t.deepEqual(controller.artifacts, [
{kind: 'implementation_plan', path: '/tmp/implementation_plan.md'},
{kind: 'task', path: '/tmp/task.md'},
]);
});

test('ArtifactController reports only real artifact changes', t => {
const controller = new ArtifactController();
const update = {
sessionUpdate: 'tool_call_update',
_meta: {
'nanocoder/artifact': {
kind: 'task',
path: '/tmp/task.md',
},
},
};

t.true(controller.observeSessionUpdate(update));
t.false(controller.observeSessionUpdate(update), 'same artifact is not a change');
t.false(
controller.observeSessionUpdate({
sessionUpdate: 'agent_message_chunk',
content: {type: 'text', text: 'streamed token'},
}),
'streaming updates do not trigger artifact refreshes',
);
t.true(
controller.observeSessionUpdate({
...update,
_meta: {
'nanocoder/artifact': {
kind: 'task',
path: '/tmp/new-task.md',
},
},
}),
'a changed path is reported',
);
});
Loading
Loading