diff --git a/openspec/changes/add-complete-artifact-inventory/.openspec.yaml b/openspec/changes/add-complete-artifact-inventory/.openspec.yaml new file mode 100644 index 0000000..84cfc12 --- /dev/null +++ b/openspec/changes/add-complete-artifact-inventory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-06 diff --git a/openspec/changes/add-complete-artifact-inventory/design.md b/openspec/changes/add-complete-artifact-inventory/design.md new file mode 100644 index 0000000..b88e5d3 --- /dev/null +++ b/openspec/changes/add-complete-artifact-inventory/design.md @@ -0,0 +1,197 @@ +## Context + +当前实现分两条平行的路径,都没有做到"按 Schema 动态"和"暴露未声明文件": + +``` +CLI 可用时(DataManager -> OpenSpecCliService) + openspec show --json + -> data.artifacts(已经是 Schema 真实返回的数组) + -> normalizeArtifactInfos() 只做字段清洗,不丢字段 + 这条路径本身已经是"动态"的,问题出在下游 Webview 把它硬套进固定 Tab + +CLI 不可用时(DataManager.listChangesFromFilesystem 的 fallback) + getFilesystemArtifactStatuses() + -> 硬编码 for (const artifactType of ['proposal', 'design', 'tasks']) + -> specs 靠 listDeltaSpecIds() 单独判断 + 这条路径确实是硬编码,但原因是没有 CLI 就没有 Schema 来源, + 不是"选择"硬编码,是没有更好的数据源 + +Webview 侧(ChangeDetail.tsx) + const ALL_TABS = [ + { id: 'proposal', label: 'Proposal' }, + { id: 'specs', label: 'Specs' }, + { id: 'design', label: 'Design' }, + { id: 'tasks', label: 'Tasks' }, + ] + ChangeDetailTabId 是这四个字面量 + 'verifyArchive' 的固定联合类型 + -> 这是真正需要改的地方:不管 Host 侧数据多动态,Tab 本身是死的 + +打开文件的行为也不一致: + openChange handler -> openTextDocument + showTextDocument + revealInExplorer + openArtifact handler -> 只有 openTextDocument + showTextDocument,没有 reveal + openDeltaSpec handler -> 同样没有 reveal + 且 openArtifact 的路径拼接是硬编码的 `${artifactType}.md`(webviewMessageHandler.ts:443), + 无法表示目录型 artifact(如 specs/)或非 .md 文件 + +已有的 `artifact-viewing` spec 里已经写了一条 +"Explorer reveal is best effort for external store roots" 的 scenario, +说明规范层面本就预期"打开 artifact 应该 reveal",只是实现没跟上。 +``` + +具体动机见 `proposal.md` - Why;本设计只处理"怎么做"。 + +## Goals / Non-Goals + +**Goals:** + +- Change Detail 的 Tab 列表由 `ChangeDetails.artifacts`(Schema 顺序)动态生成,不再是模块级常量。 +- 扫描 Change 目录,把 Schema/fallback 已知列表之外的真实文件或子目录识别为 `Other Artifacts`,用一条独立、始终可见(非空时)的条目展示,不并入任何 Tab 内容区。 +- 统一"打开"行为:单文件 reveal + open;目录型(现有的 `specs`,以及未来任何目录型 artifact 或 Other Artifact 子目录)reveal 并展开目录、默认聚焦最近更新的文件。 +- 让 `openArtifact` 具备处理目录型输出路径的能力,消除它和 `openChange` 之间的 reveal 行为差异。 + +**Non-Goals:** + +- 不重新设计 Other Artifacts 的视觉布局去匹配 v4 高保真图里的"卡片行"样式;这次先做成一条可点击的紧凑条目列表,视觉打磨留给后续。 +- 不让 filesystem fallback 路径(CLI 不可用时)具备真正的 Schema 感知能力——没有 CLI 就没有可靠的 Schema 来源,fallback 继续使用它现有的固定已知列表作为"已知 artifact"基准,Other Artifacts 的检测逻辑只是复用这个基准做目录 diff,而不是让 fallback 本身变聪明。 +- 不把"specs 是多文件、需要子选择器"这个特判泛化成"任意 Schema 声明的多文件 artifact 都自动获得同款 UI";仍然只对字面量 `specs` 保留现有子选择器行为,Other Artifacts 里的目录条目走更简单的"reveal 并选中最近文件",不做子选择器。 +- 不改动 Store/Workset/Root 解析逻辑,不新增 OpenSpec CLI 调用参数。 + +## Decisions + +### 1. 新增一个共享的 Artifact Inventory 构建函数,而不是在两条路径里各自加 diff 逻辑 + +``` +新增:src/extension/services/artifactInventory.ts + +buildOtherArtifacts( + changeDir: string, + knownOutputPaths: string[], // 来自 CLI schema 或 fallback 固定列表,统一按相对路径传入 +): Promise + +interface OtherArtifactEntry { + id: string; // 由相对路径 slug 化得到,用于消息协议里的定位 key + relativePath: string; // 相对 change 目录的路径,如 "task-details" 或 "analysis.md" + isDirectory: boolean; + fileCount: number; // 目录时统计文件数;单文件固定为 1 +} +``` + +两条现有路径(CLI 路径 `DataManager` 拿到 `ChangeDetails.artifacts` 之后、fallback 路径 `getFilesystemArtifactStatuses` 之后)都调用这同一个函数,只是传入的 `knownOutputPaths` 不同来源。这样 Other Artifacts 的识别规则只有一份,不会两条路径各写一套、逐渐分叉。 + +排除规则:跳过 `.openspec.yaml`(change 级元数据文件,不是用户可见 artifact)以及点文件/隐藏目录。 + +### 2. `openArtifact` 改为基于 Inventory 里记录的真实路径和类型,而不是拼接 `${artifactType}.md` + +现状 `webviewMessageHandler.ts` 里 `openArtifact` 自己拼路径: + +```ts +const artifactPath = path.normalize(path.join(changesBase, `${message.artifactType}.md`)); +``` + +改为:先查已经随 dashboard/change 数据一起发给 Webview 的 Artifact Inventory(`ArtifactInfo.outputPath` 现在就是 CLI 返回的真实相对路径,不需要重新拼接),再判断: + +``` +outputPath 指向单个文件 -> openTextDocument + showTextDocument + revealInExplorer(补齐现有 openChange 已有的模式) +outputPath 指向目录 -> 用 fs.readdir + stat 找目录内最近修改的文件 + -> openTextDocument + showTextDocument 该文件 + -> revealInExplorer 该文件(VS Code 会在 Explorer 树里展开其父目录) +``` + +`isPathUnderRoot` 的安全校验保持不变,只是校验对象从"拼出来的路径"变成"Inventory 里已经记录、且在构建时就已经做过一次同样校验的路径",双重校验不冲突。 + +### 3. Other Artifacts 走一条独立的新消息,不复用 `openArtifact` + +```ts +// 新增 webview -> host 消息 +{ type: 'openOtherArtifact'; changeName: string; entryId: string; scopeId?: string } +``` + +Host 收到后重新执行一次 `buildOtherArtifacts`(成本很低,一次目录扫描),按 `entryId` 找到对应条目的真实路径,做同样的"文件 reveal+open / 目录 reveal+聚焦最近文件"逻辑,而不是让 Webview 直接把路径传回来。原因: + +- 和项目里"写操作前必须重新解析 Root/路径"的既有安全惯例一致(`resolveScopeRoot` + `isPathUnderRoot` 已经在其他 handler 里这么做); +- 避免 Webview 缓存的旧路径在文件被重命名/删除后仍被信任; +- `entryId` 是扫描时生成的稳定 slug,不是绝对路径,天然避免 Webview 把任意路径回传给 Host 执行文件操作。 + +### 4. Webview 的 `ChangeDetailTabId` 从固定联合类型改为"已知特殊 tab + 动态 schema id" + +```ts +// 现状 +type ChangeDetailTabId = 'proposal' | 'specs' | 'design' | 'tasks' | 'verifyArchive'; + +// 改为 +type ChangeDetailTabId = string; // 实际取值来自 ChangeDetails.artifacts[].id,加上字面量 'verifyArchive' +``` + +`ALL_TABS` 不再是模块级常量,改为一个纯函数: + +```ts +function buildTabs(artifacts: ArtifactInfo[], showVerifyArchiveTab: boolean): TabDef[] +``` + +按 `artifacts` 数组顺序(即 Schema 顺序)生成 Tab,label 优先查一个小的 id → 展示名映射表(覆盖 `proposal/specs/design/tasks` 这四个已知 id 的现有翻译),未命中时用 id 本身做 title-case 兜底,保证任意自定义 Schema 的新 artifact id 都能显示,不会白屏。 + +对 `activeTab === 'specs'` 这类字面量特判(多文件 delta spec 子选择器)保持不变——见 Non-Goals,这次不泛化。 + +### 5. Other Artifacts 渲染为 Tab 栏下方的常驻条目区,不作为一个 Tab + +``` +┌───────────────────────────────────────────────┐ +│ [Proposal] [Specs] [Design] [Tasks] │ ← 动态 Tab(Decision 4) +├───────────────────────────────────────────────┤ +│ Other Artifacts (2) │ ← 新增,非空时才渲染 +│ [task-details · 6 files] [notes.md] │ ← 点击各自触发 openOtherArtifact +├───────────────────────────────────────────────┤ +│ │ +│ (当前 Tab 的内容区,不变) │ +│ │ +└───────────────────────────────────────────────┘ +``` + +选择"常驻条目区"而不是"再加一个 Other Tab"的原因:Other Artifacts 里的内容通常不是这个 Change 的规划文档主线(可能是分析笔记、历史遗留文件),用户更可能是"顺手看一眼/跳转过去",不需要占用主 Tab 导航的心智位置;同时避免触碰 Decision 4 里刚刚泛化的 Tab 类型,把"已知 Schema Tab"和"未知文件列表"两个概念在代码里也分开,互不影响。 + +## Message / Data Flow + +``` +DataManager.getChangeDetails(changeName) + -> CLI: openspec show --json(已有) + -> 新增: buildOtherArtifacts(changeDir, artifacts.map(a => a.outputPath)) + -> ChangeDetails 新增字段 otherArtifacts: OtherArtifactEntry[] + -> 经既有 IPC 通道随 change detail 数据一起发给 Webview + +Webview ChangeDetail.tsx + -> buildTabs(changeDetails.artifacts, showVerifyArchiveTab) 生成 Tab + -> otherArtifacts.length > 0 时渲染条目区 + -> 用户点击 Other Artifacts 条目 + -> postMessage({ type: 'openOtherArtifact', changeName, entryId, scopeId }) + -> 用户点击 Schema Tab 后点 "Open in Editor"(现有交互不变) + -> postMessage(sendMessage.openArtifact(changeName, activeTab, scopeId)) + +webviewMessageHandler.ts + case 'openArtifact' -> 查 Inventory 里的 outputPath -> 文件: reveal+open / 目录: reveal+open最近文件 + case 'openOtherArtifact' -> 重新 buildOtherArtifacts -> 按 entryId 命中 -> 同上开一套逻辑 +``` + +## Risks / Trade-offs + +- [Risk] 目录扫描(`buildOtherArtifacts`)在超大 Change 目录下可能变慢。 + → 只扫描 Change 目录的直接子项(不递归深入已知 artifact 目录内部,如 `specs/` 内部不再二次扫描),文件数统计只对被识别为"其他"的子目录做一层 `readdir`,不递归整棵树。 + +- [Risk] `ChangeDetailTabId` 从字面量联合类型放宽为 `string` 后,原本靠 TypeScript 字面量类型帮忙检查的 `activeTab === 'specs'` 之类比较,编译期检查会变弱,容易手滑打错字符串。 + → 把已知特殊值(`'specs'`、`'verifyArchive'`)提成命名常量,比较时用常量而不是裸字符串,减少手误风险。 + +- [Risk] 自定义 Schema(比如本仓库自己声明过、当前分支缺失定义文件的 `aihelp-dev`)产出的 artifact id 如果和某个内部保留字冲突(例如某个 Schema 真的定义了一个叫 `verifyArchive` 的 artifact),会和现有 Verify & Archive 特殊 Tab 冲突。 + → 在 `buildTabs` 里加一条防御:Schema artifact id 命中 `verifyArchive` 时记录 warning 并跳过該条(不覆盖内置特殊 Tab),不阻塞渲染。 + +- [Risk] `openOtherArtifact` 每次点击都重新扫描目录,如果用户在同一个 Change 里连续点开多个 Other Artifact 条目,会有重复扫描开销。 + → 扫描本身很轻(单层 readdir),且只在用户主动点击时触发,不在渲染/轮询路径上,可接受;后续如有需要可以加短期内存缓存,这次不做。 + +## Migration Plan + +1. Extension Host:新增 `artifactInventory.ts`,`DataManager`/CLI 路径和 filesystem fallback 路径分别接入,先只新增 `otherArtifacts` 字段,不改动现有 `artifacts` 字段行为(纯增量,不破坏现有消费者)。 +2. 扩展 message types:新增 `openOtherArtifact`,`webviewMessageHandler.ts` 新增对应 case;改造 `openArtifact` 使用 Inventory 里的真实 outputPath 并补上 `revealInExplorer`。 +3. Webview:`ChangeDetailTabId` 放宽为 `string` + 命名常量;`ALL_TABS` 改为 `buildTabs()`;新增 Other Artifacts 条目区组件。 +4. i18n:补充 `Other Artifacts`、空态、tooltip 文案(中英)。 +5. 测试:Host 侧 `buildOtherArtifacts` 的单元测试(已知列表之外的文件/目录识别、排除 `.openspec.yaml`、目录文件计数);`openArtifact`/`openOtherArtifact` handler 的路径解析和 reveal 调用测试;Webview `buildTabs` 的单元测试(自定义 Schema id、`verifyArchive` 冲突防御);组件测试确认 Other Artifacts 为空时整块不渲染。 +6. 构建验证:`pnpm test`、`pnpm run build`、`openspec validate add-complete-artifact-inventory --strict`。 + +回滚:所有改动都是增量字段/新 case,没有破坏性删除旧字段或旧消息类型;如需回滚,Webview 可以退回读取旧的固定 `ALL_TABS` 常量,Host 侧新字段留着不用即可,不需要数据迁移。 diff --git a/openspec/changes/add-complete-artifact-inventory/proposal.md b/openspec/changes/add-complete-artifact-inventory/proposal.md new file mode 100644 index 0000000..2f223ea --- /dev/null +++ b/openspec/changes/add-complete-artifact-inventory/proposal.md @@ -0,0 +1,30 @@ +## Why + +Change 目录里经常存在 Schema 未声明、但真实存在的文件(例如本仓库自己的多个 change 已经在用的 `task-details/` 子目录),但 Change Detail 目前把 Artifact Tab 硬编码为 `proposal/specs/design/tasks` 四种固定 id,这些真实文件既不出现在任何 Tab 里,也没有入口可以打开,用户只能去文件系统里自己翻找。同时现有的"Open in Editor"动作只在编辑器里打开一个 Tab,不会在 VS Code Explorer 中定位文件,用户会丢失这个文件在目录结构里的位置感。 + +## What Changes + +- Change Detail 的 Artifact Tab 改为完全由当前 Schema 动态生成(不再硬编码 `proposal/specs/design/tasks` 这四个 id),为自定义 Schema 铺路。 +- 扫描 Change 目录,识别 Schema 未声明、但真实存在的文件或子目录(例如 `task-details/`、历史遗留文件),归入一个独立的 `Other Artifacts` 分组:不隐藏、不丢弃、也不猜测归类到某个已知 Artifact 类型。 +- 扩展现有"Open in Editor"动作为"Reveal + Open":单文件 Artifact 在 VS Code Explorer 中 Reveal 并在编辑器打开;多文件 Artifact(如 `specs/` 目录、`Other Artifacts` 里的子目录)Reveal 并展开对应目录,默认聚焦最近更新的文件。 +- Missing(Schema 已声明但文件尚未创建)的 Artifact 保持现有"继续规划"入口,不尝试定位不存在的文件。 + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `artifact-viewing`: Artifact 列表从硬编码四类改为按当前 Schema 动态生成,并新增 `Other Artifacts` 分组;Artifact 打开动作从"仅在编辑器打开"扩展为"Reveal in Explorer + Open in Editor"。 + +## Impact + +- Extension Host:新增 Artifact Inventory 构建逻辑,对比当前 Schema 声明的 Artifact 路径与 Change 目录实际内容,得到 `Other Artifacts` 列表(文件/子目录路径与文件数量)。 +- Extension Host:扩展 `openArtifact` / `openDeltaSpec` 消息处理,复用 `openChange` 中已经在用的 `revealInExplorer` 调用,使其对新的 Other Artifacts 同样生效。 +- Webview:`ChangeDetail` / `ArtifactViewer` 新增 `Other Artifacts` 分组渲染;Artifact Tab 列表改为读取当前 Schema 的 artifact 顺序,不再写死四个固定 id。 +- Types:新增描述 Other Artifact 的展示类型(路径、文件数量、来源标记)。 +- i18n:新增 `Other Artifacts / Not defined in schema` 及相关空状态、tooltip 文案(中英文)。 +- 不影响 Dashboard / ChangeCard 上现有的 Schema Artifact 徽标;`Other Artifacts` 仅在 Change Detail 内展示,Dashboard 卡片层的展示留作后续独立 Change。 +- 不涉及 OpenSpec CLI 改动,不改变 Root 解析、Store 关联或 Workset 逻辑。 diff --git a/openspec/changes/add-complete-artifact-inventory/specs/artifact-viewing/spec.md b/openspec/changes/add-complete-artifact-inventory/specs/artifact-viewing/spec.md new file mode 100644 index 0000000..c626a71 --- /dev/null +++ b/openspec/changes/add-complete-artifact-inventory/specs/artifact-viewing/spec.md @@ -0,0 +1,82 @@ +## MODIFIED Requirements + +### Requirement: Artifact List Display +The system SHALL display all artifacts for a given change. + +#### Scenario: Show available artifacts +- GIVEN a change with multiple artifacts +- WHEN the user opens change details +- THEN all existing artifacts MUST be shown as tabs or list items +- AND the set and order of artifacts MUST come from the change's current Schema instead of a hardcoded list +- AND Schema-defined artifacts that have not been created yet MUST be indicated as "Not created" + +#### Scenario: Artifact status indication +- GIVEN artifacts in various states +- WHEN displayed +- THEN each artifact MUST show: + - Name (e.g., "Proposal", "Design") + - Status (exists, missing, or empty) + - Last modified time (if exists) + - File size (optional) + +### Requirement: Artifact Actions +The system SHALL provide actions for artifact management. + +#### Scenario: Open in editor +- GIVEN a Schema-defined artifact backed by a single file +- WHEN the user clicks "Open in Editor" +- THEN the artifact file MUST open in VSCode editor +- AND the cursor SHOULD be at the top of the file +- AND the file MUST also be revealed in the VS Code Explorer, subject to the existing scoped reveal rules for artifacts opened from a store root outside the workspace + +#### Scenario: Copy file path +- GIVEN any artifact +- WHEN the user clicks "Copy Path" +- THEN the absolute file path MUST be copied to clipboard +- AND a notification SHOULD confirm the copy + +#### Scenario: Refresh artifact +- GIVEN an artifact is being viewed +- WHEN the user clicks "Refresh" +- THEN the content MUST be reloaded from disk +- AND the view MUST update to show latest content + +#### Scenario: Reveal multi-file artifact +- GIVEN a Schema-defined artifact backed by multiple files or a directory (for example a `specs/` delta directory) +- WHEN the user opens that artifact +- THEN the extension MUST reveal and expand the corresponding directory in the VS Code Explorer +- AND it MUST select the most recently updated file within that directory by default + +#### Scenario: Missing artifact does not attempt reveal +- GIVEN a Schema-defined artifact whose file has not been created yet +- WHEN the user views that artifact +- THEN the extension MUST offer a create or continue-planning action +- AND it MUST NOT attempt to reveal or open a file path that does not exist + +## ADDED Requirements + +### Requirement: Other Artifacts Display +The system SHALL display files and directories that exist in a change's directory but are not declared by the change's current Schema, without hiding, dropping, or silently reclassifying them. + +#### Scenario: Undeclared file or directory is shown +- GIVEN a change directory contains a file or subdirectory that the current Schema does not declare +- WHEN the user opens change details +- THEN that file or subdirectory MUST appear in a separate "Other Artifacts" grouping +- AND it MUST NOT be hidden, dropped, or merged into an existing Schema-defined artifact + +#### Scenario: Other artifact directory shows file count +- GIVEN an "Other Artifacts" entry that maps to a subdirectory +- WHEN it is displayed +- THEN the entry MUST show the number of files contained in that subdirectory + +#### Scenario: Opening an other artifact +- GIVEN an entry in "Other Artifacts" +- WHEN the user clicks that entry +- THEN the extension MUST reveal the real file or directory in the VS Code Explorer +- AND it MUST open the file, or the most recently updated file within the directory, in the editor +- AND it MUST NOT attempt to map the entry to a known Schema artifact type + +#### Scenario: No other artifacts present +- GIVEN a change directory whose contents are fully declared by the current Schema +- WHEN the user opens change details +- THEN the "Other Artifacts" grouping MUST NOT be shown diff --git a/openspec/changes/add-complete-artifact-inventory/tasks.md b/openspec/changes/add-complete-artifact-inventory/tasks.md new file mode 100644 index 0000000..a114847 --- /dev/null +++ b/openspec/changes/add-complete-artifact-inventory/tasks.md @@ -0,0 +1,45 @@ +## 1. Artifact Inventory 共享模块 + +- [x] 1.1 新增 `src/extension/services/artifactInventory.ts`,定义 `OtherArtifactEntry` 类型(`id`、`relativePath`、`isDirectory`、`fileCount`) +- [x] 1.2 实现 `buildOtherArtifacts(changeDir, knownOutputPaths)`:扫描 change 目录直接子项,跳过 `.openspec.yaml` 及隐藏文件/目录,与 `knownOutputPaths` 做 diff +- [x] 1.3 实现目录型条目的文件计数(对识别为"其他"的子目录做单层 `readdir`,不递归子目录内部) +- [x] 1.4 为 `buildOtherArtifacts` 编写单元测试:已知文件被排除、未知文件/目录被识别、目录文件计数正确、无 Other Artifacts 时返回空数组 + +## 2. Extension Host 数据接入 + +- [x] 2.1 在 `DataManager` 的 CLI 数据路径(`getChangeDetails` 之后)接入 `buildOtherArtifacts`,`knownOutputPaths` 取自 `ChangeDetails.artifacts[].outputPath` +- [x] 2.2 在 `getFilesystemArtifactStatuses` fallback 路径接入 `buildOtherArtifacts`,`knownOutputPaths` 取自 fallback 现有的固定已知列表(`proposal`/`design`/`tasks`/`specs`) +- [x] 2.3 扩展 `ChangeDetails` 类型新增 `otherArtifacts?: OtherArtifactEntry[]` 字段,同步更新 `src/webview/types/messages.ts` 里对应的 payload 类型 +- [x] 2.4 为两条路径分别编写测试,覆盖 CLI 成功、CLI 失败回退到 filesystem 两种场景下 `otherArtifacts` 的正确性 + +## 3. openArtifact 改造与 openOtherArtifact 新增 + +- [x] 3.1 改造 `webviewMessageHandler.ts` 的 `openArtifact` case:改用已发送给 Webview 的 Inventory 中记录的真实 `outputPath`,不再拼接 `` `${artifactType}.md` `` +- [x] 3.2 为 `openArtifact` 的单文件分支补上 `revealInExplorer` 调用,对齐 `openChange` 现有行为 +- [x] 3.3 实现目录型 `outputPath` 的"定位最近修改文件 + reveal + open"逻辑,封装成可复用的辅助函数(`openArtifact` 与 `openOtherArtifact` 共用) +- [x] 3.4 新增 `openOtherArtifact` 消息类型:`src/webview/types/messages.ts` 增加 `{ type: 'openOtherArtifact'; changeName; entryId; scopeId? }` 及对应 `sendMessage.openOtherArtifact()` 构造函数 +- [x] 3.5 在 `webviewMessageHandler.ts` 新增 `openOtherArtifact` case:重新调用 `buildOtherArtifacts` 按 `entryId` 定位条目,复用 3.3 的辅助函数打开 +- [x] 3.6 为 `openArtifact`/`openOtherArtifact` 编写测试:路径解析正确性、`isPathUnderRoot` 安全校验仍然生效、单文件与目录分支各自的 reveal/open 调用 + +## 4. Webview Tab 动态化 + +- [x] 4.1 将 `ChangeDetailTabId` 从固定联合类型放宽为 `string`;提取 `SPECS_TAB_ID`、`VERIFY_ARCHIVE_TAB_ID` 命名常量替换现有裸字符串比较(如 `activeTab === 'specs'`) +- [x] 4.2 实现 `buildTabs(artifacts, showVerifyArchiveTab)` 纯函数,替换 `ChangeDetail.tsx` 中的模块级 `ALL_TABS` 常量 +- [x] 4.3 补充 id → 展示名映射表,覆盖 `proposal`/`specs`/`design`/`tasks` 现有翻译;未命中的自定义 id 使用 title-case 兜底 +- [x] 4.4 在 `buildTabs` 中加入保留字冲突防御:Schema artifact id 命中 `VERIFY_ARCHIVE_TAB_ID` 时记录 warning 并跳过该条,不覆盖内置 Tab +- [x] 4.5 为 `buildTabs` 编写单元测试:默认四个 id 的顺序与 label、自定义 Schema id 的兜底展示、`verifyArchive` 冲突防御 + +## 5. Other Artifacts 条目区 UI + +- [x] 5.1 在 `ChangeDetail.tsx` 的 Tab 栏与内容区之间新增 Other Artifacts 展示区域,`otherArtifacts` 为空时整块不渲染 +- [x] 5.2 实现条目点击行为:`postMessage(sendMessage.openOtherArtifact(changeName, entryId, scopeId))`,不改变 `activeTab` +- [x] 5.3 目录型条目展示文件数徽标(如 "task-details · 6 files");单文件条目不展示计数 +- [x] 5.4 为该区域编写组件测试:空列表不渲染、点击触发正确消息、目录条目文件数展示正确 + +## 6. i18n 与收尾验证 + +- [x] 6.1 在 `src/i18n/locales/en.json` 与 `zh-cn.json` 补充 Other Artifacts 标题及 tooltip 文案 +- [x] 6.2 运行 `pnpm test`,确认新增和现有测试全部通过 +- [x] 6.3 运行 `pnpm run build`,确认 extension 与 webview 均无编译错误 +- [x] 6.4 运行 `openspec validate add-complete-artifact-inventory --strict` +- [x] 6.5 手工验证:打开本仓库自身的 `add-change-lifecycle-filtering-and-pagination` change 的 Change Detail,确认其 `task-details/` 子目录作为 Other Artifacts 正确出现,点击后能在 Explorer 中正确 reveal diff --git a/src/extension/providers/webviewMessageHandler.ts b/src/extension/providers/webviewMessageHandler.ts index b7549e6..ad88fb1 100644 --- a/src/extension/providers/webviewMessageHandler.ts +++ b/src/extension/providers/webviewMessageHandler.ts @@ -6,6 +6,8 @@ import { getChangesBasePath } from '../utils/workspaceRoot'; import { isPathUnderRoot } from '../utils/pathSafety'; import { getAdapterById, getCurrentAdapter } from '../adapters'; import type { CacheStatsView, WebviewMessage } from '../../webview/types/messages'; +import { buildOtherArtifacts } from '../services/artifactInventory'; +import { openAndRevealPath } from '../utils/openAndReveal'; import { t } from '../../i18n'; import { buildWorkflowLaunchPayload } from '../../shared/workflowCommand'; import { getWorkflowLaunchConfig } from '../services/workflowLaunchConfig'; @@ -430,6 +432,11 @@ export async function handleWebviewMessage( try { const doc = await vscode.workspace.openTextDocument(absPath); await vscode.window.showTextDocument(doc); + try { + await vscode.commands.executeCommand('revealInExplorer', doc.uri); + } catch { + // Best-effort for external store roots. + } } catch (err) { logger.error(`Failed to open delta spec: ${changeName}/specs/${specId}`, err as Error); vscode.window.showErrorMessage(t('file.cannotOpenSpec', { id: specId })); @@ -438,23 +445,100 @@ export async function handleWebviewMessage( } case 'openArtifact': { - const { rootPath } = resolveScopeRoot(dataManager, message.scopeId); + const { rootPath, scope } = resolveScopeRoot(dataManager, message.scopeId); const changesBase = path.normalize(getChangesBasePath(rootPath, message.changeName)); - const artifactPath = path.normalize(path.join(changesBase, `${message.artifactType}.md`)); - logger.info(`[archived] openArtifact: changeName=${message.changeName}, artifactType=${message.artifactType}, root=${rootPath}, artifactPath=${artifactPath}`); - // Gate against the resolved scope root (which may be a store root outside the - // workspace) rather than the workspace root, so store artifacts can be opened. - if (!isPathUnderRoot(changesBase, rootPath) || !isPathUnderRoot(artifactPath, rootPath)) { + if (!isPathUnderRoot(changesBase, rootPath)) { + vscode.window.showErrorMessage(t('file.outsideWorkspaceShort')); + break; + } + + let relative = typeof message.outputPath === 'string' ? message.outputPath : ''; + if (!relative) { + // Resolve from ChangeDetails inventory when the webview did not send outputPath. + try { + const details = await dataManager.getChangeDetails(message.changeName, scope); + const match = details.artifacts?.find( + (a) => (a.id ?? '').toLowerCase() === String(message.artifactType ?? '').toLowerCase() + ); + relative = match?.outputPath ?? `${message.artifactType}.md`; + } catch { + relative = `${message.artifactType}.md`; + } + } + + // Gate against path escape using the top-level resolved entry under the change dir. + const topLevel = relative.replace(/\\/g, '/').split('*')[0].replace(/\/+$/, '').split('/').filter(Boolean)[0] ?? relative; + const gatePath = path.normalize(path.join(changesBase, topLevel)); + if (!isPathUnderRoot(gatePath, rootPath)) { + vscode.window.showErrorMessage(t('file.outsideWorkspaceShort')); + break; + } + + logger.info( + `[archived] openArtifact: changeName=${message.changeName}, artifactType=${message.artifactType}, root=${rootPath}, relative=${relative}` + ); + const result = await openAndRevealPath(changesBase, relative); + if (!result.opened && result.reason === 'missing') { + vscode.window.showErrorMessage(t('file.cannotOpen', { name: message.artifactType })); + } + break; + } + + case 'openOtherArtifact': { + const { changeName, entryId } = message; + if (!changeName || !entryId) break; + const { rootPath, scope } = resolveScopeRoot(dataManager, message.scopeId); + const changesBase = path.normalize(getChangesBasePath(rootPath, changeName)); + if (!isPathUnderRoot(changesBase, rootPath)) { vscode.window.showErrorMessage(t('file.outsideWorkspaceShort')); break; } + try { - const doc = await vscode.workspace.openTextDocument(artifactPath); - await vscode.window.showTextDocument(doc); - logger.info(`[archived] openArtifact: opened OK`); + const details = await dataManager.getChangeDetails(changeName, scope); + const knownPaths = (details.artifacts ?? []).map((a) => a.outputPath).filter(Boolean); + const others = await buildOtherArtifacts(changesBase, knownPaths, changeName); + const entry = others.find((e) => e.id === entryId); + if (!entry) { + vscode.window.showErrorMessage(t('file.cannotOpen', { name: entryId })); + break; + } + const abs = path.normalize(path.join(changesBase, entry.relativePath)); + if (!isPathUnderRoot(abs, rootPath)) { + vscode.window.showErrorMessage(t('file.outsideWorkspaceShort')); + break; + } + const result = await openAndRevealPath(changesBase, entry.relativePath); + if (!result.opened && result.reason === 'missing') { + vscode.window.showErrorMessage(t('file.cannotOpen', { name: entry.relativePath })); + } } catch (err) { - logger.error(`Failed to open artifact: ${artifactPath}`, err as Error); - vscode.window.showErrorMessage(t('file.cannotOpen', { name: message.artifactType })); + logger.error(`Failed to open other artifact: ${changeName}/${entryId}`, err as Error); + vscode.window.showErrorMessage(t('file.cannotOpen', { name: entryId })); + } + break; + } + + case 'getChangeDetails': { + const changeName = message.changeName; + if (!changeName) break; + const { scope } = resolveScopeRoot(dataManager, message.scopeId); + try { + const details = await dataManager.getChangeDetails(changeName, scope); + webview.postMessage({ + type: 'changeDetails', + changeName, + schema: details.schema, + artifacts: details.artifacts ?? [], + otherArtifacts: details.otherArtifacts ?? [], + }); + } catch (err) { + logger.error(`Failed to get change details: ${changeName}`, err as Error); + webview.postMessage({ + type: 'changeDetailsError', + changeName, + message: (err as Error)?.message ?? 'Failed to load change details', + }); } break; } diff --git a/src/extension/services/artifactInventory.ts b/src/extension/services/artifactInventory.ts new file mode 100644 index 0000000..d817aec --- /dev/null +++ b/src/extension/services/artifactInventory.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface OtherArtifactEntry { + /** Stable slug used as message key (relativePath with path separators replaced). */ + id: string; + /** Path relative to the change directory (e.g. `task-details` or `notes.md`). */ + relativePath: string; + isDirectory: boolean; + /** File count for directories (single-level); always 1 for files. */ + fileCount: number; +} + +const SKIP_NAMES = new Set(['.openspec.yaml']); + +/** + * Extract the top-level entry name that a known artifact output path "owns" + * inside a change directory. + * + * Accepts: + * - Schema-relative paths: `proposal.md`, `specs/**\/*.md` + * - Filesystem-fallback paths: `openspec/changes//proposal.md` + * - Absolute paths under a change directory (matched by basename of first segment after change dir) + */ +export function toKnownTopLevelName(outputPath: string, changeName?: string): string | null { + if (!outputPath || typeof outputPath !== 'string') return null; + let rel = outputPath.replace(/\\/g, '/').trim(); + if (!rel) return null; + + if (changeName) { + const marker = `openspec/changes/${changeName}/`; + const archiveMarker = changeName.startsWith('archive:') + ? `openspec/changes/archive/${changeName.slice(8)}/` + : null; + const idx = rel.indexOf(marker); + if (idx >= 0) { + rel = rel.slice(idx + marker.length); + } else if (archiveMarker) { + const aidx = rel.indexOf(archiveMarker); + if (aidx >= 0) rel = rel.slice(aidx + archiveMarker.length); + } + } + + // Strip glob suffixes: "specs/**/*.md" -> "specs/" + const beforeGlob = rel.split('*')[0].replace(/\/+$/, ''); + if (!beforeGlob) return null; + + // Absolute path: take the last segment that isn't a glob leftover + if (path.isAbsolute(outputPath) && !changeName) { + const base = path.basename(beforeGlob); + return base || null; + } + + const segments = beforeGlob.split('/').filter(Boolean); + return segments[0] ?? null; +} + +function slugId(relativePath: string): string { + return relativePath.replace(/\\/g, '/').replace(/\//g, '__'); +} + +async function countFilesInDirectory(dirPath: string): Promise { + try { + const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).length; + } catch { + return 0; + } +} + +/** + * Diff the change directory's direct children against known Schema / fallback + * artifact paths. Returns entries that exist on disk but are not declared. + * + * Only scans one level deep. File counts for "other" directories are also single-level. + */ +export async function buildOtherArtifacts( + changeDir: string, + knownOutputPaths: string[], + changeName?: string +): Promise { + const known = new Set(); + for (const p of knownOutputPaths) { + const name = toKnownTopLevelName(p, changeName); + if (name) known.add(name); + } + + let dirents: fs.Dirent[]; + try { + dirents = await fs.promises.readdir(changeDir, { withFileTypes: true }); + } catch { + return []; + } + + const results: OtherArtifactEntry[] = []; + for (const dirent of dirents) { + const name = dirent.name; + if (!name || name.startsWith('.')) continue; + if (SKIP_NAMES.has(name)) continue; + if (known.has(name)) continue; + + const absolute = path.join(changeDir, name); + if (dirent.isDirectory()) { + const fileCount = await countFilesInDirectory(absolute); + results.push({ + id: slugId(name), + relativePath: name, + isDirectory: true, + fileCount, + }); + } else if (dirent.isFile()) { + results.push({ + id: slugId(name), + relativePath: name, + isDirectory: false, + fileCount: 1, + }); + } + } + + results.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return results; +} + +/** + * Resolve the absolute path to open for an inventory entry or artifact outputPath. + * Glob paths like `specs/**\/*.md` resolve to the directory `specs`. + */ +export function resolveOpenablePath(changeDir: string, relativeOrGlob: string): string { + const beforeGlob = relativeOrGlob.replace(/\\/g, '/').split('*')[0].replace(/\/+$/, ''); + const top = beforeGlob.split('/').filter(Boolean)[0] ?? beforeGlob; + return path.join(changeDir, top); +} diff --git a/src/extension/services/dataManager.ts b/src/extension/services/dataManager.ts index 19871d2..2c19ec4 100644 --- a/src/extension/services/dataManager.ts +++ b/src/extension/services/dataManager.ts @@ -16,6 +16,8 @@ import type { CliActivationDiagnostic } from './cliActivationDiagnostic'; import { OpenSpecScopeManager, loadScopeRelationships, type OpenSpecScope } from './openspecScope'; import { detectOpenSpecFeatures, type OpenSpecCapabilities } from './openspecFeatures'; import type { CacheStats, CacheStatsOptions, OpenSpecCacheService } from './openSpecCacheService'; +import { buildOtherArtifacts } from './artifactInventory'; +import { getChangesBasePath } from '../utils/workspaceRoot'; export interface ScopeInfo { id: string; @@ -994,10 +996,39 @@ export class DataManager { } /** - * Get change details (from State Reader / CLI show) + * Get change details (from State Reader / CLI show), enriched with Other Artifacts + * scanned from the change directory against Schema-declared output paths. */ - async getChangeDetails(changeName: string): Promise { - return await this.stateReader.getChangeDetails(changeName); + async getChangeDetails(changeName: string, scope?: OpenSpecScope): Promise { + const services = this.getScopedServices(scope); + const details = await services.stateReader.getChangeDetails(changeName, scope); + + let artifacts = details.artifacts ?? []; + // Filesystem fallback when CLI returned no Schema artifacts (older show / empty status). + if (artifacts.length === 0) { + artifacts = (await this.getFilesystemArtifactStatuses(changeName, services.contentAccess)) ?? []; + } + + const changeDir = getChangesBasePath(services.rootPath, changeName); + const knownPaths = artifacts.map((a) => a.outputPath).filter(Boolean); + // When Schema list is empty, still treat the fixed fallback set as known so we + // don't classify proposal/design/tasks/specs as "other". + const knownForScan = + knownPaths.length > 0 + ? knownPaths + : [ + `openspec/changes/${changeName}/proposal.md`, + `openspec/changes/${changeName}/design.md`, + `openspec/changes/${changeName}/tasks.md`, + `openspec/changes/${changeName}/specs`, + ]; + const otherArtifacts = await buildOtherArtifacts(changeDir, knownForScan, changeName); + + return { + ...details, + artifacts, + otherArtifacts, + }; } /** diff --git a/src/extension/services/openspecCli.ts b/src/extension/services/openspecCli.ts index 6a38707..9092f95 100644 --- a/src/extension/services/openspecCli.ts +++ b/src/extension/services/openspecCli.ts @@ -287,6 +287,10 @@ export class OpenSpecCliService { /** * Show details for a specific change. * If CLI returns non-JSON or command fails (e.g. exit 1), returns minimal ChangeDetails so callers can fallback to Content Access. + * + * OpenSpec 1.8+ `show --json` returns delta content rather than a ChangeDetails-shaped + * payload. When `artifacts` is missing, fall back to `status --change --json` which still + * exposes the Schema artifact list and output paths. */ async showChange(name: string, scope?: ScopeOption | OpenSpecScope): Promise { try { @@ -298,10 +302,25 @@ export class OpenSpecCliService { if (!data) { return this.minimalChangeDetails(name); } + + let artifacts = this.normalizeArtifactInfos(data.artifacts ?? []); + let schema = data.schema || 'unknown'; + if (artifacts.length === 0) { + try { + const status = await this.getChangeStatus(name, scope); + artifacts = this.normalizeArtifactInfos(status.artifacts ?? []); + if (typeof status.schemaName === 'string' && status.schemaName) { + schema = status.schemaName; + } + } catch { + // Keep empty artifacts; callers may still fall back to Content Access. + } + } + return { name: data.name || name, - schema: data.schema || 'unknown', - artifacts: this.normalizeArtifactInfos(data.artifacts ?? []), + schema, + artifacts, tasks: this.normalizeTaskInfos(data.tasks ?? []), metadata: data.metadata && typeof data.metadata === 'object' ? data.metadata : {}, }; @@ -310,6 +329,8 @@ export class OpenSpecCliService { logger.warn( `openspec show ${name} failed (exit ${error.exitCode}): ${error.stderr || error.message}. Returning minimal details.` ); + // Hard show failures (e.g. change not found) should not also hammer `status` + // with retries — callers fall back to Content Access / filesystem inventory. return this.minimalChangeDetails(name); } logger.error(`Failed to show change: ${name}`, error as Error); diff --git a/src/extension/services/types.ts b/src/extension/services/types.ts index f48298b..3eb0d05 100644 --- a/src/extension/services/types.ts +++ b/src/extension/services/types.ts @@ -1,3 +1,5 @@ +import type { OtherArtifactEntry } from './artifactInventory'; + export interface ChangeInfo { name: string; completedTasks: number; @@ -34,6 +36,8 @@ export interface ChangeDetails { name: string; schema: string; artifacts: ArtifactInfo[]; + /** Files/dirs present in the change directory but not declared by the current Schema. */ + otherArtifacts?: OtherArtifactEntry[]; tasks?: TaskInfo[]; metadata?: ChangeMetadata; } diff --git a/src/extension/utils/openAndReveal.ts b/src/extension/utils/openAndReveal.ts new file mode 100644 index 0000000..2491bfc --- /dev/null +++ b/src/extension/utils/openAndReveal.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { logger } from '../utils/logger'; +import { resolveOpenablePath } from '../services/artifactInventory'; + +/** + * Open a file or directory under a change folder in the editor and reveal it + * in the Explorer. For directories (or glob output paths), selects the most + * recently modified file inside the directory. + */ +export async function openAndRevealPath( + changeDir: string, + relativeOrGlob: string +): Promise<{ opened: boolean; reason?: string }> { + const target = resolveOpenablePath(changeDir, relativeOrGlob); + let stat: fs.Stats; + try { + stat = await fs.promises.stat(target); + } catch { + return { opened: false, reason: 'missing' }; + } + + let fileToOpen = target; + if (stat.isDirectory()) { + const recent = await findMostRecentlyModifiedFile(target); + if (!recent) { + // Reveal the empty directory if possible; nothing to open in the editor. + try { + await vscode.commands.executeCommand('revealInExplorer', vscode.Uri.file(target)); + } catch (err) { + logger.warn(`revealInExplorer failed for directory ${target}: ${(err as Error)?.message}`); + } + return { opened: false, reason: 'empty-directory' }; + } + fileToOpen = recent; + } + + try { + const doc = await vscode.workspace.openTextDocument(fileToOpen); + await vscode.window.showTextDocument(doc); + try { + await vscode.commands.executeCommand('revealInExplorer', doc.uri); + } catch (err) { + // Best-effort for store roots outside the workspace (see artifact-viewing spec). + logger.warn(`revealInExplorer failed for ${fileToOpen}: ${(err as Error)?.message}`); + } + return { opened: true }; + } catch (err) { + logger.error(`Failed to open path: ${fileToOpen}`, err as Error); + return { opened: false, reason: 'open-failed' }; + } +} + +async function findMostRecentlyModifiedFile(dirPath: string): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); + } catch { + return null; + } + + let best: { path: string; mtime: number } | null = null; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name.startsWith('.')) continue; + const full = path.join(dirPath, entry.name); + try { + const st = await fs.promises.stat(full); + if (!best || st.mtimeMs > best.mtime) { + best = { path: full, mtime: st.mtimeMs }; + } + } catch { + // skip + } + } + return best?.path ?? null; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9eef184..c4fc87c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -5,6 +5,9 @@ "artifact.needProposal": "Proposal must be created first", "artifact.needBefore": "{items} must be created first", "artifact.and": " and ", + "artifact.otherArtifacts": "Other Artifacts", + "artifact.otherArtifactOpenTooltip": "Reveal in Explorer and open", + "artifact.otherArtifactDirLabel": "{name} · {count} files", "task.execute": "Execute", "task.executing": "Executing...", diff --git a/src/i18n/locales/zh-cn.json b/src/i18n/locales/zh-cn.json index fb8ca75..3a66d85 100644 --- a/src/i18n/locales/zh-cn.json +++ b/src/i18n/locales/zh-cn.json @@ -5,6 +5,9 @@ "artifact.needProposal": "需要先创建 Proposal", "artifact.needBefore": "需要先创建 {items}", "artifact.and": " 和 ", + "artifact.otherArtifacts": "其他工件", + "artifact.otherArtifactOpenTooltip": "在资源管理器中定位并打开", + "artifact.otherArtifactDirLabel": "{name} · {count} 个文件", "task.execute": "执行", "task.executing": "执行中...", diff --git a/src/shared/interactiveWorkflow.ts b/src/shared/interactiveWorkflow.ts index 06c9d69..eed8fed 100644 --- a/src/shared/interactiveWorkflow.ts +++ b/src/shared/interactiveWorkflow.ts @@ -1,6 +1,13 @@ export type InteractiveWorkflowAction = 'verify' | 'archive'; export type InteractiveWorkflowSessionStatus = 'running' | 'error'; -export type ChangeDetailTabId = 'proposal' | 'specs' | 'design' | 'tasks' | 'verifyArchive'; +/** Dynamic Schema artifact id, plus the reserved Verify & Archive tab. */ +export type ChangeDetailTabId = string; + +export const SPECS_TAB_ID = 'specs'; +export const VERIFY_ARCHIVE_TAB_ID = 'verifyArchive'; +export const PROPOSAL_TAB_ID = 'proposal'; +export const DESIGN_TAB_ID = 'design'; +export const TASKS_TAB_ID = 'tasks'; export interface InteractiveWorkflowSessionState { action: InteractiveWorkflowAction; diff --git a/src/webview/components/ChangeDetail.tsx b/src/webview/components/ChangeDetail.tsx index 3570063..52f7123 100644 --- a/src/webview/components/ChangeDetail.tsx +++ b/src/webview/components/ChangeDetail.tsx @@ -17,6 +17,14 @@ import type { InteractiveWorkflowAction, InteractiveWorkflowState, } from '../../shared/interactiveWorkflow'; +import { + SPECS_TAB_ID, + VERIFY_ARCHIVE_TAB_ID, + PROPOSAL_TAB_ID, + TASKS_TAB_ID, +} from '../../shared/interactiveWorkflow'; +import { buildTabs } from '../utils/buildTabs'; +import type { ArtifactStatus, OtherArtifactEntry } from '../types/messages'; const MISSING_ARTIFACT_MESSAGE = t('artifact.missing'); @@ -30,17 +38,9 @@ export interface ChangeDetailProps { scopeId?: string; } -const ALL_TABS = [ - { id: 'proposal' as const, label: 'Proposal' }, - { id: 'specs' as const, label: 'Specs' }, - { id: 'design' as const, label: 'Design' }, - { id: 'tasks' as const, label: 'Tasks' }, - { id: 'verifyArchive' as const, label: 'Verify & Archive' }, -]; - // Cache key includes scopeId so the same change name in two roots never shares content. const cacheKey = (scopeId: string | undefined, type: string, specId?: string | null) => - `${scopeId ? `${scopeId}::` : ''}${type === 'specs' && specId ? `specs:${specId}` : type}`; + `${scopeId ? `${scopeId}::` : ''}${type === SPECS_TAB_ID && specId ? `specs:${specId}` : type}`; function getCreateDisabledReason( artifactType: string, @@ -118,6 +118,8 @@ export const ChangeDetail: React.FC = ({ }); const [pendingInteractiveAction, setPendingInteractiveAction] = useState(interactiveAction ?? null); const [copiedName, setCopiedName] = useState(false); + const [schemaArtifacts, setSchemaArtifacts] = useState(undefined); + const [otherArtifacts, setOtherArtifacts] = useState([]); const handleCopyChangeName = () => { postMessage(sendMessage.copyToClipboard(changeName)); @@ -139,19 +141,29 @@ export const ChangeDetail: React.FC = ({ [changeName, existingArtifactIds, completedTasks, totalTasks, isArchived] ); const showVerifyArchiveTab = debug || (completedTasks > 0 && totalTasks > 0); - const tabs = showVerifyArchiveTab ? ALL_TABS : ALL_TABS.filter((tab) => tab.id !== 'verifyArchive'); + const tabs = useMemo( + () => + buildTabs(schemaArtifacts, showVerifyArchiveTab, (id) => { + console.warn(`[OpenSpec] Schema artifact id "${id}" conflicts with reserved tab; skipped`); + }), + [schemaArtifacts, showVerifyArchiveTab] + ); + + useEffect(() => { + postMessage(sendMessage.getChangeDetails(changeName, scopeId)); + }, [changeName, scopeId, postMessage]); useEffect(() => { if (initialTab) { setActiveTab(initialTab); - } else if (!showVerifyArchiveTab && activeTab === 'verifyArchive') { - setActiveTab('proposal'); + } else if (!showVerifyArchiveTab && activeTab === VERIFY_ARCHIVE_TAB_ID) { + setActiveTab(PROPOSAL_TAB_ID); } }, [initialTab, showVerifyArchiveTab, activeTab]); useEffect(() => { if (interactiveAction) { - setActiveTab('verifyArchive'); + setActiveTab(VERIFY_ARCHIVE_TAB_ID); setPendingInteractiveAction(interactiveAction); } }, [interactiveAction]); @@ -174,7 +186,7 @@ export const ChangeDetail: React.FC = ({ }; useEffect(() => { - if (activeTab === 'verifyArchive') { + if (activeTab === VERIFY_ARCHIVE_TAB_ID) { setLoading(false); setError(null); setContent(null); @@ -192,14 +204,14 @@ export const ChangeDetail: React.FC = ({ setError(MISSING_ARTIFACT_MESSAGE); setErrorCode('ARTIFACT_MISSING'); setContent(null); - if (activeTab === 'specs') { + if (activeTab === SPECS_TAB_ID) { setDeltaSpecIds([]); setSelectedSpecId(null); } return; } - if (activeTab === 'specs') { + if (activeTab === SPECS_TAB_ID) { requestSpecsList(); return; } @@ -278,11 +290,16 @@ export const ChangeDetail: React.FC = ({ setWorkflowLaunchConfig(msg.config ?? null); } else if (msg.type === 'interactiveWorkflowState' && msg.changeName === changeName) { setInteractiveState(msg.state ?? { changeName, sessions: {} }); + } else if (msg.type === 'changeDetails' && msg.changeName === changeName) { + setSchemaArtifacts(Array.isArray(msg.artifacts) ? msg.artifacts : []); + setOtherArtifacts(Array.isArray(msg.otherArtifacts) ? msg.otherArtifacts : []); + } else if (msg.type === 'changeDetailsError' && msg.changeName === changeName) { + console.warn('[OpenSpec] Failed to load change details:', msg.message); } else if (msg.type === 'artifactInvalidated' && msg.changeName === changeName) { const invalidated: string[] = msg.artifactTypes ?? []; const scopePrefix = scopeId ? `${scopeId}::` : ''; for (const type of invalidated) { - if (type === 'specs') { + if (type === SPECS_TAB_ID) { for (const key of Array.from(contentCacheRef.current.keys())) { // Cache keys are optionally scope-prefixed; drop the specs entries that // belong to this panel's scope (and any legacy unscoped specs:* keys). @@ -304,9 +321,9 @@ export const ChangeDetail: React.FC = ({ } } if (invalidated.includes(activeTab)) { - if (activeTab === 'specs') { + if (activeTab === SPECS_TAB_ID) { requestSpecsList(); - } else if (activeTab !== 'verifyArchive') { + } else if (activeTab !== VERIFY_ARCHIVE_TAB_ID) { requestArtifact(activeTab); } } @@ -320,7 +337,7 @@ export const ChangeDetail: React.FC = ({ }, [postMessage]); useEffect(() => { - if (activeTab === 'specs' && selectedSpecId) { + if (activeTab === SPECS_TAB_ID && selectedSpecId) { const key = cacheKey(scopeId, 'specs', selectedSpecId); const cached = contentCacheRef.current.get(key); if (cached !== undefined) { @@ -337,14 +354,14 @@ export const ChangeDetail: React.FC = ({ }, [activeTab, selectedSpecId, changeName, postMessage, scopeId]); useEffect(() => { - if (activeTab === 'tasks') { + if (activeTab === TASKS_TAB_ID) { postMessage(sendMessage.getAgentAdapters()); postMessage(sendMessage.getTaskExecutionState(changeName, scopeId)); } }, [activeTab, changeName, postMessage, scopeId]); useEffect(() => { - if (activeTab !== 'verifyArchive') return; + if (activeTab !== VERIFY_ARCHIVE_TAB_ID) return; postMessage(sendMessage.getInteractiveWorkflowState(changeName, scopeId)); if (pendingInteractiveAction) { postMessage(sendMessage.runInteractiveWorkflow(changeName, pendingInteractiveAction, scopeId)); @@ -354,22 +371,23 @@ export const ChangeDetail: React.FC = ({ const handleOpenInEditor = () => { - if (activeTab === 'verifyArchive') return; - if (activeTab === 'specs' && selectedSpecId) { + if (activeTab === VERIFY_ARCHIVE_TAB_ID) return; + if (activeTab === SPECS_TAB_ID && selectedSpecId) { postMessage(sendMessage.openDeltaSpec(changeName, selectedSpecId, scopeId)); return; } - postMessage(sendMessage.openArtifact(changeName, activeTab, scopeId)); + const artifactMeta = schemaArtifacts?.find((a) => a.id === activeTab); + postMessage(sendMessage.openArtifact(changeName, activeTab, scopeId, artifactMeta?.outputPath)); }; const handleRefresh = () => { contentCacheRef.current.clear(); postMessage(sendMessage.refresh()); - if (activeTab === 'verifyArchive') { + if (activeTab === VERIFY_ARCHIVE_TAB_ID) { postMessage(sendMessage.getInteractiveWorkflowState(changeName, scopeId)); return; } - if (activeTab === 'specs') { + if (activeTab === SPECS_TAB_ID) { requestSpecsList(); } else { requestArtifact(activeTab); @@ -397,7 +415,7 @@ export const ChangeDetail: React.FC = ({ } if (step === 'verify' || step === 'archive') { if (showVerifyArchiveTab || step === 'archive') { - setActiveTab('verifyArchive'); + setActiveTab(VERIFY_ARCHIVE_TAB_ID); } return; } @@ -482,7 +500,40 @@ export const ChangeDetail: React.FC = ({ ))} - {activeTab === 'specs' && deltaSpecIds.length > 1 && ( + {otherArtifacts.length > 0 && ( +
+ + {t('artifact.otherArtifacts')} ({otherArtifacts.length}) + + {otherArtifacts.map((entry) => ( + + ))} +
+ )} + + {activeTab === SPECS_TAB_ID && deltaSpecIds.length > 1 && (
{t('spec.label')}