Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Why

- The VS Code companion already ships bundled semantic icons for OpenSpec workflow files, but the Active Agents raw tree still falls back to generic folder/file icons for those same nodes.
- Operators scanning live agent lanes inside Active Agents cannot quickly distinguish `proposal.md`, `tasks.md`, `spec.md`, or OpenSpec folders without switching back to Explorer.

## What Changes

- Reuse the bundled file-icon manifest to resolve semantic SVG icons for Active Agents folder/file tree items when no higher-priority icon override is already set.
- Mirror the same behavior into the template extension source so fresh installs and workspace copies stay aligned.
- Add focused regression coverage for OpenSpec folder/file nodes in the Active Agents raw tree.

## Impact

- Affected surfaces: `vscode/guardex-active-agents/extension.js`, `templates/vscode/guardex-active-agents/extension.js`, and `test/vscode-active-agents-session-state.test.js`.
- Risk stays narrow: presentation-only behavior inside the VS Code Active Agents tree, with warning/lock icon overrides preserved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## ADDED Requirements

### Requirement: Active Agents raw tree uses bundled workflow icons

The Active Agents raw tree SHALL use bundled semantic workflow icons for OpenSpec folders and files when no higher-priority status icon override applies.

#### Scenario: OpenSpec folders use semantic icons in the raw tree

- **GIVEN** the Active Agents raw tree renders OpenSpec folder nodes such as `changes` and `specs`
- **WHEN** those tree items are displayed
- **THEN** `changes` uses the bundled OpenSpec icon asset
- **AND** `specs` uses the bundled spec icon asset

#### Scenario: OpenSpec files use semantic icons in the raw tree

- **GIVEN** the Active Agents raw tree renders `proposal.md`, `tasks.md`, or `spec.md` nodes without lock/warning overrides
- **WHEN** those file items are displayed
- **THEN** each node uses the bundled semantic icon asset that matches the shipped file-icon manifest

#### Scenario: Warning icons still override bundled file icons

- **GIVEN** an Active Agents change row carries an explicit warning icon or foreign-lock warning state
- **WHEN** that row is rendered
- **THEN** the warning icon remains visible instead of a bundled workflow file icon
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Definition of Done

This change is complete only when all of the following are true:

- Every checkbox below is checked.
- Focused Active Agents regression coverage passes.
- Cleanup records the final PR URL plus `MERGED` evidence, or a `BLOCKED:` line explains why finish could not complete.

Handoff: 2026-04-23 codex owns branch `agent/codex/add-openspec-and-provider-icons-2026-04-23-16-49`, the Active Agents live/template tree-item icon resolver, focused tests, and this OpenSpec change for semantic OpenSpec icons inside the Active Agents raw tree.

## 1. Specification

- [x] 1.1 Finalize proposal scope for semantic OpenSpec folder/file icons inside the Active Agents raw tree.
- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-provider-icons/spec.md`.

## 2. Implementation

- [x] 2.1 Resolve bundled semantic icons from the shipped file-icon manifest for Active Agents folder/file tree items when no higher-priority status icon is set.
- [x] 2.2 Mirror the same tree-item icon resolver behavior into the template extension source.
- [x] 2.3 Add focused regression coverage for `changes`, `specs`, `proposal.md`, `tasks.md`, and `spec.md` nodes in the Active Agents raw tree.

## 3. Verification

- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. Result: passed `48/48`.
- [x] 3.2 Run `openspec validate agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49 --type change --strict`. Result: passed.
- [x] 3.3 Run `openspec validate --specs`. Result: exited `0` with `No items found to validate.`

## 4. Cleanup (mandatory; run before claiming completion)

- [ ] 4.1 Run `gx branch finish --branch "agent/codex/add-openspec-and-provider-icons-2026-04-23-16-49" --base main --via-pr --wait-for-merge --cleanup`.
- [ ] 4.2 Record the PR URL and final `MERGED` state in the completion handoff.
- [ ] 4.3 Confirm the sandbox worktree and branch refs are gone after cleanup.

BLOCKED: none.
78 changes: 77 additions & 1 deletion templates/vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const REFRESH_POLL_INTERVAL_MS = 30_000;
const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect';
const GIT_CONFIGURATION_SECTION = 'git';
const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders';
const BUNDLED_FILE_ICONS_MANIFEST_RELATIVE = path.join('fileicons', 'gitguardex-fileicons.json');
const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
'.omx/agent-worktrees',
'**/.omx/agent-worktrees',
Expand Down Expand Up @@ -74,6 +75,7 @@ const SESSION_PROVIDER_BRANDS = {
badge: 'CL',
},
};
let bundledTreeIconThemeCache = null;

function iconColorId(iconId) {
switch (iconId) {
Expand Down Expand Up @@ -119,6 +121,76 @@ function sessionDecorationUri(branch) {
return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`);
}

