From d1883c785adb61518a7d5a8005720e7bd7a0def6 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 16:53:14 +0200 Subject: [PATCH] Make Active Agents tree distinguish OpenSpec workflow nodes The companion already shipped semantic OpenSpec icons in Explorer, but the Active Agents raw tree still flattened those nodes to generic file and folder icons. This reuses the bundled icon manifest for tree items, preserves warning overrides, mirrors the template source, and adds focused regression coverage plus a follow-up OpenSpec lane. Constraint: Keep warning and lock icons higher priority than workflow art Rejected: Rely on the Explorer file icon theme alone | Active Agents tree rows still need explicit iconPath resolution Confidence: high Scope-risk: narrow Directive: Keep the file-icon manifest and tree-item resolver in sync when adding new workflow file names Tested: node --test test/vscode-active-agents-session-state.test.js Tested: openspec validate agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49 --type change --strict Tested: openspec validate --specs Not-tested: Manual VS Code desktop install/reload check --- .../proposal.md | 15 ++++ .../spec.md | 24 ++++++ .../tasks.md | 34 ++++++++ .../vscode/guardex-active-agents/extension.js | 78 +++++++++++++++++- ...vscode-active-agents-session-state.test.js | 81 +++++++++++++++++++ vscode/guardex-active-agents/extension.js | 78 +++++++++++++++++- 6 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/proposal.md create mode 100644 openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/specs/vscode-active-agents-provider-icons/spec.md create mode 100644 openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/tasks.md diff --git a/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/proposal.md b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/proposal.md new file mode 100644 index 0000000..f298bd2 --- /dev/null +++ b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/proposal.md @@ -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. diff --git a/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/specs/vscode-active-agents-provider-icons/spec.md b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/specs/vscode-active-agents-provider-icons/spec.md new file mode 100644 index 0000000..a5db45a --- /dev/null +++ b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/specs/vscode-active-agents-provider-icons/spec.md @@ -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 diff --git a/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/tasks.md b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/tasks.md new file mode 100644 index 0000000..92eee6f --- /dev/null +++ b/openspec/changes/agent-codex-add-openspec-and-provider-icons-2026-04-23-16-49/tasks.md @@ -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. diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 5c573cc..10197d4 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -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', @@ -74,6 +75,7 @@ const SESSION_PROVIDER_BRANDS = { badge: 'CL', }, }; +let bundledTreeIconThemeCache = null; function iconColorId(iconId) { switch (iconId) { @@ -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; @@ -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'; } } @@ -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 = { diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index c529594..f61d6d2 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -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); @@ -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?.(); + } +}); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 5c573cc..10197d4 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -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', @@ -74,6 +75,7 @@ const SESSION_PROVIDER_BRANDS = { badge: 'CL', }, }; +let bundledTreeIconThemeCache = null; function iconColorId(iconId) { switch (iconId) { @@ -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; @@ -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'; } } @@ -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 = {