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 = {