function emptyBundledTreeIconTheme() {
return {
iconPathById: new Map(),
fileNames: {},
folderNames: {},
fileExtensions: {},
};
}

function loadBundledTreeIconTheme() {
if (bundledTreeIconThemeCache) {
return bundledTreeIconThemeCache;
}

const manifestPath = path.join(__dirname, BUNDLED_FILE_ICONS_MANIFEST_RELATIVE);
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestDir = path.dirname(manifestPath);
const iconPathById = new Map();
for (const [iconId, definition] of Object.entries(parsed?.iconDefinitions || {})) {
if (typeof definition?.iconPath !== 'string' || !definition.iconPath.trim()) {
continue;
}
const iconUri = vscode.Uri.file(path.resolve(manifestDir, definition.iconPath));
iconPathById.set(iconId, {
light: iconUri,
dark: iconUri,
});
}
bundledTreeIconThemeCache = {
iconPathById,
fileNames: parsed?.fileNames || {},
folderNames: parsed?.folderNames || {},
fileExtensions: parsed?.fileExtensions || {},
};
} catch (_error) {
bundledTreeIconThemeCache = emptyBundledTreeIconTheme();
}

return bundledTreeIconThemeCache;
}

function resolveBundledTreeItemIconId(relativePath, kind = 'file') {
const normalizedRelativePath = normalizeRelativePath(relativePath);
const entryName = path.posix.basename(normalizedRelativePath || '');
if (!entryName) {
return '';
}

const bundledTheme = loadBundledTreeIconTheme();
if (kind === 'folder') {
return bundledTheme.folderNames[entryName] || '';
}

if (bundledTheme.fileNames[entryName]) {
return bundledTheme.fileNames[entryName];
}

const matchingExtension = Object.keys(bundledTheme.fileExtensions)
.sort((left, right) => right.length - left.length)
.find((extension) => entryName === extension || entryName.endsWith(`.${extension}`));
return matchingExtension ? bundledTheme.fileExtensions[matchingExtension] : '';
}

function resolveBundledTreeItemIcon(relativePath, kind = 'file') {
const bundledTheme = loadBundledTreeIconTheme();
const iconId = resolveBundledTreeItemIconId(relativePath, kind);
return iconId ? bundledTheme.iconPathById.get(iconId) : undefined;
}

