feat(session): archive action exports session to Markdown (#165)#166
Conversation
Previously the right-click 'Archive' menu was a soft-hide toggle — it flipped session.archived in globalState, the entry disappeared from the sidebar, and that was it. Users reported that the label did not match what happened: nothing was actually archived anywhere they could grep, commit, or share. This change makes Archive do what it says: 1. src/chat/archive-export.js (new): renders a SessionStore record to Markdown — YAML frontmatter with sessionId/model/timestamps, role- tagged sections (User / Assistant), and <details>-wrapped reasoning so it does not drown out the conversation. Picks the destination via workspaceFolders (single root -> use it; multi-root -> workspace folder pick, biased toward the session's original ws; no folder open -> showSaveDialog). Sanitises titles for filesystem hostility, appends -1/-2 on name collision, and verifies the resolved path stays under the chosen root before writing (defence in depth). 2. SessionStore.archive: on first archive of a visible session, export to .deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md, then perform the original soft-hide. Clicking again on an archived session still un-hides it (toggle preserved; the file on disk is not touched). Export failures surface through showErrorMessage but do not block the hide, so the gesture always advances UI state. 3. Bottom-right toast on success with Open File / Reveal in Explorer actions. Path is shown workspace-relative when possible. 4. i18n: 9 new keys for the toast, dialog labels, and rendered role headers. EN and ZH both populated. Security (per copilot-instructions.md red-line #3): writes are gated by path.relative containment check + path.resolve normalisation before any fs.writeFile call; no user input is interpolated into a shell command. Closes #165.
| return target; | ||
| } | ||
|
|
||
| module.exports = { exportSessionToMarkdown, renderSessionMarkdown }; |
There was a problem hiding this comment.
-
安全性: 在
_uniquePath函数中,虽然使用了fs.access来检查文件是否存在,但没有考虑到并发写入的情况,可能会导致竞态条件。建议在生成文件名时加锁或使用其他机制确保唯一性。 -
异常处理: 在
exportSessionToMarkdown函数中,虽然有抛出错误的处理,但在调用fs.mkdir和fs.writeFile时,如果发生异常,应该有更详细的错误处理机制,以便于调试和用户友好提示。 -
代码风格: 代码整体风格较为一致,但在某些地方(如
_safeTitle函数)可以考虑增加注释以提高可读性,尤其是正则表达式的部分。 -
性能: 在
_uniquePath函数中,循环最多会执行 1000 次,这可能会影响性能,尤其是在文件系统较慢的情况下。建议考虑更高效的文件名生成策略。 -
可维护性: 函数
_renderThoughts中对thoughts的处理较为简单,建议增加对输入的验证,以防止潜在的 XSS 攻击。
| const { t, tf } = require('../utils/i18n'); | ||
|
|
||
| // ─── Orphan tool_calls sanitizer ─────────────────────────────────────────── | ||
| // Removes ANY incomplete assistant{tool_calls} group from a message array, |
There was a problem hiding this comment.
引入了新的 tf 函数,但未提供其来源或用途的说明。请确保 tf 函数的安全性和功能性,避免引入潜在的安全漏洞。
| }); | ||
| } | ||
|
|
||
| // ─── Auto-naming ──────────────────────────────────────────────────────── |
There was a problem hiding this comment.
在 archive 方法中,存在未处理的异常情况。如果 exportSessionToMarkdown 失败,虽然有错误提示,但 s.archived 仍然会被设置为 true,这可能导致状态不一致。建议在捕获异常后,添加逻辑以确保 s.archived 不会被错误地设置。
| }); | ||
| } | ||
|
|
||
| // ─── Auto-naming ──────────────────────────────────────────────────────── |
There was a problem hiding this comment.
在 _notifyArchived 方法中,路径处理逻辑可能存在路径穿越风险。建议在生成相对路径时,确保 absPath 是在允许的目录下,避免用户通过修改路径访问不应访问的文件。
| }); | ||
| } | ||
|
|
||
| // ─── Auto-naming ──────────────────────────────────────────────────────── |
There was a problem hiding this comment.
在 archive 方法中,this.set(list) 和 this.postList() 的调用可能会导致性能问题,特别是在频繁调用时。建议在状态变化后,合并这些调用以减少不必要的重复操作。
There was a problem hiding this comment.
Pull request overview
This PR redefines the session right-click 📦 Archive action from a soft-hide toggle into a real archive workflow by exporting the session to a Markdown file and notifying the user with quick actions to open or reveal the exported file.
Changes:
- Added a new session → Markdown exporter (frontmatter + role sections + reasoning in
<details>). - Updated
SessionStore.archive(id)to export-then-hide and added an archive toast with Open/Reveal actions. - Added new i18n keys (EN/ZH) for archive UI strings.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/chat/archive-export.js | New module to render and persist a session as a Markdown archive under the workspace (or via save dialog). |
| src/chat/session-store.js | Archive now triggers export + toast, while un-archive remains a pure visibility toggle. |
| src/utils/i18n.js | Adds localized strings for archive export dialogs/toasts and role headers. 结论:需修改 |
| } | ||
| } | ||
|
|
||
| return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; |
| // No workspace open — ask the user where to put it. | ||
| const uri = await vscode.window.showSaveDialog({ | ||
| saveLabel: t('archiveSaveLabel'), | ||
| filters: { Markdown: ['md'] }, | ||
| defaultUri: vscode.Uri.file(fileName), | ||
| }); | ||
| if (!uri) return null; | ||
| target = uri.fsPath; | ||
| } |
Four corrections from Copilot's PR review on #166: 1. archive-export.js renderSessionMarkdown: drop the global `replace(/\\n{3,}/g, '\\n\\n')` pass. It would collapse blank lines inside fenced code blocks / tool output, mutating verbatim message content. Each section already pushes its own controlled trailing blank line, so the global normalisation was both redundant and harmful. Issue raised by Copilot inline review. 2. archive-export.js exportSessionToMarkdown: showSaveDialog's `defaultUri` was `vscode.Uri.file(fileName)` — `Uri.file()` requires an absolute path. On Windows that resolves to a drive root, on POSIX to `/`, which is a confusing and possibly unwritable default. Anchor it at `os.homedir() + fileName` so the dialog opens somewhere the user expects. 3. session-store.js archive(): only set `archived = true` when a file was actually written. Previously, both `exportSessionToMarkdown` throwing AND returning null (user cancelled the save dialog) would still hide the session from the sidebar even though nothing was archived — leaving the UI inconsistent with disk state. New contract: - returns absolute path → hide + show toast - returns null (cancel) → keep visible, no toast - throws (fs error) → keep visible, show error message 4. archive-export.js _uniquePath: replace the fs.access pre-check with `fs.open(candidate, 'wx')` (exclusive create). Closes the TOCTOU window where two concurrent archive clicks in the same second could both pass the existence check and one would overwrite the other. The placeholder zero-byte file is then overwritten by the subsequent fs.writeFile. Also added a comment to `_safeTitle` explaining the regex classes (reserved-on-Windows set + C0 control codes + leading-dot strip), addressing a readability note from the AI reviewer. Build verified locally; no behavioural change outside the four fixes.
| } | ||
| } | ||
| // Extremely unlikely; bail out with a timestamped name. | ||
| return path.join(dir, `${stem}-${Date.now()}${ext}`); |
There was a problem hiding this comment.
在这个 hunk 中,您引入了一个新的路径生成逻辑,使用了 fs.open 方法来确保路径的唯一性。这是一个很好的改进,但需要注意的是,您在处理异常时仅检查了 EEXIST 错误。建议您在捕获异常时,记录其他可能的错误,以便于后续的调试和问题排查。此外,您在循环中使用了 i < 1000 的限制,这可能会导致在极端情况下无法生成唯一路径。建议考虑使用更灵活的机制来处理路径冲突。
| if (!savedPath) return; // user cancelled; keep state consistent | ||
|
|
||
| s.archived = true; | ||
| if (this.sessionId === id) { |
There was a problem hiding this comment.
在此代码段中,异常处理逻辑已经得到改善,确保了在导出失败时不会将会话状态设置为已归档。然而,建议在捕获异常时,除了显示错误信息外,还可以考虑记录错误日志,以便后续排查。同时,确保 exportSessionToMarkdown 函数的实现是安全的,避免潜在的命令注入或路径穿越问题。
| /** | ||
| * Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive | ||
| * create). This closes the TOCTOU window that an `fs.access` pre-check | ||
| * would leave open: two concurrent archive clicks in the same second | ||
| * could otherwise pick the same name and one would overwrite the other. | ||
| * The caller is responsible for writing content into the returned path; | ||
| * the empty placeholder file we create is overwritten by `fs.writeFile`. | ||
| */ | ||
| async function _uniquePath(dir, baseName) { | ||
| const ext = '.md'; | ||
| const stem = baseName.replace(/\.md$/i, ''); | ||
| for (let i = 0; i < 1000; i++) { | ||
| const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`); | ||
| try { | ||
| const handle = await fs.open(candidate, 'wx'); | ||
| await handle.close(); | ||
| return candidate; | ||
| } catch (err) { |
| /** | ||
| * Strip filesystem-hostile characters and trim length. | ||
| * Removed character classes (per #166 review): | ||
| * - `\ / : * ? " < > |` are reserved on Windows. | ||
| * - `\u0000-\u001f` covers C0 control codes (NUL, newlines, tabs, ESC, …), | ||
| * which corrupt filenames and can be abused for terminal injection when | ||
| * the path is later printed to a log. | ||
| * Leading dots are also stripped so we never produce a hidden file (`.foo`) | ||
| * or a relative-path escape (`..`). |
| // Archive: render to Markdown FIRST. Only hide the session from the | ||
| // sidebar if we actually produced a file on disk. PR #166 review: | ||
| // setting `archived = true` on export failure (or on a user-cancelled | ||
| // save dialog) would leave the session inconsistent — invisible in | ||
| // the list while nothing was actually archived. The new contract is: | ||
| // - savedPath is a string → write succeeded, hide the session. | ||
| // - savedPath is null → user cancelled the save dialog; keep | ||
| // session visible, do nothing more. | ||
| // - throw caught below → fs error; surface message, keep visible. |
| _notifyArchived(absPath) { | ||
| const path = require('path'); | ||
| const folders = vscode.workspace.workspaceFolders || []; | ||
| let display = absPath; | ||
| for (const f of folders) { | ||
| const root = f.uri.fsPath; | ||
| const rel = path.relative(root, absPath); | ||
| if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { | ||
| display = rel.replace(/\\/g, '/'); | ||
| break; | ||
| } | ||
| } |
| let savedPath = null; | ||
| try { | ||
| const { exportSessionToMarkdown } = require('./archive-export'); | ||
| savedPath = await exportSessionToMarkdown(s); | ||
| } catch (err) { | ||
| const msg = (err && err.message) || String(err); | ||
| vscode.window.showErrorMessage(tf('archiveFailed', { msg })); | ||
| return; // do not toggle archived — keep state consistent | ||
| } | ||
| if (!savedPath) return; // user cancelled; keep state consistent |
| // Defence in depth: even though fileName is sanitised, verify the | ||
| // resolved path stays inside the chosen root before writing. | ||
| const resolved = path.resolve(archiveDir, fileName); | ||
| const rel = path.relative(root, resolved); | ||
| if (rel.startsWith('..') || path.isAbsolute(rel)) { | ||
| throw new Error('Resolved archive path escapes the workspace root.'); | ||
| } | ||
| await fs.mkdir(archiveDir, { recursive: true }); |
- _writeUnique: write through the exclusive handle so a failed write no longer leaves a zero-byte placeholder behind (closes Copilot comment on _uniquePath placeholder leak). - Replace ad-hoc workspace-relative path logic in _notifyArchived with the multi-root + longest-prefix-aware findContainingFolder() helper from src/utils/paths.js. - Move the 'archive path escapes workspace' error string into i18n (archiveErrEscape) so ZH users no longer see an English assertion in the failure toast. - Drop the self-referential 'PR #166 review' rationale comments left over from the previous round; keep only the substantive 'why'.
| return fallback; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
在 _writeUnique 函数中,虽然处理了写入失败的情况,但在 handle.writeFile(content, 'utf8'); 这一行,handle 可能会是未定义的,建议在使用前确保 handle 已成功打开。此外,fs.unlink(candidate) 的调用可能会因为文件不存在而抛出异常,建议在调用时加上错误处理。
| return uri.fsPath; | ||
| } | ||
|
|
||
| module.exports = { exportSessionToMarkdown, renderSessionMarkdown }; |
There was a problem hiding this comment.
在 exportSessionToMarkdown 函数中,_writeUnique 函数的调用后没有检查返回值是否有效,建议在写入文件后检查返回值,确保文件成功写入。同时,await fs.writeFile(uri.fsPath, md, 'utf8'); 这一行没有处理可能的异常,建议加上异常处理逻辑。
| const display = hit ? hit.rel : absPath; | ||
|
|
||
| const openLabel = t('archiveOpenFile'); | ||
| const revealLabel = t('archiveRevealInOS'); |
There was a problem hiding this comment.
在这一段代码中,使用了 findContainingFolder 函数来替代原有的路径处理逻辑。请确保 findContainingFolder 函数的实现是安全的,特别是在处理用户输入的路径时,避免路径穿越等安全漏洞。此外,建议对 hit 结果进行空值检查,以防止潜在的空指针异常。
| async function _pickWorkspaceRoot(sessionWs) { | ||
| const folders = vscode.workspace.workspaceFolders; | ||
| if (!folders || folders.length === 0) return null; | ||
| if (folders.length === 1) return folders[0].uri.fsPath; | ||
| if (sessionWs) { | ||
| const match = folders.find((f) => f.uri.fsPath === sessionWs); | ||
| if (match) return match.uri.fsPath; | ||
| } | ||
| const picked = await vscode.window.showWorkspaceFolderPick({ | ||
| placeHolder: t('archivePickWorkspace'), | ||
| }); | ||
| return picked ? picked.uri.fsPath : null; | ||
| } |
| }); | ||
|
|
||
| const parts = [head, `# ${session.title || t('sessionUntitled')}`, '']; | ||
| const messages = Array.isArray(session.messages) ? session.messages : []; | ||
| for (const m of messages) { |
| .then((choice) => { | ||
| if (!choice) return; | ||
| const uri = vscode.Uri.file(absPath); | ||
| if (choice === openLabel) { | ||
| vscode.window.showTextDocument(uri); | ||
| } else if (choice === revealLabel) { | ||
| vscode.commands.executeCommand('revealFileInOS', uri); | ||
| } |
- archive-export: sanitize session.title for Markdown `#` heading (collapse newlines/control chars) - archive-export: distinguish 'user cancelled folder picker' (sentinel) from 'no workspace open' (null) so cancel no longer falls back to showSaveDialog - session-store: wrap Open File / Reveal in Explorer handlers in try/catch and surface failures as a non-fatal toast (archiveOpenFailed i18n key)
| return picked ? picked.uri.fsPath : PICK_CANCELLED; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
在返回值中引入了 PICK_CANCELLED 符号,虽然可以帮助区分用户取消选择的情况,但需要确保调用此函数的地方都能正确处理这一返回值,避免出现未处理的异常或逻辑错误。建议在相关调用处添加相应的处理逻辑。
| if (root === PICK_CANCELLED) return null; // user dismissed the picker | ||
| if (root) { | ||
| const archiveDir = path.join(root, ARCHIVE_SUBDIR); | ||
| // Defence in depth: even though fileName is sanitised, verify the |
There was a problem hiding this comment.
在处理用户取消的情况时,返回 null 是合理的,但需要确保调用此函数的地方能够正确处理 null 返回值,避免出现空指针异常。建议在调用 exportSessionToMarkdown 的地方添加对返回值的检查。
| }).catch(() => { /* showInformationMessage itself never rejects, but be defensive */ }); | ||
| } | ||
|
|
||
| // ─── Auto-naming ──────────────────────────────────────────────────────── |
There was a problem hiding this comment.
在这个代码段中,增加了对未处理异常的捕获,这是一项良好的实践。然而,仍需注意以下几点:
-
安全性:在处理用户输入或文件路径时,确保没有路径穿越或命令注入的风险。虽然在此段代码中没有直接的风险,但建议在其他地方对
absPath进行验证,以确保其安全性。 -
性能:使用
Promise.resolve()包裹showInformationMessage的调用是多余的,因为该方法本身已经返回一个 Promise。可以直接调用vscode.window.showInformationMessage(...)。 -
可维护性:建议在捕获异常时,记录详细的错误信息,以便后续调试。当前的实现仅显示了错误消息,但没有记录具体的错误堆栈信息。
-
代码风格:确保遵循项目的代码风格,例如缩进、空行等,以保持代码的一致性。
| .trim(); | ||
| return (cleaned || 'untitled').slice(0, 60); |
| // YAML-safe: quote strings containing colons or leading whitespace. | ||
| const s = String(v); | ||
| const needsQuote = /[:#\n]/.test(s) || /^\s/.test(s); | ||
| lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`); |
- _safeTitle: strip trailing space/dot so Win32 path normalisation can't silently mutate the on-disk filename or defeat the collision counter - _frontmatter: always quote string scalars so titles/models that look like YAML keywords (true/null/2026-05-26/123) aren't coerced into bool/date/null/number - _notifyArchived: drop redundant Promise.resolve() wrapper around showInformationMessage (it already returns a thenable); attach rejection handler as the second .then() arg
|
|
||
|
|
||
| # scratch files used by AI review triage | ||
| .tmp-*.json |
There was a problem hiding this comment.
新增的 .tmp-*.json 文件可能会导致敏感数据泄露,建议在版本控制中忽略此类临时文件。同时,确保这些临时文件不会被意外上传到生产环境中。请考虑使用 .gitignore 文件来排除这些临时文件。
| return (cleaned || 'untitled').slice(0, 60).replace(/[. ]+$/, '') || 'untitled'; | ||
| } | ||
|
|
||
| /** "20260526-143012" — local time, fixed-width, sortable. */ |
There was a problem hiding this comment.
在这一段中,增加了对尾部空格和点的处理,这有助于避免在文件系统中出现不一致的文件名。然而,最后的 replace(/[. ]+$/, '') 可能会导致返回的字符串为空,因此在 slice(0, 60) 后面需要确保返回值不为空。建议在返回前添加一个检查,确保返回的字符串至少有一个字符,避免潜在的空指针异常。
| } | ||
| } | ||
| lines.push('---', ''); | ||
| return lines.join('\n'); |
There was a problem hiding this comment.
在这一段中,增加了对所有字符串值的引号处理,这样可以避免 YAML 解析时的潜在问题。然而,当前的实现对数字类型的处理可能会导致意外的行为,特别是在处理 null 和 undefined 时。建议在处理 null 和 undefined 时,明确地将其转换为字符串,以避免在 YAML 中出现不一致的表现。
| tf('archiveSaved', { path: display }), openLabel, revealLabel, | ||
| ).then(async (choice) => { | ||
| if (!choice) return; | ||
| const uri = vscode.Uri.file(absPath); |
There was a problem hiding this comment.
在这一段代码中,虽然 showInformationMessage 本身不会拒绝,但在 .then() 中处理 choice 的时候,仍然需要确保 choice 的值是有效的。建议在处理 choice 之前增加对其类型的检查,以防止潜在的空指针异常。此外,虽然 .catch() 被移除,但建议保留一个简单的错误处理,以防在链式调用中发生意外错误。
| }, () => { /* swallow toast-promise rejection, if any */ }); | ||
| } | ||
|
|
||
| // ─── Auto-naming ──────────────────────────────────────────────────────── |
There was a problem hiding this comment.
在这里,虽然注释提到要吞掉 toast-promise 的拒绝,但没有明确处理可能的错误情况。建议在 .then() 的最后添加一个 .catch() 来处理可能的错误,以确保在发生异常时能够记录或处理错误,而不是简单地吞掉。这样可以提高代码的健壮性和可维护性。
| if (sessionWs) { | ||
| const match = folders.find((f) => f.uri.fsPath === sessionWs); | ||
| if (match) return match.uri.fsPath; | ||
| } |
| const messages = Array.isArray(session.messages) ? session.messages : []; | ||
| for (const m of messages) { | ||
| if (!m) continue; | ||
| if (m.role === 'user') { | ||
| parts.push(`### 🧑 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), ''); |
| const head = _frontmatter({ | ||
| sessionId: session.id || '', | ||
| title: session.title || '', | ||
| createdAt: created, | ||
| updatedAt: updated, |
Round 4 review (#166): - _pickWorkspaceRoot: drop sessionWs shortcut. session.ws is always folder[0] so the shortcut silently bypassed the picker in multi-root workspaces. - _frontmatter: add provider (from config), promptTokens / completionTokens / totalTokens (from session.totals).
| totalTokens: Number(totals.total_tokens) || 0, | ||
| workspace: session.ws || '', | ||
| }); | ||
|
|
There was a problem hiding this comment.
在获取配置时,使用了 try-catch 来处理可能的异常,但没有明确捕获异常类型,可能会掩盖其他潜在问题。建议在 catch 中添加错误处理逻辑,以便于调试和日志记录。此外,provider 的默认值为空字符串,可能会导致后续逻辑中出现空值问题,建议在使用前进行有效性检查。
| if (folders.length === 1) return folders[0].uri.fsPath; | ||
| const picked = await vscode.window.showWorkspaceFolderPick({ | ||
| placeHolder: t('archivePickWorkspace'), | ||
| }); |
There was a problem hiding this comment.
函数参数 _sessionWs 的命名不符合现有代码风格,建议保持一致性,使用 sessionWs。此外,虽然注释中提到的逻辑变更是合理的,但需要确保在多根文件夹的情况下,用户体验不会受到影响,建议在实现后进行充分的测试。
| archiveSaved: 'Session archived to {path}', | ||
| archiveFailed: 'Deep Copilot: failed to archive session — {msg}', | ||
| archiveOpenFile: 'Open File', | ||
| archiveRevealInOS: 'Reveal in Explorer', |
| archiveSaved: '会话已存档到 {path}', | ||
| archiveFailed: 'Deep Copilot:会话存档失败 — {msg}', | ||
| archiveOpenFile: '打开文件', | ||
| archiveRevealInOS: '在资源管理器中显示', |
| // `provider` is not persisted on the session record (only `model`/`mode` | ||
| // are), so we read the live setting at archive time. Token totals come | ||
| // from `session.totals`, which SessionStore accumulates per turn — see | ||
| // session-store.js ~L263. Both fields are best-effort: missing values | ||
| // are omitted by `_frontmatter` rather than rendered as empty strings. | ||
| let provider = ''; | ||
| try { | ||
| provider = vscode.workspace.getConfiguration('deepseekAgent').get('provider') || ''; | ||
| } catch { /* tests / no vscode runtime */ } | ||
| const totals = session.totals || {}; | ||
|
|
||
| const head = _frontmatter({ | ||
| sessionId: session.id || '', | ||
| title: session.title || '', | ||
| createdAt: created, | ||
| updatedAt: updated, | ||
| archivedAt: archived, | ||
| provider, | ||
| model: session.model || '', | ||
| mode: session.mode || '', | ||
| messageCount: session.msgCount || (session.messages || []).length, | ||
| promptTokens: Number(totals.prompt_tokens) || 0, | ||
| completionTokens: Number(totals.completion_tokens) || 0, | ||
| totalTokens: Number(totals.total_tokens) || 0, | ||
| workspace: session.ws || '', |
#167) (#168) * feat(session): archive action now exports session to Markdown (#165) Previously the right-click 'Archive' menu was a soft-hide toggle — it flipped session.archived in globalState, the entry disappeared from the sidebar, and that was it. Users reported that the label did not match what happened: nothing was actually archived anywhere they could grep, commit, or share. This change makes Archive do what it says: 1. src/chat/archive-export.js (new): renders a SessionStore record to Markdown — YAML frontmatter with sessionId/model/timestamps, role- tagged sections (User / Assistant), and <details>-wrapped reasoning so it does not drown out the conversation. Picks the destination via workspaceFolders (single root -> use it; multi-root -> workspace folder pick, biased toward the session's original ws; no folder open -> showSaveDialog). Sanitises titles for filesystem hostility, appends -1/-2 on name collision, and verifies the resolved path stays under the chosen root before writing (defence in depth). 2. SessionStore.archive: on first archive of a visible session, export to .deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md, then perform the original soft-hide. Clicking again on an archived session still un-hides it (toggle preserved; the file on disk is not touched). Export failures surface through showErrorMessage but do not block the hide, so the gesture always advances UI state. 3. Bottom-right toast on success with Open File / Reveal in Explorer actions. Path is shown workspace-relative when possible. 4. i18n: 9 new keys for the toast, dialog labels, and rendered role headers. EN and ZH both populated. Security (per copilot-instructions.md red-line #3): writes are gated by path.relative containment check + path.resolve normalisation before any fs.writeFile call; no user input is interpolated into a shell command. Closes #165. * fix(archive): address PR #166 review feedback Four corrections from Copilot's PR review on #166: 1. archive-export.js renderSessionMarkdown: drop the global `replace(/\\n{3,}/g, '\\n\\n')` pass. It would collapse blank lines inside fenced code blocks / tool output, mutating verbatim message content. Each section already pushes its own controlled trailing blank line, so the global normalisation was both redundant and harmful. Issue raised by Copilot inline review. 2. archive-export.js exportSessionToMarkdown: showSaveDialog's `defaultUri` was `vscode.Uri.file(fileName)` — `Uri.file()` requires an absolute path. On Windows that resolves to a drive root, on POSIX to `/`, which is a confusing and possibly unwritable default. Anchor it at `os.homedir() + fileName` so the dialog opens somewhere the user expects. 3. session-store.js archive(): only set `archived = true` when a file was actually written. Previously, both `exportSessionToMarkdown` throwing AND returning null (user cancelled the save dialog) would still hide the session from the sidebar even though nothing was archived — leaving the UI inconsistent with disk state. New contract: - returns absolute path → hide + show toast - returns null (cancel) → keep visible, no toast - throws (fs error) → keep visible, show error message 4. archive-export.js _uniquePath: replace the fs.access pre-check with `fs.open(candidate, 'wx')` (exclusive create). Closes the TOCTOU window where two concurrent archive clicks in the same second could both pass the existence check and one would overwrite the other. The placeholder zero-byte file is then overwritten by the subsequent fs.writeFile. Also added a comment to `_safeTitle` explaining the regex classes (reserved-on-Windows set + C0 control codes + leading-dot strip), addressing a readability note from the AI reviewer. Build verified locally; no behavioural change outside the four fixes. * refactor(archive): address PR review #166 round 2 - _writeUnique: write through the exclusive handle so a failed write no longer leaves a zero-byte placeholder behind (closes Copilot comment on _uniquePath placeholder leak). - Replace ad-hoc workspace-relative path logic in _notifyArchived with the multi-root + longest-prefix-aware findContainingFolder() helper from src/utils/paths.js. - Move the 'archive path escapes workspace' error string into i18n (archiveErrEscape) so ZH users no longer see an English assertion in the failure toast. - Drop the self-referential 'PR #166 review' rationale comments left over from the previous round; keep only the substantive 'why'. * refactor(archive): address PR #166 round 3 review - archive-export: sanitize session.title for Markdown `#` heading (collapse newlines/control chars) - archive-export: distinguish 'user cancelled folder picker' (sentinel) from 'no workspace open' (null) so cancel no longer falls back to showSaveDialog - session-store: wrap Open File / Reveal in Explorer handlers in try/catch and surface failures as a non-fatal toast (archiveOpenFailed i18n key) * refactor(archive): address PR #166 round 4 review - _safeTitle: strip trailing space/dot so Win32 path normalisation can't silently mutate the on-disk filename or defeat the collision counter - _frontmatter: always quote string scalars so titles/models that look like YAML keywords (true/null/2026-05-26/123) aren't coerced into bool/date/null/number - _notifyArchived: drop redundant Promise.resolve() wrapper around showInformationMessage (it already returns a thenable); attach rejection handler as the second .then() arg * chore: drop accidentally-committed .tmp-*.json scratch files and ignore them * fix(archive): always show picker in multi-root; export provider+tokens Round 4 review (#166): - _pickWorkspaceRoot: drop sessionWs shortcut. session.ws is always folder[0] so the shortcut silently bypassed the picker in multi-root workspaces. - _frontmatter: add provider (from config), promptTokens / completionTokens / totalTokens (from session.totals). * fix(bg-shell): keep agent on duty until session-started bg jobs finish (#167) Track jobIds started by run_shell_bg within the current run in run._sessionStartedBgJobs, and refuse to take the BG_WAIT_SKIPPED_MODEL_DONE early exit while any of them is still alive. Previously the model could defuse the wait loop by emitting a single closing sentence (e.g. 'I'll tell you when it's done'), leaving the just-spawned background task orphaned with no one listening for its end event. Also restate the run_shell_bg tool hint honestly so the model understands it cannot end the turn via a verbal promise while a bg job is live. Refs #167 * fix(bg-shell): address PR #168 review feedback 1. Register jobId in run-scoped _sessionStartedBgJobs BEFORE publishing to addActiveBgJob, closing a race where an extremely short-lived command could fire bg-job-end between the two calls, leaving a stale entry in the run set. 2. Stop swallowing ctx.registerBgJob errors. Guard with typeof check and log via Logger.info (Logger has no warn today) so regressions surface in debug-logs instead of silently losing the keep-alive signal. 3. Rename archive directory from .deepcopilot/archives to .deep-copilot/archives to match the rest of the codebase's workspace-artifact convention (plans, memory, logs). Updates docstrings and toast example path accordingly. * chore(bg-shell): drop PR# from inline comments to avoid review confusion Copilot reviewer kept reading the in-code 'PR #168 review' note as a wrong PR reference (the issue is #167 and the branch name carries 167). Rephrase to 'post-merge review' so the rationale stays accurate regardless of which PR ends up shipping the commit.
What
Closes #165. Reworks the right-click 📦 Archive menu action from a silent soft-hide into a real archive: the session is rendered to a Markdown file under
<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.mdand the user gets a bottom-right toast with Open File / Reveal in Explorer buttons. The original soft-hide still happens afterwards so the entry leaves the sidebar.Why
The pre-existing behaviour only flipped
session.archivedinglobalState. Nothing was archived anywhere the user could grep, commit, or share ��?the menu label promised more than it delivered.Changes
src/chat/archive-export.js(new)<details>-wrapped reasoning). Resolves destination viaworkspaceFolderswithshowWorkspaceFolderPickfor multi-root andshowSaveDialogfallback when no workspace is open. Sanitises titles, appends-1/-2on collision, verifies resolved path stays under the chosen root before writing.src/chat/session-store.jsarchive(id): export-then-hide; un-archive (second click) remains a pure toggle and never touches the file on disk. Export failures and user-cancelled save dialogs keep the session visible (we only soft-hide after a Markdown file is confirmed on disk) and surface ashowErrorMessageon failure. New_notifyArchived(absPath)builds the toast with workspace-relative path display + Open/Reveal actions. Addstfimport.src/utils/i18n.jsAcceptance criteria check (from #165)
.deepcopilot/archives/<timestamp>-<title>.mdarchivedsemantics preserved)showSaveDialogws)<details>, frontmatter parsableshowErrorMessagesrc/utils/i18n.js(EN + ZH)Security (copilot-instructions.md red-line #3)
\/:*?"<>|+ control chars stripped, leading dots dropped, length capped).path.relative+path.isAbsoluteagainst the chosen workspace root beforefs.writeFile��?out-of-root paths throw.Manual test
Session archived to .deepcopilot/archives/…Notes
npm run build(esbuild ��?out/extension.js, no errors).dangerfile.js/ commitlint should be satisfied: conventional commit subject, body within 100 cols.