function sessionIdleDecoration(session, now = Date.now()) {
if (!session) {
return undefined;
Expand Down Expand Up @@ -1236,7 +1308,9 @@ class FolderItem extends vscode.TreeItem {
this.items = items;
this.description = typeof options.description === 'string' ? options.description : '';
this.tooltip = options.tooltip || relativePath || label;
this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId);
this.iconPath = options.iconPath
|| (!options.iconId ? resolveBundledTreeItemIcon(relativePath || label, 'folder') : undefined)
|| themeIcon(options.iconId || 'folder', options.iconColorId);
this.contextValue = options.contextValue || 'gitguardex.folder';
}
}
Expand All @@ -1262,6 +1336,8 @@ class ChangeItem extends vscode.TreeItem {
this.resourceUri = vscode.Uri.file(change.absolutePath);
if (options.iconId || change.hasForeignLock) {
this.iconPath = themeIcon(options.iconId || 'warning', options.iconColorId || 'list.warningForeground');
} else {
this.iconPath = options.iconPath || resolveBundledTreeItemIcon(change.relativePath || label, 'file');
}
this.contextValue = 'gitguardex.change';
this.command = {
Expand Down
81 changes: 81 additions & 0 deletions test/vscode-active-agents-session-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,15 @@ async function getChildByLabel(provider, parentItem, label) {
return match;
}

function assertBundledIcon(item, iconFileName) {
assert.equal(
item?.iconPath?.light?.fsPath.endsWith(path.join('fileicons', 'icons', iconFileName)),
true,
`Expected ${item?.label || 'item'} to use ${iconFileName}`,
);
assert.equal(item?.iconPath?.light?.fsPath, item?.iconPath?.dark?.fsPath);
}

async function getSessionByBranch(provider, sectionItem, branch) {
const children = await provider.getChildren(sectionItem);
const match = children.find((item) => item.session?.branch === branch);
Expand Down Expand Up @@ -3323,3 +3332,75 @@ test('active-agents extension opens the selected changed file through the Git di
subscription.dispose?.();
}
});

test('active-agents extension uses bundled OpenSpec icons in Active Agents tree nodes', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-openspec-icons-'));
initGitRepo(tempRoot);
const branch = 'agent/codex/openspec-icons';
runGit(tempRoot, ['checkout', '-b', branch]);

const proposalPath = path.join(tempRoot, 'openspec', 'changes', 'icon-pass', 'proposal.md');
const tasksPath = path.join(tempRoot, 'openspec', 'changes', 'icon-pass', 'tasks.md');
const specPath = path.join(tempRoot, 'openspec', 'changes', 'icon-pass', 'specs', 'active-agents-icons', 'spec.md');
fs.mkdirSync(path.dirname(proposalPath), { recursive: true });
fs.mkdirSync(path.dirname(specPath), { recursive: true });
fs.writeFileSync(proposalPath, 'proposal base\n', 'utf8');
fs.writeFileSync(tasksPath, 'tasks base\n', 'utf8');
fs.writeFileSync(specPath, 'spec base\n', 'utf8');
runGit(tempRoot, ['add', 'openspec']);
runGit(tempRoot, ['commit', '-m', 'baseline']);
fs.writeFileSync(proposalPath, 'proposal base\nchanged\n', 'utf8');
fs.writeFileSync(tasksPath, 'tasks base\nchanged\n', 'utf8');
fs.writeFileSync(specPath, 'spec base\nchanged\n', 'utf8');

const sessionPath = sessionSchema.sessionFilePathForBranch(tempRoot, branch);
fs.mkdirSync(path.dirname(sessionPath), { recursive: true });
fs.writeFileSync(
sessionPath,
`${JSON.stringify(sessionSchema.buildSessionRecord({
repoRoot: tempRoot,
branch,
taskName: 'openspec-icons',
agentName: 'codex',
worktreePath: tempRoot,
pid: process.pid,
cliName: 'codex',
}), null, 2)}\n`,
'utf8',
);

const { registrations, vscode } = createMockVscode(tempRoot);
vscode.workspace.findFiles = async () => [{ fsPath: sessionPath }];
const extension = loadExtensionWithMockVscode(vscode);
const context = { subscriptions: [] };

extension.activate(context);
await flushAsyncWork();

const provider = registrations.providers[0].provider;
const [repoItem] = await provider.getChildren();
const advancedSection = await getSectionByLabel(provider, repoItem, 'Advanced details');
const activeAgentTree = await getSectionByLabel(provider, advancedSection, 'Active agent tree');
const rawWorkingSection = await getSectionByLabel(provider, activeAgentTree, 'WORKING NOW');
const { sessionItem } = await getOnlyWorktreeAndSession(provider, rawWorkingSection);

const openspecFolder = await getChildByLabel(provider, sessionItem, 'openspec');
const changesFolder = await getChildByLabel(provider, openspecFolder, 'changes');
assertBundledIcon(changesFolder, 'openspec.svg');

const iconPassFolder = await getChildByLabel(provider, changesFolder, 'icon-pass');
const proposalItem = await getChildByLabel(provider, iconPassFolder, 'proposal.md');
const specsFolder = await getChildByLabel(provider, iconPassFolder, 'specs');
const tasksItem = await getChildByLabel(provider, iconPassFolder, 'tasks.md');
assertBundledIcon(proposalItem, 'openspec.svg');
assertBundledIcon(specsFolder, 'spec.svg');
assertBundledIcon(tasksItem, 'plan.svg');

const activeAgentsIconsFolder = await getChildByLabel(provider, specsFolder, 'active-agents-icons');
const specItem = await getChildByLabel(provider, activeAgentsIconsFolder, 'spec.md');
assertBundledIcon(specItem, 'spec.svg');

for (const subscription of context.subscriptions) {
subscription.dispose?.();
}
});
78 changes: 77 additions & 1 deletion vscode/guardex-active-agents/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const REFRESH_POLL_INTERVAL_MS = 30_000;
const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect';
const GIT_CONFIGURATION_SECTION = 'git';
const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders';
const BUNDLED_FILE_ICONS_MANIFEST_RELATIVE = path.join('fileicons', 'gitguardex-fileicons.json');
const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
'.omx/agent-worktrees',
'**/.omx/agent-worktrees',
Expand Down Expand Up @@ -74,6 +75,7 @@ const SESSION_PROVIDER_BRANDS = {
badge: 'CL',
},
};
let bundledTreeIconThemeCache = null;

function iconColorId(iconId) {
switch (iconId) {
Expand Down Expand Up @@ -119,6 +121,76 @@ function sessionDecorationUri(branch) {
return vscode.Uri.parse(`${SESSION_DECORATION_SCHEME}://${sanitizeBranchForFile(branch)}`);
}

function emptyBundledTreeIconTheme() {
return {
iconPathById: new Map(),
fileNames: {},
folderNames: {},
fileExtensions: {},
};
}

function loadBundledTreeIconTheme() {
if (bundledTreeIconThemeCache) {
return bundledTreeIconThemeCache;
}

const manifestPath = path.join(__dirname, BUNDLED_FILE_ICONS_MANIFEST_RELATIVE);
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestDir = path.dirname(manifestPath);
const iconPathById = new Map();
for (const [iconId, definition] of Object.entries(parsed?.iconDefinitions || {})) {
if (typeof definition?.iconPath !== 'string' || !definition.iconPath.trim()) {
continue;
}
const iconUri = vscode.Uri.file(path.resolve(manifestDir, definition.iconPath));
iconPathById.set(iconId, {
light: iconUri,
dark: iconUri,
});
}
bundledTreeIconThemeCache = {
iconPathById,
fileNames: parsed?.fileNames || {},
folderNames: parsed?.folderNames || {},
fileExtensions: parsed?.fileExtensions || {},
};
} catch (_error) {
bundledTreeIconThemeCache = emptyBundledTreeIconTheme();
}

return bundledTreeIconThemeCache;
}

function resolveBundledTreeItemIconId(relativePath, kind = 'file') {
const normalizedRelativePath = normalizeRelativePath(relativePath);
const entryName = path.posix.basename(normalizedRelativePath || '');
if (!entryName) {
return '';
}

const bundledTheme = loadBundledTreeIconTheme();
if (kind === 'folder') {
return bundledTheme.folderNames[entryName] || '';
}

if (bundledTheme.fileNames[entryName]) {
return bundledTheme.fileNames[entryName];
}

const matchingExtension = Object.keys(bundledTheme.fileExtensions)
.sort((left, right) => right.length - left.length)
.find((extension) => entryName === extension || entryName.endsWith(`.${extension}`));
return matchingExtension ? bundledTheme.fileExtensions[matchingExtension] : '';
}

function resolveBundledTreeItemIcon(relativePath, kind = 'file') {
const bundledTheme = loadBundledTreeIconTheme();
const iconId = resolveBundledTreeItemIconId(relativePath, kind);
return iconId ? bundledTheme.iconPathById.get(iconId) : undefined;
}

function sessionIdleDecoration(session, now = Date.now()) {
if (!session) {
return undefined;
Expand Down Expand Up @@ -1236,7 +1308,9 @@ class FolderItem extends vscode.TreeItem {
this.items = items;
this.description = typeof options.description === 'string' ? options.description : '';
this.tooltip = options.tooltip || relativePath || label;
this.iconPath = themeIcon(options.iconId || 'folder', options.iconColorId);
this.iconPath = options.iconPath
|| (!options.iconId ? resolveBundledTreeItemIcon(relativePath || label, 'folder') : undefined)
|| themeIcon(options.iconId || 'folder', options.iconColorId);
this.contextValue = options.contextValue || 'gitguardex.folder';
}
}
Expand All @@ -1262,6 +1336,8 @@ class ChangeItem extends vscode.TreeItem {
this.resourceUri = vscode.Uri.file(change.absolutePath);
if (options.iconId || change.hasForeignLock) {
this.iconPath = themeIcon(options.iconId || 'warning', options.iconColorId || 'list.warningForeground');
} else {
this.iconPath = options.iconPath || resolveBundledTreeItemIcon(change.relativePath || label, 'file');
}
this.contextValue = 'gitguardex.change';
this.command = {
Expand Down