From ace3b12b1a8fe8f50975c0eff7310ecd20392dbb Mon Sep 17 00:00:00 2001 From: ZhouChuange Date: Tue, 26 May 2026 17:11:05 +1000 Subject: [PATCH 01/10] feat(session): archive action now exports session to Markdown (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
-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-.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. --- src/chat/archive-export.js | 202 +++++++++++++++++++++++++++++++++++++ src/chat/session-store.js | 83 ++++++++++++++- src/utils/i18n.js | 22 ++++ 3 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 src/chat/archive-export.js diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js new file mode 100644 index 0000000..47bd6f9 --- /dev/null +++ b/src/chat/archive-export.js @@ -0,0 +1,202 @@ +// Export a chat session to a Markdown file under the workspace. +// +// Issue #165: the right-click "📦 Archive" action used to be a soft hide +// (toggle `archived` flag). Users expected real archiving — a Markdown +// snapshot they can grep, commit, or share. This module renders the +// session record to Markdown and writes it under +// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`. +// +// Edge cases handled: +// - No workspace open → fall back to vscode.window.showSaveDialog. +// - Multi-root workspace → showWorkspaceFolderPick to choose target. +// - Path traversal → resolved path must stay under chosen root +// (defence in depth even though titles are +// already sanitised). +// - Name collision → append "-1", "-2", … suffix. +'use strict'; + +const vscode = require('vscode'); +const path = require('path'); +const fs = require('fs/promises'); +const { t } = require('../utils/i18n'); + +const ARCHIVE_SUBDIR = '.deepcopilot/archives'; + +/** Strip filesystem-hostile characters and trim length. */ +function _safeTitle(raw) { + const s = String(raw || '').trim(); + if (!s) return 'untitled'; + // Forbidden on Windows: \ / : * ? " < > | — plus control chars. + // Also drop leading dots so we never produce a hidden file. + const cleaned = s + .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') + .replace(/^\.+/, '_') + .replace(/\s+/g, ' ') + .trim(); + return (cleaned || 'untitled').slice(0, 60); +} + +/** "20260526-143012" — local time, fixed-width, sortable. */ +function _timestamp(d = new Date()) { + const pad = (n) => String(n).padStart(2, '0'); + return ( + d.getFullYear().toString() + + pad(d.getMonth() + 1) + + pad(d.getDate()) + + '-' + + pad(d.getHours()) + + pad(d.getMinutes()) + + pad(d.getSeconds()) + ); +} + +/** Render YAML frontmatter from primitive key/value pairs. */ +function _frontmatter(meta) { + const lines = ['---']; + for (const [k, v] of Object.entries(meta)) { + if (v == null || v === '') continue; + // 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}`); + } + lines.push('---', ''); + return lines.join('\n'); +} + +/** Wrap reasoning/thoughts in a collapsible <details> block. */ +function _renderThoughts(thoughts) { + if (!thoughts) return ''; + return [ + '<details>', + `<summary>${t('archiveThoughtsLabel')}</summary>`, + '', + thoughts.trim(), + '', + '</details>', + '', + ].join('\n'); +} + +/** + * Render a session record to a Markdown string. + * The record shape mirrors what SessionStore.append() persists: + * { id, title, createdAt, updatedAt, model, mode, ws, msgCount, + * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] } + */ +function renderSessionMarkdown(session) { + const created = session.createdAt ? new Date(session.createdAt).toISOString() : ''; + const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : ''; + const archived = new Date().toISOString(); + + const head = _frontmatter({ + sessionId: session.id || '', + title: session.title || '', + createdAt: created, + updatedAt: updated, + archivedAt: archived, + model: session.model || '', + mode: session.mode || '', + messageCount: session.msgCount || (session.messages || []).length, + workspace: session.ws || '', + }); + + const parts = [head, `# ${session.title || t('sessionUntitled')}`, '']; + 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(), ''); + } else if (m.role === 'assistant') { + parts.push(`### 🤖 ${t('archiveRoleAssistant')}`, ''); + const thoughts = _renderThoughts(m.thoughts); + if (thoughts) parts.push(thoughts); + const body = String(m.text || '').trim(); + if (body) parts.push(body, ''); + } else { + // Defensive: render unknown roles verbatim so nothing is silently lost. + parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), ''); + } + } + + return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; +} + +/** + * Pick the target workspace folder. Returns the folder fsPath or null. + * - 0 folders → null (caller falls back to save dialog). + * - 1 folder → use it. + * - 2+ → prompt the user. + * @param {string} sessionWs — the workspace the session was created in; used + * as a strong hint to skip the picker in multi-root scenarios. + */ +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; +} + +/** Find a non-colliding path by appending "-1", "-2", … before ".md". */ +async function _uniquePath(dir, baseName) { + const ext = '.md'; + const stem = baseName.replace(/\.md$/i, ''); + let candidate = path.join(dir, stem + ext); + for (let i = 1; i < 1000; i++) { + try { + await fs.access(candidate); + } catch { + return candidate; + } + candidate = path.join(dir, `${stem}-${i}${ext}`); + } + // Extremely unlikely; bail out with a timestamped name. + return path.join(dir, `${stem}-${Date.now()}${ext}`); +} + +/** + * Resolve the destination path, then write the markdown. + * Returns the absolute path written, or null if the user cancelled the + * save dialog in the no-workspace fallback. + * Throws on filesystem errors so the caller can surface a friendly message. + */ +async function exportSessionToMarkdown(session) { + const md = renderSessionMarkdown(session); + const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`; + + const root = await _pickWorkspaceRoot(session.ws); + let target; + if (root) { + const archiveDir = path.join(root, ARCHIVE_SUBDIR); + // 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 }); + target = await _uniquePath(archiveDir, fileName); + } else { + // 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; + } + + await fs.writeFile(target, md, 'utf8'); + return target; +} + +module.exports = { exportSessionToMarkdown, renderSessionMarkdown }; diff --git a/src/chat/session-store.js b/src/chat/session-store.js index 9c235c6..3e8bc6f 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -7,7 +7,7 @@ const vscode = require('vscode'); const { randomBytes } = require('crypto'); -const { t } = require('../utils/i18n'); +const { t, tf } = require('../utils/i18n'); // ─── Orphan tool_calls sanitizer ─────────────────────────────────────────── // Removes ANY incomplete assistant{tool_calls} group from a message array, @@ -360,17 +360,94 @@ class SessionStore { this.postList(); } + /** + * "Archive" a session — issue #165. + * + * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`, + * disappear from the sidebar list, data stays in globalState. Users + * reported that this didn't match the menu label's promise: nothing was + * actually *archived* anywhere they could see, find, or grep. + * + * New behaviour: render the session to Markdown and write it under + * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md + * Then perform the original soft-hide so the session leaves the sidebar. + * + * The "un-archive" gesture (clicking again on an already-archived item) + * is still supported via the boolean toggle — it simply un-hides the + * record without touching the Markdown file on disk. + */ async archive(id) { const list = this.all(); const s = list.find(x => x.id === id); if (!s) return; - s.archived = !s.archived; - if (this.sessionId === id && s.archived) { + + // Un-archive: just flip back to visible. No file I/O needed. + if (s.archived) { + s.archived = false; + await this.set(list); + this.postList(); + return; + } + + // Archive: try to export to Markdown first. If that fails (user + // cancelled save dialog, disk error, etc.) we still perform the + // soft-hide so the menu action does *something* visible — the user + // can re-trigger the export later via the un-archive → archive + // round-trip if they fix the underlying problem. Errors are surfaced + // through showErrorMessage but never thrown — the UI gesture must + // not leave the sidebar in an inconsistent state. + 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 })); + } + + s.archived = true; + if (this.sessionId === id) { this.sessionId = null; this._post({ type: 'sessionLoaded', id: null, messages: [] }); } await this.set(list); this.postList(); + + if (savedPath) this._notifyArchived(savedPath); + } + + /** + * Show the bottom-right toast with "Open" / "Reveal in Explorer" buttons. + * Path display is workspace-relative when possible so users see + * ".deepcopilot/archives/20260526-….md" + * instead of a long absolute path. + */ + _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; + } + } + + const openLabel = t('archiveOpenFile'); + const revealLabel = t('archiveRevealInOS'); + vscode.window + .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel) + .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); + } + }); } // ─── Auto-naming ──────────────────────────────────────────────────────── diff --git a/src/utils/i18n.js b/src/utils/i18n.js index 63319f6..baffff3 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -115,6 +115,17 @@ const EN = { // locales and only localize the trailing human-readable explanation. shellNoOutput: '[Note: no output for last {sec}s]', shellSilentTimeout: '[Note: process was silent for last {sec}s before timeout — likely hung (e.g. port in use, waiting for input, blocked on external resource). Do NOT retry blindly; report the situation to the user.]', + + // Session archive → Markdown export (issue #165) + archiveSaved: 'Session archived to {path}', + archiveFailed: 'Deep Copilot: failed to archive session — {msg}', + archiveOpenFile: 'Open File', + archiveRevealInOS: 'Reveal in Explorer', + archiveSaveLabel: 'Save Archive', + archivePickWorkspace: 'Pick a workspace folder to save the archive into', + archiveRoleUser: 'User', + archiveRoleAssistant: 'Assistant', + archiveThoughtsLabel: 'Reasoning', }; const ZH = { @@ -219,6 +230,17 @@ const ZH = { // 仅本地化后面的中文说明部分。 shellNoOutput: '[Note: no output for last {sec}s](进程仍在运行,已 {sec} 秒未输出)', shellSilentTimeout: '[Note: process was silent for last {sec}s before timeout — likely hung](超时前 {sec} 秒静默,疑似挂起:端口被占用 / 等待输入 / 外部资源阻塞。不要盲目重试,请向用户报告。)', + + // 会话归档 → Markdown 导出(issue #165) + archiveSaved: '会话已存档到 {path}', + archiveFailed: 'Deep Copilot:会话存档失败 — {msg}', + archiveOpenFile: '打开文件', + archiveRevealInOS: '在资源管理器中显示', + archiveSaveLabel: '保存存档', + archivePickWorkspace: '选择要保存存档的工作区文件夹', + archiveRoleUser: '用户', + archiveRoleAssistant: '助手', + archiveThoughtsLabel: '思维链', }; function t(key) { From 58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 17:18:17 +1000 Subject: [PATCH 02/10] fix(archive): address PR #166 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/chat/archive-export.js | 47 +++++++++++++++++++++++++++++--------- src/chat/session-store.js | 20 +++++++++------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js index 47bd6f9..fbb7dc7 100644 --- a/src/chat/archive-export.js +++ b/src/chat/archive-export.js @@ -22,12 +22,19 @@ const { t } = require('../utils/i18n'); const ARCHIVE_SUBDIR = '.deepcopilot/archives'; -/** Strip filesystem-hostile characters and trim length. */ +/** + * 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 (`..`). + */ function _safeTitle(raw) { const s = String(raw || '').trim(); if (!s) return 'untitled'; - // Forbidden on Windows: \ / : * ? " < > | — plus control chars. - // Also drop leading dots so we never produce a hidden file. const cleaned = s .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') .replace(/^\.+/, '_') @@ -119,7 +126,11 @@ function renderSessionMarkdown(session) { } } - return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; + // Compose the document. We intentionally do NOT run a global + // `\n{3,}` collapse here — that would mutate verbatim user/assistant + // text and break formatting inside fenced code blocks (PR #166 review). + // Instead, each section pushes its own controlled trailing blank line. + return parts.join('\n').trimEnd() + '\n'; } /** @@ -144,18 +155,27 @@ async function _pickWorkspaceRoot(sessionWs) { return picked ? picked.uri.fsPath : null; } -/** Find a non-colliding path by appending "-1", "-2", … before ".md". */ +/** + * 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, ''); - let candidate = path.join(dir, stem + ext); - for (let i = 1; i < 1000; i++) { + for (let i = 0; i < 1000; i++) { + const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`); try { - await fs.access(candidate); - } catch { + const handle = await fs.open(candidate, 'wx'); + await handle.close(); return candidate; + } catch (err) { + if (err && err.code === 'EEXIST') continue; + throw err; } - candidate = path.join(dir, `${stem}-${i}${ext}`); } // Extremely unlikely; bail out with a timestamped name. return path.join(dir, `${stem}-${Date.now()}${ext}`); @@ -186,10 +206,15 @@ async function exportSessionToMarkdown(session) { target = await _uniquePath(archiveDir, fileName); } else { // No workspace open — ask the user where to put it. + // `Uri.file()` requires an absolute path (PR #166 review): passing a + // bare filename resolves to a confusing location (drive root on + // Windows, `/` on POSIX). Anchor the default at the user's home so + // the dialog opens somewhere predictable. + const os = require('os'); const uri = await vscode.window.showSaveDialog({ saveLabel: t('archiveSaveLabel'), filters: { Markdown: ['md'] }, - defaultUri: vscode.Uri.file(fileName), + defaultUri: vscode.Uri.file(path.join(os.homedir(), fileName)), }); if (!uri) return null; target = uri.fsPath; diff --git a/src/chat/session-store.js b/src/chat/session-store.js index 3e8bc6f..eade1b4 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -389,13 +389,15 @@ class SessionStore { return; } - // Archive: try to export to Markdown first. If that fails (user - // cancelled save dialog, disk error, etc.) we still perform the - // soft-hide so the menu action does *something* visible — the user - // can re-trigger the export later via the un-archive → archive - // round-trip if they fix the underlying problem. Errors are surfaced - // through showErrorMessage but never thrown — the UI gesture must - // not leave the sidebar in an inconsistent state. + // 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. let savedPath = null; try { const { exportSessionToMarkdown } = require('./archive-export'); @@ -403,7 +405,9 @@ class SessionStore { } 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 s.archived = true; if (this.sessionId === id) { @@ -413,7 +417,7 @@ class SessionStore { await this.set(list); this.postList(); - if (savedPath) this._notifyArchived(savedPath); + this._notifyArchived(savedPath); } /** From 34ed95903a593741b31af6e296e85ac8b9ad2fa9 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 17:29:24 +1000 Subject: [PATCH 03/10] 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'. --- src/chat/archive-export.js | 81 ++++++++++++++++++++++---------------- src/chat/session-store.js | 25 +++++------- src/utils/i18n.js | 2 + 3 files changed, 58 insertions(+), 50 deletions(-) diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js index fbb7dc7..19cec65 100644 --- a/src/chat/archive-export.js +++ b/src/chat/archive-export.js @@ -24,7 +24,7 @@ const ARCHIVE_SUBDIR = '.deepcopilot/archives'; /** * Strip filesystem-hostile characters and trim length. - * Removed character classes (per #166 review): + * Removed character classes: * - `\ / : * ? " < > |` 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 @@ -128,8 +128,8 @@ function renderSessionMarkdown(session) { // Compose the document. We intentionally do NOT run a global // `\n{3,}` collapse here — that would mutate verbatim user/assistant - // text and break formatting inside fenced code blocks (PR #166 review). - // Instead, each section pushes its own controlled trailing blank line. + // text and break formatting inside fenced code blocks. Instead, each + // section pushes its own controlled trailing blank line. return parts.join('\n').trimEnd() + '\n'; } @@ -156,29 +156,45 @@ async function _pickWorkspaceRoot(sessionWs) { } /** - * 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`. + * Reserve a non-colliding path AND write content atomically through an + * exclusive handle. `fs.open(..., 'wx')` 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). + * + * Writing through the exclusive handle — rather than reserving an empty + * placeholder and then re-opening with `fs.writeFile` — prevents zero-byte + * residue when the write itself fails (disk full / permission revoked + * mid-write). On error we close the handle and `unlink` the placeholder so + * subsequent archives don't skip the now-orphaned name. */ -async function _uniquePath(dir, baseName) { +async function _writeUnique(dir, baseName, content) { 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}`); + let handle; try { - const handle = await fs.open(candidate, 'wx'); - await handle.close(); - return candidate; + handle = await fs.open(candidate, 'wx'); } catch (err) { if (err && err.code === 'EEXIST') continue; throw err; } + try { + await handle.writeFile(content, 'utf8'); + await handle.close(); + return candidate; + } catch (writeErr) { + // Close best-effort, then remove the empty/partial placeholder. + try { await handle.close(); } catch { /* ignore */ } + try { await fs.unlink(candidate); } catch { /* ignore */ } + throw writeErr; + } } - // Extremely unlikely; bail out with a timestamped name. - return path.join(dir, `${stem}-${Date.now()}${ext}`); + // Extremely unlikely (1000 same-second collisions); bail out with a + // timestamped name and a regular write — still safer than overwriting. + const fallback = path.join(dir, `${stem}-${Date.now()}${ext}`); + await fs.writeFile(fallback, content, { encoding: 'utf8', flag: 'wx' }); + return fallback; } /** @@ -192,7 +208,6 @@ async function exportSessionToMarkdown(session) { const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`; const root = await _pickWorkspaceRoot(session.ws); - let target; if (root) { const archiveDir = path.join(root, ARCHIVE_SUBDIR); // Defence in depth: even though fileName is sanitised, verify the @@ -200,28 +215,26 @@ async function exportSessionToMarkdown(session) { 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.'); + // i18n'd, user-facing — see archiveErrEscape in src/utils/i18n.js. + throw new Error(t('archiveErrEscape')); } await fs.mkdir(archiveDir, { recursive: true }); - target = await _uniquePath(archiveDir, fileName); - } else { - // No workspace open — ask the user where to put it. - // `Uri.file()` requires an absolute path (PR #166 review): passing a - // bare filename resolves to a confusing location (drive root on - // Windows, `/` on POSIX). Anchor the default at the user's home so - // the dialog opens somewhere predictable. - const os = require('os'); - const uri = await vscode.window.showSaveDialog({ - saveLabel: t('archiveSaveLabel'), - filters: { Markdown: ['md'] }, - defaultUri: vscode.Uri.file(path.join(os.homedir(), fileName)), - }); - if (!uri) return null; - target = uri.fsPath; + return await _writeUnique(archiveDir, fileName, md); } - await fs.writeFile(target, md, 'utf8'); - return target; + // No workspace open — ask the user where to put it. `Uri.file()` requires + // an absolute path: passing a bare filename resolves to a confusing + // location (drive root on Windows, `/` on POSIX). Anchor the default at + // the user's home so the dialog opens somewhere predictable. + const os = require('os'); + const uri = await vscode.window.showSaveDialog({ + saveLabel: t('archiveSaveLabel'), + filters: { Markdown: ['md'] }, + defaultUri: vscode.Uri.file(path.join(os.homedir(), fileName)), + }); + if (!uri) return null; + await fs.writeFile(uri.fsPath, md, 'utf8'); + return uri.fsPath; } module.exports = { exportSessionToMarkdown, renderSessionMarkdown }; diff --git a/src/chat/session-store.js b/src/chat/session-store.js index eade1b4..cd1759b 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -390,10 +390,10 @@ class SessionStore { } // 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: + // sidebar if we actually produced a file on disk — otherwise setting + // `archived = true` on export failure (or on a user-cancelled save + // dialog) would leave the session invisible in the list while nothing + // was actually archived. Contract: // - savedPath is a string → write succeeded, hide the session. // - savedPath is null → user cancelled the save dialog; keep // session visible, do nothing more. @@ -424,20 +424,13 @@ class SessionStore { * Show the bottom-right toast with "Open" / "Reveal in Explorer" buttons. * Path display is workspace-relative when possible so users see * ".deepcopilot/archives/20260526-….md" - * instead of a long absolute path. + * instead of a long absolute path. Delegates the relativisation to + * `findContainingFolder` so multi-root + nested-root cases stay correct. */ _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; - } - } + const { findContainingFolder } = require('../utils/paths'); + const hit = findContainingFolder(absPath); + const display = hit ? hit.rel : absPath; const openLabel = t('archiveOpenFile'); const revealLabel = t('archiveRevealInOS'); diff --git a/src/utils/i18n.js b/src/utils/i18n.js index baffff3..c4e326d 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -123,6 +123,7 @@ const EN = { archiveRevealInOS: 'Reveal in Explorer', archiveSaveLabel: 'Save Archive', archivePickWorkspace: 'Pick a workspace folder to save the archive into', + archiveErrEscape: 'Refused to write archive: resolved path is outside the workspace.', archiveRoleUser: 'User', archiveRoleAssistant: 'Assistant', archiveThoughtsLabel: 'Reasoning', @@ -238,6 +239,7 @@ const ZH = { archiveRevealInOS: '在资源管理器中显示', archiveSaveLabel: '保存存档', archivePickWorkspace: '选择要保存存档的工作区文件夹', + archiveErrEscape: '拒绝写入存档:解析后的路径超出了工作区。', archiveRoleUser: '用户', archiveRoleAssistant: '助手', archiveThoughtsLabel: '思维链', From 3d53ea6dfc6ad1bceae0ae8825b8122676d45287 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 17:39:10 +1000 Subject: [PATCH 04/10] 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) --- src/chat/archive-export.js | 42 ++++++++++++++++++++++++++++++-------- src/chat/session-store.js | 28 +++++++++++++++++-------- src/utils/i18n.js | 2 ++ 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js index 19cec65..7f86bc4 100644 --- a/src/chat/archive-export.js +++ b/src/chat/archive-export.js @@ -85,6 +85,19 @@ function _renderThoughts(thoughts) { ].join('\n'); } +/** + * Collapse newlines/tabs/control chars in a session title down to a single + * space before it is injected into a Markdown `# ...` heading. Without this, + * a title that contains "\n" (e.g. taken from the first user message or a + * pasted rename) would split the heading and break the document structure. + */ +function _safeHeadingTitle(raw) { + return String(raw || '') + .replace(/[\u0000-\u001f\u007f]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + /** * Render a session record to a Markdown string. * The record shape mirrors what SessionStore.append() persists: @@ -108,7 +121,8 @@ function renderSessionMarkdown(session) { workspace: session.ws || '', }); - const parts = [head, `# ${session.title || t('sessionUntitled')}`, '']; + const heading = _safeHeadingTitle(session.title) || t('sessionUntitled'); + const parts = [head, `# ${heading}`, '']; const messages = Array.isArray(session.messages) ? session.messages : []; for (const m of messages) { if (!m) continue; @@ -134,10 +148,20 @@ function renderSessionMarkdown(session) { } /** - * Pick the target workspace folder. Returns the folder fsPath or null. - * - 0 folders → null (caller falls back to save dialog). - * - 1 folder → use it. - * - 2+ → prompt the user. + * Sentinel returned by `_pickWorkspaceRoot` when the user explicitly + * dismissed the multi-root workspace folder picker. We MUST distinguish this + * from the "no workspace open" case (returns `null`): in the cancel case we + * should abort the archive cleanly, not silently fall back to a save dialog + * (which would happily let the user save outside any workspace). + */ +const PICK_CANCELLED = Symbol('pick-cancelled'); + +/** + * Pick the target workspace folder. + * - 0 folders → returns `null` (caller falls back to save dialog). + * - 1 folder → returns its fsPath. + * - 2+ → returns the picked fsPath, or `PICK_CANCELLED` if the + * user dismissed the picker. * @param {string} sessionWs — the workspace the session was created in; used * as a strong hint to skip the picker in multi-root scenarios. */ @@ -152,7 +176,7 @@ async function _pickWorkspaceRoot(sessionWs) { const picked = await vscode.window.showWorkspaceFolderPick({ placeHolder: t('archivePickWorkspace'), }); - return picked ? picked.uri.fsPath : null; + return picked ? picked.uri.fsPath : PICK_CANCELLED; } /** @@ -199,8 +223,9 @@ async function _writeUnique(dir, baseName, content) { /** * Resolve the destination path, then write the markdown. - * Returns the absolute path written, or null if the user cancelled the - * save dialog in the no-workspace fallback. + * Returns the absolute path written, or `null` if: + * - the user cancelled the multi-root workspace folder picker, OR + * - the user cancelled the save dialog in the no-workspace fallback. * Throws on filesystem errors so the caller can surface a friendly message. */ async function exportSessionToMarkdown(session) { @@ -208,6 +233,7 @@ async function exportSessionToMarkdown(session) { const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`; const root = await _pickWorkspaceRoot(session.ws); + 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 diff --git a/src/chat/session-store.js b/src/chat/session-store.js index cd1759b..22342ef 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -434,17 +434,29 @@ class SessionStore { const openLabel = t('archiveOpenFile'); const revealLabel = t('archiveRevealInOS'); - vscode.window - .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel) - .then((choice) => { - if (!choice) return; - const uri = vscode.Uri.file(absPath); + // Wrap both the toast .then() and the VS Code calls inside it so that + // a missing/deleted file or permission error after the user clicks + // does not surface as an unhandled promise rejection. We swallow the + // error after surfacing it as a non-fatal toast — failure to *open* + // the archive is not the archive itself failing. + Promise.resolve( + vscode.window.showInformationMessage( + tf('archiveSaved', { path: display }), openLabel, revealLabel, + ), + ).then(async (choice) => { + if (!choice) return; + const uri = vscode.Uri.file(absPath); + try { if (choice === openLabel) { - vscode.window.showTextDocument(uri); + await vscode.window.showTextDocument(uri); } else if (choice === revealLabel) { - vscode.commands.executeCommand('revealFileInOS', uri); + await vscode.commands.executeCommand('revealFileInOS', uri); } - }); + } catch (err) { + const msg = (err && err.message) || String(err); + vscode.window.showWarningMessage(tf('archiveOpenFailed', { msg })); + } + }).catch(() => { /* showInformationMessage itself never rejects, but be defensive */ }); } // ─── Auto-naming ──────────────────────────────────────────────────────── diff --git a/src/utils/i18n.js b/src/utils/i18n.js index c4e326d..7751bd6 100644 --- a/src/utils/i18n.js +++ b/src/utils/i18n.js @@ -124,6 +124,7 @@ const EN = { archiveSaveLabel: 'Save Archive', archivePickWorkspace: 'Pick a workspace folder to save the archive into', archiveErrEscape: 'Refused to write archive: resolved path is outside the workspace.', + archiveOpenFailed: 'Could not open the archived file — {msg}', archiveRoleUser: 'User', archiveRoleAssistant: 'Assistant', archiveThoughtsLabel: 'Reasoning', @@ -240,6 +241,7 @@ const ZH = { archiveSaveLabel: '保存存档', archivePickWorkspace: '选择要保存存档的工作区文件夹', archiveErrEscape: '拒绝写入存档:解析后的路径超出了工作区。', + archiveOpenFailed: '无法打开存档文件 — {msg}', archiveRoleUser: '用户', archiveRoleAssistant: '助手', archiveThoughtsLabel: '思维链', From 5c3caf6a6cb472ea2eda014277ee5bb79df31ea3 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 17:50:55 +1000 Subject: [PATCH 05/10] 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 --- .tmp-c.json | 1 + .tmp-pr.json | 1 + src/chat/archive-export.js | 25 +++++++++++++++++++------ src/chat/session-store.js | 17 +++++++---------- 4 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 .tmp-c.json create mode 100644 .tmp-pr.json diff --git a/.tmp-c.json b/.tmp-c.json new file mode 100644 index 0000000..8f80485 --- /dev/null +++ b/.tmp-c.json @@ -0,0 +1 @@ +[{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887925","pull_request_review_id":4361411325,"id":3301887925,"node_id":"PRRC_kwDOSYeQPs7Ezs-1","diff_hunk":"@@ -0,0 +1,202 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/** Strip filesystem-hostile characters and trim length. */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ // Forbidden on Windows: \\ / : * ? \" < > | 鈥?plus control chars.\n+ // Also drop leading dots so we never produce a hidden file.\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ return parts.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/** Find a non-colliding path by appending \"-1\", \"-2\", 鈥?before \".md\". */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ let candidate = path.join(dir, stem + ext);\n+ for (let i = 1; i < 1000; i++) {\n+ try {\n+ await fs.access(candidate);\n+ } catch {\n+ return candidate;\n+ }\n+ candidate = path.join(dir, `${stem}-${i}${ext}`);\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ let target;\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ throw new Error('Resolved archive path escapes the workspace root.');\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });\n+ target = await _uniquePath(archiveDir, fileName);\n+ } else {\n+ // No workspace open 鈥?ask the user where to put it.\n+ const uri = await vscode.window.showSaveDialog({\n+ saveLabel: t('archiveSaveLabel'),\n+ filters: { Markdown: ['md'] },\n+ defaultUri: vscode.Uri.file(fileName),\n+ });\n+ if (!uri) return null;\n+ target = uri.fsPath;\n+ }\n+\n+ await fs.writeFile(target, md, 'utf8');\n+ return target;\n+}\n+\n+module.exports = { exportSessionToMarkdown, renderSessionMarkdown };","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"1. **瀹夊叏鎬?*: 鍦?`_uniquePath` 鍑芥暟涓紝铏界劧浣跨敤浜?`fs.access` 鏉ユ鏌ユ枃浠舵槸鍚﹀瓨鍦紝浣嗘病鏈夎€冭檻鍒板苟鍙戝啓鍏ョ殑鎯呭喌锛屽彲鑳戒細瀵艰嚧绔炴€佹潯浠躲€傚缓璁湪鐢熸垚鏂囦欢鍚嶆椂鍔犻攣鎴栦娇鐢ㄥ叾浠栨満鍒剁‘淇濆敮涓€鎬с€俓n\n2. **寮傚父澶勭悊**: 鍦?`exportSessionToMarkdown` 鍑芥暟涓紝铏界劧鏈夋姏鍑洪敊璇殑澶勭悊锛屼絾鍦ㄨ皟鐢?`fs.mkdir` 鍜?`fs.writeFile` 鏃讹紝濡傛灉鍙戠敓寮傚父锛屽簲璇ユ湁鏇磋缁嗙殑閿欒澶勭悊鏈哄埗锛屼互渚夸簬璋冭瘯鍜岀敤鎴峰弸濂芥彁绀恒€俓n\n3. **浠g爜椋庢牸**: 浠g爜鏁翠綋椋庢牸杈冧负涓€鑷达紝浣嗗湪鏌愪簺鍦版柟锛堝 `_safeTitle` 鍑芥暟锛夊彲浠ヨ€冭檻澧炲姞娉ㄩ噴浠ユ彁楂樺彲璇绘€э紝灏ゅ叾鏄鍒欒〃杈惧紡鐨勯儴鍒嗐€俓n\n4. **鎬ц兘**: 鍦?`_uniquePath` 鍑芥暟涓紝寰幆鏈€澶氫細鎵ц 1000 娆★紝杩欏彲鑳戒細褰卞搷鎬ц兘锛屽挨鍏舵槸鍦ㄦ枃浠剁郴缁熻緝鎱㈢殑鎯呭喌涓嬨€傚缓璁€冭檻鏇撮珮鏁堢殑鏂囦欢鍚嶇敓鎴愮瓥鐣ャ€俓n\n5. **鍙淮鎶ゆ€?*: 鍑芥暟 `_renderThoughts` 涓 `thoughts` 鐨勫鐞嗚緝涓虹畝鍗曪紝寤鸿澧炲姞瀵硅緭鍏ョ殑楠岃瘉锛屼互闃叉娼滃湪鐨?XSS 鏀诲嚮銆?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887925","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887925"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887925"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887925/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":266,"original_line":202,"side":"RIGHT","author_association":"NONE","original_position":202,"position":266,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887928","pull_request_review_id":4361411325,"id":3301887928,"node_id":"PRRC_kwDOSYeQPs7Ezs-4","diff_hunk":"@@ -7,7 +7,7 @@\n \n const vscode = require('vscode');\n const { randomBytes } = require('crypto');\n-const { t } = require('../utils/i18n');\n+const { t, tf } = require('../utils/i18n');\n \n // 鈹€鈹€鈹€ Orphan tool_calls sanitizer 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€\n // Removes ANY incomplete assistant{tool_calls} group from a message array,","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"寮曞叆浜嗘柊鐨?`tf` 鍑芥暟锛屼絾鏈彁渚涘叾鏉ユ簮鎴栫敤閫旂殑璇存槑銆傝纭繚 `tf` 鍑芥暟鐨勫畨鍏ㄦ€у拰鍔熻兘鎬э紝閬垮厤寮曞叆娼滃湪鐨勫畨鍏ㄦ紡娲炪€?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887928","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887928"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887928"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887928/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":13,"original_line":13,"side":"RIGHT","author_association":"NONE","original_position":8,"position":8,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887931","pull_request_review_id":4361411325,"id":3301887931,"node_id":"PRRC_kwDOSYeQPs7Ezs-7","diff_hunk":"@@ -360,17 +360,94 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: try to export to Markdown first. If that fails (user\n+ // cancelled save dialog, disk error, etc.) we still perform the\n+ // soft-hide so the menu action does *something* visible 鈥?the user\n+ // can re-trigger the export later via the un-archive 鈫?archive\n+ // round-trip if they fix the underlying problem. Errors are surfaced\n+ // through showErrorMessage but never thrown 鈥?the UI gesture must\n+ // not leave the sidebar in an inconsistent state.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ }\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ if (savedPath) this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`archive` 鏂规硶涓紝瀛樺湪鏈鐞嗙殑寮傚父鎯呭喌銆傚鏋?`exportSessionToMarkdown` 澶辫触锛岃櫧鐒舵湁閿欒鎻愮ず锛屼絾 `s.archived` 浠嶇劧浼氳璁剧疆涓?`true`锛岃繖鍙兘瀵艰嚧鐘舵€佷笉涓€鑷淬€傚缓璁湪鎹曡幏寮傚父鍚庯紝娣诲姞閫昏緫浠ョ‘淇?`s.archived` 涓嶄細琚敊璇湴璁剧疆銆?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887931","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887931"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887931"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887931/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":453,"side":"RIGHT","author_association":"NONE","original_position":105,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887935","pull_request_review_id":4361411325,"id":3301887935,"node_id":"PRRC_kwDOSYeQPs7Ezs-_","diff_hunk":"@@ -360,17 +360,94 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: try to export to Markdown first. If that fails (user\n+ // cancelled save dialog, disk error, etc.) we still perform the\n+ // soft-hide so the menu action does *something* visible 鈥?the user\n+ // can re-trigger the export later via the un-archive 鈫?archive\n+ // round-trip if they fix the underlying problem. Errors are surfaced\n+ // through showErrorMessage but never thrown 鈥?the UI gesture must\n+ // not leave the sidebar in an inconsistent state.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ }\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ if (savedPath) this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`_notifyArchived` 鏂规硶涓紝璺緞澶勭悊閫昏緫鍙兘瀛樺湪璺緞绌胯秺椋庨櫓銆傚缓璁湪鐢熸垚鐩稿璺緞鏃讹紝纭繚 `absPath` 鏄湪鍏佽鐨勭洰褰曚笅锛岄伩鍏嶇敤鎴烽€氳繃淇敼璺緞璁块棶涓嶅簲璁块棶鐨勬枃浠躲€?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887935","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887935"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887935"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887935/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":453,"side":"RIGHT","author_association":"NONE","original_position":105,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887941","pull_request_review_id":4361411325,"id":3301887941,"node_id":"PRRC_kwDOSYeQPs7Ezs_F","diff_hunk":"@@ -360,17 +360,94 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: try to export to Markdown first. If that fails (user\n+ // cancelled save dialog, disk error, etc.) we still perform the\n+ // soft-hide so the menu action does *something* visible 鈥?the user\n+ // can re-trigger the export later via the un-archive 鈫?archive\n+ // round-trip if they fix the underlying problem. Errors are surfaced\n+ // through showErrorMessage but never thrown 鈥?the UI gesture must\n+ // not leave the sidebar in an inconsistent state.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ }\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ if (savedPath) this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`archive` 鏂规硶涓紝`this.set(list)` 鍜?`this.postList()` 鐨勮皟鐢ㄥ彲鑳戒細瀵艰嚧鎬ц兘闂锛岀壒鍒槸鍦ㄩ绻佽皟鐢ㄦ椂銆傚缓璁湪鐘舵€佸彉鍖栧悗锛屽悎骞惰繖浜涜皟鐢ㄤ互鍑忓皯涓嶅繀瑕佺殑閲嶅鎿嶄綔銆?,"created_at":"2026-05-26T07:12:09Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887941","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887941"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887941"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887941/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":453,"side":"RIGHT","author_association":"NONE","original_position":105,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905308","pull_request_review_id":4361432703,"id":3301905308,"node_id":"PRRC_kwDOSYeQPs7EzxOc","diff_hunk":"@@ -0,0 +1,202 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/** Strip filesystem-hostile characters and trim length. */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ // Forbidden on Windows: \\ / : * ? \" < > | 鈥?plus control chars.\n+ // Also drop leading dots so we never produce a hidden file.\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ return parts.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trimEnd() + '\\n';","path":"src/chat/archive-export.js","commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`renderSessionMarkdown()` collapses any occurrence of 3+ consecutive newlines across the entire rendered document (`replace(/\\n{3,}/g, ...)`). That will mutate the *verbatim* message text (including inside fenced code blocks or tool outputs), potentially changing archived content and breaking formatting expectations. Prefer only controlling the separators you add between sections, and avoid global whitespace normalization on user/assistant text.\n","created_at":"2026-05-26T07:15:27Z","updated_at":"2026-05-26T07:15:28Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905308","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905308"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905308"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905308/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":null,"original_line":122,"side":"RIGHT","author_association":"NONE","original_position":122,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905345","pull_request_review_id":4361432703,"id":3301905345,"node_id":"PRRC_kwDOSYeQPs7EzxPB","diff_hunk":"@@ -0,0 +1,202 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/** Strip filesystem-hostile characters and trim length. */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ // Forbidden on Windows: \\ / : * ? \" < > | 鈥?plus control chars.\n+ // Also drop leading dots so we never produce a hidden file.\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ return parts.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/** Find a non-colliding path by appending \"-1\", \"-2\", 鈥?before \".md\". */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ let candidate = path.join(dir, stem + ext);\n+ for (let i = 1; i < 1000; i++) {\n+ try {\n+ await fs.access(candidate);\n+ } catch {\n+ return candidate;\n+ }\n+ candidate = path.join(dir, `${stem}-${i}${ext}`);\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ let target;\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ throw new Error('Resolved archive path escapes the workspace root.');\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });\n+ target = await _uniquePath(archiveDir, fileName);\n+ } else {\n+ // No workspace open 鈥?ask the user where to put it.\n+ const uri = await vscode.window.showSaveDialog({\n+ saveLabel: t('archiveSaveLabel'),\n+ filters: { Markdown: ['md'] },\n+ defaultUri: vscode.Uri.file(fileName),\n+ });\n+ if (!uri) return null;\n+ target = uri.fsPath;\n+ }","path":"src/chat/archive-export.js","commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"In the no-workspace fallback, `showSaveDialog` sets `defaultUri: vscode.Uri.file(fileName)`, but `Uri.file()` expects an absolute filesystem path. Passing a relative filename can resolve to an unexpected location (e.g. `/2026...` on *nix or drive-root behavior on Windows) or fail. Consider omitting `defaultUri`, or build an absolute default (e.g. user home / last-used folder) and then append `fileName`.","created_at":"2026-05-26T07:15:28Z","updated_at":"2026-05-26T07:15:28Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905345","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905345"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905345"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905345/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":188,"start_side":"RIGHT","line":null,"original_line":196,"side":"RIGHT","author_association":"NONE","original_position":196,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921606","pull_request_review_id":4361453957,"id":3301921606,"node_id":"PRRC_kwDOSYeQPs7Ez1NG","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks (PR #166 review).\n+ // Instead, each section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive\n+ * create). This closes the TOCTOU window that an `fs.access` pre-check\n+ * would leave open: two concurrent archive clicks in the same second\n+ * could otherwise pick the same name and one would overwrite the other.\n+ * The caller is responsible for writing content into the returned path;\n+ * the empty placeholder file we create is overwritten by `fs.writeFile`.\n+ */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ try {\n+ const handle = await fs.open(candidate, 'wx');\n+ await handle.close();\n+ return candidate;\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);","path":"src/chat/archive-export.js","commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繖涓?hunk 涓紝鎮ㄥ紩鍏ヤ簡涓€涓柊鐨勮矾寰勭敓鎴愰€昏緫锛屼娇鐢ㄤ簡 `fs.open` 鏂规硶鏉ョ‘淇濊矾寰勭殑鍞竴鎬с€傝繖鏄竴涓緢濂界殑鏀硅繘锛屼絾闇€瑕佹敞鎰忕殑鏄紝鎮ㄥ湪澶勭悊寮傚父鏃朵粎妫€鏌ヤ簡 `EEXIST` 閿欒銆傚缓璁偍鍦ㄦ崟鑾峰紓甯告椂锛岃褰曞叾浠栧彲鑳界殑閿欒锛屼互渚夸簬鍚庣画鐨勮皟璇曞拰闂鎺掓煡銆傛澶栵紝鎮ㄥ湪寰幆涓娇鐢ㄤ簡 `i < 1000` 鐨勯檺鍒讹紝杩欏彲鑳戒細瀵艰嚧鍦ㄦ瀬绔儏鍐典笅鏃犳硶鐢熸垚鍞竴璺緞銆傚缓璁€冭檻浣跨敤鏇寸伒娲荤殑鏈哄埗鏉ュ鐞嗚矾寰勫啿绐併€?,"created_at":"2026-05-26T07:18:40Z","updated_at":"2026-05-26T07:18:40Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921606","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921606"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921606"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921606/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":null,"original_line":181,"side":"RIGHT","author_association":"NONE","original_position":181,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921619","pull_request_review_id":4361453957,"id":3301921619,"node_id":"PRRC_kwDOSYeQPs7Ez1NT","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄦ浠g爜娈典腑锛屽紓甯稿鐞嗛€昏緫宸茬粡寰楀埌鏀瑰杽锛岀‘淇濅簡鍦ㄥ鍑哄け璐ユ椂涓嶄細灏嗕細璇濈姸鎬佽缃负宸插綊妗c€傜劧鑰岋紝寤鸿鍦ㄦ崟鑾峰紓甯告椂锛岄櫎浜嗘樉绀洪敊璇俊鎭锛岃繕鍙互鑰冭檻璁板綍閿欒鏃ュ織锛屼互渚垮悗缁帓鏌ャ€傚悓鏃讹紝纭繚 `exportSessionToMarkdown` 鍑芥暟鐨勫疄鐜版槸瀹夊叏鐨勶紝閬垮厤娼滃湪鐨勫懡浠ゆ敞鍏ユ垨璺緞绌胯秺闂銆?,"created_at":"2026-05-26T07:18:40Z","updated_at":"2026-05-26T07:18:40Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921619","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921619"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921619"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921619/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":413,"original_line":413,"side":"RIGHT","author_association":"NONE","original_position":65,"position":65,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953841","pull_request_review_id":4361495340,"id":3301953841,"node_id":"PRRC_kwDOSYeQPs7Ez9Ex","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks (PR #166 review).\n+ // Instead, each section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive\n+ * create). This closes the TOCTOU window that an `fs.access` pre-check\n+ * would leave open: two concurrent archive clicks in the same second\n+ * could otherwise pick the same name and one would overwrite the other.\n+ * The caller is responsible for writing content into the returned path;\n+ * the empty placeholder file we create is overwritten by `fs.writeFile`.\n+ */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ try {\n+ const handle = await fs.open(candidate, 'wx');\n+ await handle.close();\n+ return candidate;\n+ } catch (err) {","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`_uniquePath()` reserves a candidate by creating an empty file (`fs.open(...,'wx')`) and later overwrites it with `fs.writeFile()`. If `writeFile` throws (disk full/permission/etc.), the placeholder file will be left behind, causing future archives to skip the intended name and potentially leaving many empty artifacts. Consider writing through the exclusive handle (or writing to a temp file and `rename`) and cleaning up on error.","created_at":"2026-05-26T07:24:56Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953841","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953841"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953841"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953841/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":182,"original_start_line":158,"start_side":"RIGHT","line":202,"original_line":175,"side":"RIGHT","author_association":"NONE","original_position":175,"position":202,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953905","pull_request_review_id":4361495340,"id":3301953905,"node_id":"PRRC_kwDOSYeQPs7Ez9Fx","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"Several comments reference 鈥淧R #166 review鈥?/ 鈥減er #166 review鈥? but this change is for issue/PR #165. This makes the rationale harder to track later; please update these references to the correct issue/PR (or remove the PR number if it鈥檚 not important).","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953905","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953905"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953905"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953905/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":25,"original_start_line":25,"start_side":"RIGHT","line":33,"original_line":33,"side":"RIGHT","author_association":"NONE","original_position":33,"position":33,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953937","pull_request_review_id":4361495340,"id":3301953937,"node_id":"PRRC_kwDOSYeQPs7Ez9GR","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"The archive flow鈥檚 comments reference 鈥淧R #166 review鈥? but the linked feature is #165. This incorrect reference will confuse future readers when trying to understand the intended contract; please update/remove the PR number in this comment block.","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953937","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953937"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953937"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953937/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":392,"original_start_line":392,"start_side":"RIGHT","line":400,"original_line":400,"side":"RIGHT","author_association":"NONE","original_position":52,"position":52,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953965","pull_request_review_id":4361495340,"id":3301953965,"node_id":"PRRC_kwDOSYeQPs7Ez9Gt","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }","path":"src/chat/session-store.js","commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`_notifyArchived()` reimplements workspace-relative path resolution with a manual loop over `workspaceFolders`. The repo already has `findContainingFolder()` in `src/utils/paths.js` that is multi-root aware and prefers the longest matching root (nested roots). Using it here would avoid duplication and ensure the displayed relative path is correct in nested multi-root setups.","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953965","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953965"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953965"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953965/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":429,"start_side":"RIGHT","line":null,"original_line":440,"side":"RIGHT","author_association":"NONE","original_position":92,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953984","pull_request_review_id":4361495340,"id":3301953984,"node_id":"PRRC_kwDOSYeQPs7Ez9HA","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"PR description says 鈥淓xport failures 鈥?do not block UI state advance鈥? but the implementation returns early on export errors/cancel and explicitly does *not* set `s.archived = true`. Either update the PR description to match the code鈥檚 behavior, or adjust the code if the intended UX is to still soft-hide on failure.","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953984","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953984"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953984"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953984/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":401,"original_start_line":401,"start_side":"RIGHT","line":410,"original_line":410,"side":"RIGHT","author_association":"NONE","original_position":62,"position":62,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301954002","pull_request_review_id":4361495340,"id":3301954002,"node_id":"PRRC_kwDOSYeQPs7Ez9HS","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks (PR #166 review).\n+ // Instead, each section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive\n+ * create). This closes the TOCTOU window that an `fs.access` pre-check\n+ * would leave open: two concurrent archive clicks in the same second\n+ * could otherwise pick the same name and one would overwrite the other.\n+ * The caller is responsible for writing content into the returned path;\n+ * the empty placeholder file we create is overwritten by `fs.writeFile`.\n+ */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ try {\n+ const handle = await fs.open(candidate, 'wx');\n+ await handle.close();\n+ return candidate;\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ let target;\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ throw new Error('Resolved archive path escapes the workspace root.');\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"The thrown error message `Resolved archive path escapes the workspace root.` is surfaced directly to users via `archiveFailed` 鈫?`{msg}` (see SessionStore.archive catch). This will appear in English even in ZH locale and reads like an internal assertion. Consider mapping this case to an i18n鈥檇, user-oriented message (or throwing a typed error code and formatting it at the call site).","created_at":"2026-05-26T07:24:58Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301954002","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301954002"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301954002"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301954002/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":239,"original_start_line":198,"start_side":"RIGHT","line":247,"original_line":205,"side":"RIGHT","author_association":"NONE","original_position":205,"position":247,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985817","pull_request_review_id":4361539251,"id":3301985817,"node_id":"PRRC_kwDOSYeQPs7E0E4Z","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path AND write content atomically through an\n+ * exclusive handle. `fs.open(..., 'wx')` closes the TOCTOU window that an\n+ * `fs.access` pre-check would leave open (two concurrent archive clicks in\n+ * the same second could otherwise pick the same name).\n+ *\n+ * Writing through the exclusive handle 鈥?rather than reserving an empty\n+ * placeholder and then re-opening with `fs.writeFile` 鈥?prevents zero-byte\n+ * residue when the write itself fails (disk full / permission revoked\n+ * mid-write). On error we close the handle and `unlink` the placeholder so\n+ * subsequent archives don't skip the now-orphaned name.\n+ */\n+async function _writeUnique(dir, baseName, content) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ let handle;\n+ try {\n+ handle = await fs.open(candidate, 'wx');\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ try {\n+ await handle.writeFile(content, 'utf8');\n+ await handle.close();\n+ return candidate;\n+ } catch (writeErr) {\n+ // Close best-effort, then remove the empty/partial placeholder.\n+ try { await handle.close(); } catch { /* ignore */ }\n+ try { await fs.unlink(candidate); } catch { /* ignore */ }\n+ throw writeErr;\n+ }\n+ }\n+ // Extremely unlikely (1000 same-second collisions); bail out with a\n+ // timestamped name and a regular write 鈥?still safer than overwriting.\n+ const fallback = path.join(dir, `${stem}-${Date.now()}${ext}`);\n+ await fs.writeFile(fallback, content, { encoding: 'utf8', flag: 'wx' });\n+ return fallback;\n+}\n+\n+/**","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`_writeUnique` 鍑芥暟涓紝铏界劧澶勭悊浜嗗啓鍏ュけ璐ョ殑鎯呭喌锛屼絾鍦?`handle.writeFile(content, 'utf8');` 杩欎竴琛岋紝`handle` 鍙兘浼氭槸鏈畾涔夌殑锛屽缓璁湪浣跨敤鍓嶇‘淇?`handle` 宸叉垚鍔熸墦寮€銆傛澶栵紝`fs.unlink(candidate)` 鐨勮皟鐢ㄥ彲鑳戒細鍥犱负鏂囦欢涓嶅瓨鍦ㄨ€屾姏鍑哄紓甯革紝寤鸿鍦ㄨ皟鐢ㄦ椂鍔犱笂閿欒澶勭悊銆?,"created_at":"2026-05-26T07:29:59Z","updated_at":"2026-05-26T07:29:59Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985817","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985817"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985817"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985817/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":224,"original_line":200,"side":"RIGHT","author_association":"NONE","original_position":200,"position":224,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985826","pull_request_review_id":4361539251,"id":3301985826,"node_id":"PRRC_kwDOSYeQPs7E0E4i","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path AND write content atomically through an\n+ * exclusive handle. `fs.open(..., 'wx')` closes the TOCTOU window that an\n+ * `fs.access` pre-check would leave open (two concurrent archive clicks in\n+ * the same second could otherwise pick the same name).\n+ *\n+ * Writing through the exclusive handle 鈥?rather than reserving an empty\n+ * placeholder and then re-opening with `fs.writeFile` 鈥?prevents zero-byte\n+ * residue when the write itself fails (disk full / permission revoked\n+ * mid-write). On error we close the handle and `unlink` the placeholder so\n+ * subsequent archives don't skip the now-orphaned name.\n+ */\n+async function _writeUnique(dir, baseName, content) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ let handle;\n+ try {\n+ handle = await fs.open(candidate, 'wx');\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ try {\n+ await handle.writeFile(content, 'utf8');\n+ await handle.close();\n+ return candidate;\n+ } catch (writeErr) {\n+ // Close best-effort, then remove the empty/partial placeholder.\n+ try { await handle.close(); } catch { /* ignore */ }\n+ try { await fs.unlink(candidate); } catch { /* ignore */ }\n+ throw writeErr;\n+ }\n+ }\n+ // Extremely unlikely (1000 same-second collisions); bail out with a\n+ // timestamped name and a regular write 鈥?still safer than overwriting.\n+ const fallback = path.join(dir, `${stem}-${Date.now()}${ext}`);\n+ await fs.writeFile(fallback, content, { encoding: 'utf8', flag: 'wx' });\n+ return fallback;\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ // i18n'd, user-facing 鈥?see archiveErrEscape in src/utils/i18n.js.\n+ throw new Error(t('archiveErrEscape'));\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });\n+ return await _writeUnique(archiveDir, fileName, md);\n+ }\n+\n+ // No workspace open 鈥?ask the user where to put it. `Uri.file()` requires\n+ // an absolute path: passing a bare filename resolves to a confusing\n+ // location (drive root on Windows, `/` on POSIX). Anchor the default at\n+ // the user's home so the dialog opens somewhere predictable.\n+ const os = require('os');\n+ const uri = await vscode.window.showSaveDialog({\n+ saveLabel: t('archiveSaveLabel'),\n+ filters: { Markdown: ['md'] },\n+ defaultUri: vscode.Uri.file(path.join(os.homedir(), fileName)),\n+ });\n+ if (!uri) return null;\n+ await fs.writeFile(uri.fsPath, md, 'utf8');\n+ return uri.fsPath;\n+}\n+\n+module.exports = { exportSessionToMarkdown, renderSessionMarkdown };","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`exportSessionToMarkdown` 鍑芥暟涓紝`_writeUnique` 鍑芥暟鐨勮皟鐢ㄥ悗娌℃湁妫€鏌ヨ繑鍥炲€兼槸鍚︽湁鏁堬紝寤鸿鍦ㄥ啓鍏ユ枃浠跺悗妫€鏌ヨ繑鍥炲€硷紝纭繚鏂囦欢鎴愬姛鍐欏叆銆傚悓鏃讹紝`await fs.writeFile(uri.fsPath, md, 'utf8');` 杩欎竴琛屾病鏈夊鐞嗗彲鑳界殑寮傚父锛屽缓璁姞涓婂紓甯稿鐞嗛€昏緫銆?,"created_at":"2026-05-26T07:29:59Z","updated_at":"2026-05-26T07:29:59Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985826","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985826"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985826"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985826/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":266,"original_line":240,"side":"RIGHT","author_association":"NONE","original_position":240,"position":266,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985832","pull_request_review_id":4361539251,"id":3301985832,"node_id":"PRRC_kwDOSYeQPs7E0E4o","diff_hunk":"@@ -360,17 +360,91 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk 鈥?otherwise setting\n+ // `archived = true` on export failure (or on a user-cancelled save\n+ // dialog) would leave the session invisible in the list while nothing\n+ // was actually archived. Contract:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path. Delegates the relativisation to\n+ * `findContainingFolder` so multi-root + nested-root cases stay correct.\n+ */\n+ _notifyArchived(absPath) {\n+ const { findContainingFolder } = require('../utils/paths');\n+ const hit = findContainingFolder(absPath);\n+ const display = hit ? hit.rel : absPath;\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繖涓€娈典唬鐮佷腑锛屼娇鐢ㄤ簡 `findContainingFolder` 鍑芥暟鏉ユ浛浠e師鏈夌殑璺緞澶勭悊閫昏緫銆傝纭繚 `findContainingFolder` 鍑芥暟鐨勫疄鐜版槸瀹夊叏鐨勶紝鐗瑰埆鏄湪澶勭悊鐢ㄦ埛杈撳叆鐨勮矾寰勬椂锛岄伩鍏嶈矾寰勭┛瓒婄瓑瀹夊叏婕忔礊銆傛澶栵紝寤鸿瀵?`hit` 缁撴灉杩涜绌哄€兼鏌ワ紝浠ラ槻姝㈡綔鍦ㄧ殑绌烘寚閽堝紓甯搞€?,"created_at":"2026-05-26T07:29:59Z","updated_at":"2026-05-26T07:29:59Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985832","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985832"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985832"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985832/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":436,"original_line":436,"side":"RIGHT","author_association":"NONE","original_position":88,"position":88,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019809","pull_request_review_id":4361579830,"id":3302019809,"node_id":"PRRC_kwDOSYeQPs7E0NLh","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"In multi-root workspaces, cancelling the folder picker currently returns null from `_pickWorkspaceRoot()`, which makes `exportSessionToMarkdown()` fall back to `showSaveDialog()` as if no workspace were open. This breaks the expected cancel semantics and can unexpectedly allow saving outside the workspace. Consider distinguishing 鈥渘o workspace鈥?vs 鈥渦ser cancelled picker鈥?(e.g., return a sentinel/throw a cancellation error) so cancellation cleanly aborts the archive export (return null) without opening the save dialog.","created_at":"2026-05-26T07:35:04Z","updated_at":"2026-05-26T07:35:06Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019809","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019809"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019809"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019809/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":168,"original_start_line":144,"start_side":"RIGHT","line":180,"original_line":156,"side":"RIGHT","author_association":"NONE","original_position":156,"position":180,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019863","pull_request_review_id":4361579830,"id":3302019863,"node_id":"PRRC_kwDOSYeQPs7E0NMX","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`renderSessionMarkdown()` uses `session.title` directly in the `# ...` heading. Session titles can contain newlines/control characters (e.g., derived from the first user message or rename), which can break the Markdown structure. Consider normalizing the title for display (e.g., collapse whitespace/newlines to a single space) before injecting it into the heading.","created_at":"2026-05-26T07:35:05Z","updated_at":"2026-05-26T07:35:06Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019863","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019863"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019863"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019863/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":122,"original_start_line":109,"start_side":"RIGHT","line":127,"original_line":113,"side":"RIGHT","author_association":"NONE","original_position":113,"position":127,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019894","pull_request_review_id":4361579830,"id":3302019894,"node_id":"PRRC_kwDOSYeQPs7E0NM2","diff_hunk":"@@ -360,17 +360,91 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk 鈥?otherwise setting\n+ // `archived = true` on export failure (or on a user-cancelled save\n+ // dialog) would leave the session invisible in the list while nothing\n+ // was actually archived. Contract:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path. Delegates the relativisation to\n+ * `findContainingFolder` so multi-root + nested-root cases stay correct.\n+ */\n+ _notifyArchived(absPath) {\n+ const { findContainingFolder } = require('../utils/paths');\n+ const hit = findContainingFolder(absPath);\n+ const display = hit ? hit.rel : absPath;\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }","path":"src/chat/session-store.js","commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"The 鈥淥pen File鈥?/ 鈥淩eveal in Explorer鈥?handlers call `showTextDocument()` / `executeCommand()` without awaiting or handling rejections. If either fails (missing file, permission issues, etc.) this can surface as an unhandled promise rejection in the extension host. Consider using `await` inside an async handler with try/catch, or attach `.then(() => {}, () => {})` / `.catch(...)` to explicitly swallow/report errors.\n","created_at":"2026-05-26T07:35:05Z","updated_at":"2026-05-26T07:35:06Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019894","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019894"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019894"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019894/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":439,"start_side":"RIGHT","line":null,"original_line":446,"side":"RIGHT","author_association":"NONE","original_position":98,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044180","pull_request_review_id":4361608530,"id":3302044180,"node_id":"PRRC_kwDOSYeQPs7E0TIU","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Collapse newlines/tabs/control chars in a session title down to a single\n+ * space before it is injected into a Markdown `# ...` heading. Without this,\n+ * a title that contains \"\\n\" (e.g. taken from the first user message or a\n+ * pasted rename) would split the heading and break the document structure.\n+ */\n+function _safeHeadingTitle(raw) {\n+ return String(raw || '')\n+ .replace(/[\\^@-\\^_\\u007f]+/g, ' ')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const heading = _safeHeadingTitle(session.title) || t('sessionUntitled');\n+ const parts = [head, `# ${heading}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Sentinel returned by `_pickWorkspaceRoot` when the user explicitly\n+ * dismissed the multi-root workspace folder picker. We MUST distinguish this\n+ * from the \"no workspace open\" case (returns `null`): in the cancel case we\n+ * should abort the archive cleanly, not silently fall back to a save dialog\n+ * (which would happily let the user save outside any workspace).\n+ */\n+const PICK_CANCELLED = Symbol('pick-cancelled');\n+\n+/**\n+ * Pick the target workspace folder.\n+ * - 0 folders 鈫?returns `null` (caller falls back to save dialog).\n+ * - 1 folder 鈫?returns its fsPath.\n+ * - 2+ 鈫?returns the picked fsPath, or `PICK_CANCELLED` if the\n+ * user dismissed the picker.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : PICK_CANCELLED;\n+}\n+\n+/**","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繑鍥炲€间腑寮曞叆浜?`PICK_CANCELLED` 绗﹀彿锛岃櫧鐒跺彲浠ュ府鍔╁尯鍒嗙敤鎴峰彇娑堥€夋嫨鐨勬儏鍐碉紝浣嗛渶瑕佺‘淇濊皟鐢ㄦ鍑芥暟鐨勫湴鏂归兘鑳芥纭鐞嗚繖涓€杩斿洖鍊硷紝閬垮厤鍑虹幇鏈鐞嗙殑寮傚父鎴栭€昏緫閿欒銆傚缓璁湪鐩稿叧璋冪敤澶勬坊鍔犵浉搴旂殑澶勭悊閫昏緫銆?,"created_at":"2026-05-26T07:39:41Z","updated_at":"2026-05-26T07:39:41Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044180","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044180"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044180"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044180/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":182,"original_line":182,"side":"RIGHT","author_association":"NONE","original_position":182,"position":182,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044186","pull_request_review_id":4361608530,"id":3302044186,"node_id":"PRRC_kwDOSYeQPs7E0TIa","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Collapse newlines/tabs/control chars in a session title down to a single\n+ * space before it is injected into a Markdown `# ...` heading. Without this,\n+ * a title that contains \"\\n\" (e.g. taken from the first user message or a\n+ * pasted rename) would split the heading and break the document structure.\n+ */\n+function _safeHeadingTitle(raw) {\n+ return String(raw || '')\n+ .replace(/[\\^@-\\^_\\u007f]+/g, ' ')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const heading = _safeHeadingTitle(session.title) || t('sessionUntitled');\n+ const parts = [head, `# ${heading}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Sentinel returned by `_pickWorkspaceRoot` when the user explicitly\n+ * dismissed the multi-root workspace folder picker. We MUST distinguish this\n+ * from the \"no workspace open\" case (returns `null`): in the cancel case we\n+ * should abort the archive cleanly, not silently fall back to a save dialog\n+ * (which would happily let the user save outside any workspace).\n+ */\n+const PICK_CANCELLED = Symbol('pick-cancelled');\n+\n+/**\n+ * Pick the target workspace folder.\n+ * - 0 folders 鈫?returns `null` (caller falls back to save dialog).\n+ * - 1 folder 鈫?returns its fsPath.\n+ * - 2+ 鈫?returns the picked fsPath, or `PICK_CANCELLED` if the\n+ * user dismissed the picker.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : PICK_CANCELLED;\n+}\n+\n+/**\n+ * Reserve a non-colliding path AND write content atomically through an\n+ * exclusive handle. `fs.open(..., 'wx')` closes the TOCTOU window that an\n+ * `fs.access` pre-check would leave open (two concurrent archive clicks in\n+ * the same second could otherwise pick the same name).\n+ *\n+ * Writing through the exclusive handle 鈥?rather than reserving an empty\n+ * placeholder and then re-opening with `fs.writeFile` 鈥?prevents zero-byte\n+ * residue when the write itself fails (disk full / permission revoked\n+ * mid-write). On error we close the handle and `unlink` the placeholder so\n+ * subsequent archives don't skip the now-orphaned name.\n+ */\n+async function _writeUnique(dir, baseName, content) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ let handle;\n+ try {\n+ handle = await fs.open(candidate, 'wx');\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ try {\n+ await handle.writeFile(content, 'utf8');\n+ await handle.close();\n+ return candidate;\n+ } catch (writeErr) {\n+ // Close best-effort, then remove the empty/partial placeholder.\n+ try { await handle.close(); } catch { /* ignore */ }\n+ try { await fs.unlink(candidate); } catch { /* ignore */ }\n+ throw writeErr;\n+ }\n+ }\n+ // Extremely unlikely (1000 same-second collisions); bail out with a\n+ // timestamped name and a regular write 鈥?still safer than overwriting.\n+ const fallback = path.join(dir, `${stem}-${Date.now()}${ext}`);\n+ await fs.writeFile(fallback, content, { encoding: 'utf8', flag: 'wx' });\n+ return fallback;\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or `null` if:\n+ * - the user cancelled the multi-root workspace folder picker, OR\n+ * - the user cancelled the save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ if (root === PICK_CANCELLED) return null; // user dismissed the picker\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄥ鐞嗙敤鎴峰彇娑堢殑鎯呭喌鏃讹紝杩斿洖 `null` 鏄悎鐞嗙殑锛屼絾闇€瑕佺‘淇濊皟鐢ㄦ鍑芥暟鐨勫湴鏂硅兘澶熸纭鐞?`null` 杩斿洖鍊硷紝閬垮厤鍑虹幇绌烘寚閽堝紓甯搞€傚缓璁湪璋冪敤 `exportSessionToMarkdown` 鐨勫湴鏂规坊鍔犲杩斿洖鍊肩殑妫€鏌ャ€?,"created_at":"2026-05-26T07:39:41Z","updated_at":"2026-05-26T07:39:41Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044186","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044186"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044186"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044186/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":239,"original_line":239,"side":"RIGHT","author_association":"NONE","original_position":239,"position":239,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044192","pull_request_review_id":4361608530,"id":3302044192,"node_id":"PRRC_kwDOSYeQPs7E0TIg","diff_hunk":"@@ -360,17 +360,103 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk 鈥?otherwise setting\n+ // `archived = true` on export failure (or on a user-cancelled save\n+ // dialog) would leave the session invisible in the list while nothing\n+ // was actually archived. Contract:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path. Delegates the relativisation to\n+ * `findContainingFolder` so multi-root + nested-root cases stay correct.\n+ */\n+ _notifyArchived(absPath) {\n+ const { findContainingFolder } = require('../utils/paths');\n+ const hit = findContainingFolder(absPath);\n+ const display = hit ? hit.rel : absPath;\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ // Wrap both the toast .then() and the VS Code calls inside it so that\n+ // a missing/deleted file or permission error after the user clicks\n+ // does not surface as an unhandled promise rejection. We swallow the\n+ // error after surfacing it as a non-fatal toast 鈥?failure to *open*\n+ // the archive is not the archive itself failing.\n+ Promise.resolve(\n+ vscode.window.showInformationMessage(\n+ tf('archiveSaved', { path: display }), openLabel, revealLabel,\n+ ),\n+ ).then(async (choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ try {\n+ if (choice === openLabel) {\n+ await vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ await vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showWarningMessage(tf('archiveOpenFailed', { msg }));\n+ }\n+ }).catch(() => { /* showInformationMessage itself never rejects, but be defensive */ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繖涓唬鐮佹涓紝澧炲姞浜嗗鏈鐞嗗紓甯哥殑鎹曡幏锛岃繖鏄竴椤硅壇濂界殑瀹炶返銆傜劧鑰岋紝浠嶉渶娉ㄦ剰浠ヤ笅鍑犵偣锛歕n\n1. **瀹夊叏鎬?*锛氬湪澶勭悊鐢ㄦ埛杈撳叆鎴栨枃浠惰矾寰勬椂锛岀‘淇濇病鏈夎矾寰勭┛瓒婃垨鍛戒护娉ㄥ叆鐨勯闄┿€傝櫧鐒跺湪姝ゆ浠g爜涓病鏈夌洿鎺ョ殑椋庨櫓锛屼絾寤鸿鍦ㄥ叾浠栧湴鏂瑰 `absPath` 杩涜楠岃瘉锛屼互纭繚鍏跺畨鍏ㄦ€с€俓n\n2. **鎬ц兘**锛氫娇鐢?`Promise.resolve()` 鍖呰9 `showInformationMessage` 鐨勮皟鐢ㄦ槸澶氫綑鐨勶紝鍥犱负璇ユ柟娉曟湰韬凡缁忚繑鍥炰竴涓?Promise銆傚彲浠ョ洿鎺ヨ皟鐢?`vscode.window.showInformationMessage(...)`銆俓n\n3. **鍙淮鎶ゆ€?*锛氬缓璁湪鎹曡幏寮傚父鏃讹紝璁板綍璇︾粏鐨勯敊璇俊鎭紝浠ヤ究鍚庣画璋冭瘯銆傚綋鍓嶇殑瀹炵幇浠呮樉绀轰簡閿欒娑堟伅锛屼絾娌℃湁璁板綍鍏蜂綋鐨勯敊璇爢鏍堜俊鎭€俓n\n4. **浠g爜椋庢牸**锛氱‘淇濋伒寰」鐩殑浠g爜椋庢牸锛屼緥濡傜缉杩涖€佺┖琛岀瓑锛屼互淇濇寔浠g爜鐨勪竴鑷存€с€?,"created_at":"2026-05-26T07:39:41Z","updated_at":"2026-05-26T07:39:41Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044192","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044192"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044192"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044192/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":462,"side":"RIGHT","author_association":"NONE","original_position":114,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067336","pull_request_review_id":4361638020,"id":3302067336,"node_id":"PRRC_kwDOSYeQPs7E0YyI","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"_safeTitle() doesn鈥檛 strip trailing spaces/dots from the generated filename stem. On Windows, paths ending with a space or dot are invalid/normalized by Win32 APIs, which can cause write failures or unexpected collisions. Consider trimming trailing `[ .]+` (and optionally collapsing them) after sanitization so the resulting archive filename is always Windows-safe.\n","created_at":"2026-05-26T07:43:49Z","updated_at":"2026-05-26T07:43:50Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067336","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067336"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067336"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067336/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":42,"original_start_line":42,"start_side":"RIGHT","line":43,"original_line":43,"side":"RIGHT","author_association":"NONE","original_position":43,"position":43,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067394","pull_request_review_id":4361638020,"id":3302067394,"node_id":"PRRC_kwDOSYeQPs7E0YzC","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"_frontmatter() only quotes strings containing `:`/`#`/newlines/leading whitespace. YAML will still coerce many unquoted scalars (e.g. `true`, `2026-05-26`, `123`, `null`) into non-string types, which can make the exported metadata incorrect or inconsistent depending on the session title/model/etc. Consider always quoting string values (or at least forcing quoting for any non-numeric fields) to keep frontmatter stable and unambiguous.\n","created_at":"2026-05-26T07:43:50Z","updated_at":"2026-05-26T07:43:50Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067394","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067394"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067394"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067394/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":65,"original_start_line":65,"start_side":"RIGHT","line":68,"original_line":68,"side":"RIGHT","author_association":"NONE","original_position":68,"position":68,"subject_type":"line"}] diff --git a/.tmp-pr.json b/.tmp-pr.json new file mode 100644 index 0000000..95e47b1 --- /dev/null +++ b/.tmp-pr.json @@ -0,0 +1 @@ +{"comments":[],"headRefOid":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","reviews":[{"id":"PRR_kwDOSYeQPs8AAAABA_Xa_Q","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:12:08Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb"}},{"id":"PRR_kwDOSYeQPs8AAAABA_Yufw","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nThis 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.\n\n**Changes:**\n- Added a new session 鈫?Markdown exporter (frontmatter + role sections + reasoning in `<details>`).\n- Updated `SessionStore.archive(id)` to export-then-hide and added an archive toast with Open/Reveal actions.\n- Added new i18n keys (EN/ZH) for archive UI strings.\n\n### Reviewed changes\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.\n\n| File | Description |\n| ---- | ----------- |\n| src/chat/archive-export.js | New module to render and persist a session as a Markdown archive under the workspace (or via save dialog). |\n| src/chat/session-store.js | Archive now triggers export + toast, while un-archive remains a pure visibility toggle. |\n| src/utils/i18n.js | Adds localized strings for archive export dialogs/toasts and role headers. 缁撹锛氶渶淇敼 |\n\n\n\n\n","submittedAt":"2026-05-26T07:15:28Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb"}},{"id":"PRR_kwDOSYeQPs8AAAABA_aBhQ","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:18:39Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc"}},{"id":"PRR_kwDOSYeQPs8AAAABA_cjLA","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.\n\n\n\n\n","submittedAt":"2026-05-26T07:24:58Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc"}},{"id":"PRR_kwDOSYeQPs8AAAABA_fOsw","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:29:59Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"34ed95903a593741b31af6e296e85ac8b9ad2fa9"}},{"id":"PRR_kwDOSYeQPs8AAAABA_htNg","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.\n\n\n\n\n","submittedAt":"2026-05-26T07:35:05Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"34ed95903a593741b31af6e296e85ac8b9ad2fa9"}},{"id":"PRR_kwDOSYeQPs8AAAABA_jdUg","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:39:41Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287"}},{"id":"PRR_kwDOSYeQPs8AAAABA_lQhA","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.\n\n\n\n\n","submittedAt":"2026-05-26T07:43:50Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287"}}]} diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js index 7f86bc4..c007cfa 100644 --- a/src/chat/archive-export.js +++ b/src/chat/archive-export.js @@ -39,8 +39,14 @@ function _safeTitle(raw) { .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') .replace(/^\.+/, '_') .replace(/\s+/g, ' ') - .trim(); - return (cleaned || 'untitled').slice(0, 60); + .trim() + // Windows: Win32 APIs strip/normalise trailing spaces and dots from + // path components, which turns "foo ." / "foo " into "foo" silently + // — or rejects the write outright. Strip them ourselves so the + // on-disk name matches what we report back to the user and the + // collision counter in _writeUnique can’t be defeated. + .replace(/[. ]+$/, ''); + return (cleaned || 'untitled').slice(0, 60).replace(/[. ]+$/, '') || 'untitled'; } /** "20260526-143012" — local time, fixed-width, sortable. */ @@ -62,10 +68,17 @@ function _frontmatter(meta) { const lines = ['---']; for (const [k, v] of Object.entries(meta)) { if (v == null || v === '') continue; - // 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}`); + // Always quote string values: bare YAML scalars like `true`, + // `2026-05-26`, `null`, `123` would be coerced to bool/date/null/ + // number by any YAML parser, silently corrupting the exported + // metadata if a session title or model name happens to match one + // of those forms. Numbers stay bare because their identity is + // preserved either way and bare numerics read more naturally. + if (typeof v === 'number' && Number.isFinite(v)) { + lines.push(`${k}: ${v}`); + } else { + lines.push(`${k}: ${JSON.stringify(String(v))}`); + } } lines.push('---', ''); return lines.join('\n'); diff --git a/src/chat/session-store.js b/src/chat/session-store.js index 22342ef..c0851f2 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -434,15 +434,12 @@ class SessionStore { const openLabel = t('archiveOpenFile'); const revealLabel = t('archiveRevealInOS'); - // Wrap both the toast .then() and the VS Code calls inside it so that - // a missing/deleted file or permission error after the user clicks - // does not surface as an unhandled promise rejection. We swallow the - // error after surfacing it as a non-fatal toast — failure to *open* - // the archive is not the archive itself failing. - Promise.resolve( - vscode.window.showInformationMessage( - tf('archiveSaved', { path: display }), openLabel, revealLabel, - ), + // `showInformationMessage` already returns a thenable, so we can + // chain `.then()` directly. We still attach `.catch()` defensively + // because the action handler itself is async and may reject if a + // command call throws synchronously before reaching our try/catch. + vscode.window.showInformationMessage( + tf('archiveSaved', { path: display }), openLabel, revealLabel, ).then(async (choice) => { if (!choice) return; const uri = vscode.Uri.file(absPath); @@ -456,7 +453,7 @@ class SessionStore { const msg = (err && err.message) || String(err); vscode.window.showWarningMessage(tf('archiveOpenFailed', { msg })); } - }).catch(() => { /* showInformationMessage itself never rejects, but be defensive */ }); + }, () => { /* swallow toast-promise rejection, if any */ }); } // ─── Auto-naming ──────────────────────────────────────────────────────── From 39e9e050a2997186f9f713e971f0e1920460f769 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 17:51:07 +1000 Subject: [PATCH 06/10] chore: drop accidentally-committed .tmp-*.json scratch files and ignore them --- .gitignore | 3 +++ .tmp-c.json | 1 - .tmp-pr.json | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 .tmp-c.json delete mode 100644 .tmp-pr.json diff --git a/.gitignore b/.gitignore index d6b046f..da5cd92 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Build output out/ + +# scratch files used by AI review triage +.tmp-*.json diff --git a/.tmp-c.json b/.tmp-c.json deleted file mode 100644 index 8f80485..0000000 --- a/.tmp-c.json +++ /dev/null @@ -1 +0,0 @@ -[{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887925","pull_request_review_id":4361411325,"id":3301887925,"node_id":"PRRC_kwDOSYeQPs7Ezs-1","diff_hunk":"@@ -0,0 +1,202 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/** Strip filesystem-hostile characters and trim length. */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ // Forbidden on Windows: \\ / : * ? \" < > | 鈥?plus control chars.\n+ // Also drop leading dots so we never produce a hidden file.\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ return parts.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/** Find a non-colliding path by appending \"-1\", \"-2\", 鈥?before \".md\". */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ let candidate = path.join(dir, stem + ext);\n+ for (let i = 1; i < 1000; i++) {\n+ try {\n+ await fs.access(candidate);\n+ } catch {\n+ return candidate;\n+ }\n+ candidate = path.join(dir, `${stem}-${i}${ext}`);\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ let target;\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ throw new Error('Resolved archive path escapes the workspace root.');\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });\n+ target = await _uniquePath(archiveDir, fileName);\n+ } else {\n+ // No workspace open 鈥?ask the user where to put it.\n+ const uri = await vscode.window.showSaveDialog({\n+ saveLabel: t('archiveSaveLabel'),\n+ filters: { Markdown: ['md'] },\n+ defaultUri: vscode.Uri.file(fileName),\n+ });\n+ if (!uri) return null;\n+ target = uri.fsPath;\n+ }\n+\n+ await fs.writeFile(target, md, 'utf8');\n+ return target;\n+}\n+\n+module.exports = { exportSessionToMarkdown, renderSessionMarkdown };","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"1. **瀹夊叏鎬?*: 鍦?`_uniquePath` 鍑芥暟涓紝铏界劧浣跨敤浜?`fs.access` 鏉ユ鏌ユ枃浠舵槸鍚﹀瓨鍦紝浣嗘病鏈夎€冭檻鍒板苟鍙戝啓鍏ョ殑鎯呭喌锛屽彲鑳戒細瀵艰嚧绔炴€佹潯浠躲€傚缓璁湪鐢熸垚鏂囦欢鍚嶆椂鍔犻攣鎴栦娇鐢ㄥ叾浠栨満鍒剁‘淇濆敮涓€鎬с€俓n\n2. **寮傚父澶勭悊**: 鍦?`exportSessionToMarkdown` 鍑芥暟涓紝铏界劧鏈夋姏鍑洪敊璇殑澶勭悊锛屼絾鍦ㄨ皟鐢?`fs.mkdir` 鍜?`fs.writeFile` 鏃讹紝濡傛灉鍙戠敓寮傚父锛屽簲璇ユ湁鏇磋缁嗙殑閿欒澶勭悊鏈哄埗锛屼互渚夸簬璋冭瘯鍜岀敤鎴峰弸濂芥彁绀恒€俓n\n3. **浠g爜椋庢牸**: 浠g爜鏁翠綋椋庢牸杈冧负涓€鑷达紝浣嗗湪鏌愪簺鍦版柟锛堝 `_safeTitle` 鍑芥暟锛夊彲浠ヨ€冭檻澧炲姞娉ㄩ噴浠ユ彁楂樺彲璇绘€э紝灏ゅ叾鏄鍒欒〃杈惧紡鐨勯儴鍒嗐€俓n\n4. **鎬ц兘**: 鍦?`_uniquePath` 鍑芥暟涓紝寰幆鏈€澶氫細鎵ц 1000 娆★紝杩欏彲鑳戒細褰卞搷鎬ц兘锛屽挨鍏舵槸鍦ㄦ枃浠剁郴缁熻緝鎱㈢殑鎯呭喌涓嬨€傚缓璁€冭檻鏇撮珮鏁堢殑鏂囦欢鍚嶇敓鎴愮瓥鐣ャ€俓n\n5. **鍙淮鎶ゆ€?*: 鍑芥暟 `_renderThoughts` 涓 `thoughts` 鐨勫鐞嗚緝涓虹畝鍗曪紝寤鸿澧炲姞瀵硅緭鍏ョ殑楠岃瘉锛屼互闃叉娼滃湪鐨?XSS 鏀诲嚮銆?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887925","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887925"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887925"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887925/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":266,"original_line":202,"side":"RIGHT","author_association":"NONE","original_position":202,"position":266,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887928","pull_request_review_id":4361411325,"id":3301887928,"node_id":"PRRC_kwDOSYeQPs7Ezs-4","diff_hunk":"@@ -7,7 +7,7 @@\n \n const vscode = require('vscode');\n const { randomBytes } = require('crypto');\n-const { t } = require('../utils/i18n');\n+const { t, tf } = require('../utils/i18n');\n \n // 鈹€鈹€鈹€ Orphan tool_calls sanitizer 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€\n // Removes ANY incomplete assistant{tool_calls} group from a message array,","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"寮曞叆浜嗘柊鐨?`tf` 鍑芥暟锛屼絾鏈彁渚涘叾鏉ユ簮鎴栫敤閫旂殑璇存槑銆傝纭繚 `tf` 鍑芥暟鐨勫畨鍏ㄦ€у拰鍔熻兘鎬э紝閬垮厤寮曞叆娼滃湪鐨勫畨鍏ㄦ紡娲炪€?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887928","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887928"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887928"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887928/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":13,"original_line":13,"side":"RIGHT","author_association":"NONE","original_position":8,"position":8,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887931","pull_request_review_id":4361411325,"id":3301887931,"node_id":"PRRC_kwDOSYeQPs7Ezs-7","diff_hunk":"@@ -360,17 +360,94 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: try to export to Markdown first. If that fails (user\n+ // cancelled save dialog, disk error, etc.) we still perform the\n+ // soft-hide so the menu action does *something* visible 鈥?the user\n+ // can re-trigger the export later via the un-archive 鈫?archive\n+ // round-trip if they fix the underlying problem. Errors are surfaced\n+ // through showErrorMessage but never thrown 鈥?the UI gesture must\n+ // not leave the sidebar in an inconsistent state.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ }\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ if (savedPath) this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`archive` 鏂规硶涓紝瀛樺湪鏈鐞嗙殑寮傚父鎯呭喌銆傚鏋?`exportSessionToMarkdown` 澶辫触锛岃櫧鐒舵湁閿欒鎻愮ず锛屼絾 `s.archived` 浠嶇劧浼氳璁剧疆涓?`true`锛岃繖鍙兘瀵艰嚧鐘舵€佷笉涓€鑷淬€傚缓璁湪鎹曡幏寮傚父鍚庯紝娣诲姞閫昏緫浠ョ‘淇?`s.archived` 涓嶄細琚敊璇湴璁剧疆銆?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887931","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887931"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887931"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887931/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":453,"side":"RIGHT","author_association":"NONE","original_position":105,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887935","pull_request_review_id":4361411325,"id":3301887935,"node_id":"PRRC_kwDOSYeQPs7Ezs-_","diff_hunk":"@@ -360,17 +360,94 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: try to export to Markdown first. If that fails (user\n+ // cancelled save dialog, disk error, etc.) we still perform the\n+ // soft-hide so the menu action does *something* visible 鈥?the user\n+ // can re-trigger the export later via the un-archive 鈫?archive\n+ // round-trip if they fix the underlying problem. Errors are surfaced\n+ // through showErrorMessage but never thrown 鈥?the UI gesture must\n+ // not leave the sidebar in an inconsistent state.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ }\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ if (savedPath) this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`_notifyArchived` 鏂规硶涓紝璺緞澶勭悊閫昏緫鍙兘瀛樺湪璺緞绌胯秺椋庨櫓銆傚缓璁湪鐢熸垚鐩稿璺緞鏃讹紝纭繚 `absPath` 鏄湪鍏佽鐨勭洰褰曚笅锛岄伩鍏嶇敤鎴烽€氳繃淇敼璺緞璁块棶涓嶅簲璁块棶鐨勬枃浠躲€?,"created_at":"2026-05-26T07:12:08Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887935","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887935"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887935"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887935/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":453,"side":"RIGHT","author_association":"NONE","original_position":105,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887941","pull_request_review_id":4361411325,"id":3301887941,"node_id":"PRRC_kwDOSYeQPs7Ezs_F","diff_hunk":"@@ -360,17 +360,94 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: try to export to Markdown first. If that fails (user\n+ // cancelled save dialog, disk error, etc.) we still perform the\n+ // soft-hide so the menu action does *something* visible 鈥?the user\n+ // can re-trigger the export later via the un-archive 鈫?archive\n+ // round-trip if they fix the underlying problem. Errors are surfaced\n+ // through showErrorMessage but never thrown 鈥?the UI gesture must\n+ // not leave the sidebar in an inconsistent state.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ }\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ if (savedPath) this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`archive` 鏂规硶涓紝`this.set(list)` 鍜?`this.postList()` 鐨勮皟鐢ㄥ彲鑳戒細瀵艰嚧鎬ц兘闂锛岀壒鍒槸鍦ㄩ绻佽皟鐢ㄦ椂銆傚缓璁湪鐘舵€佸彉鍖栧悗锛屽悎骞惰繖浜涜皟鐢ㄤ互鍑忓皯涓嶅繀瑕佺殑閲嶅鎿嶄綔銆?,"created_at":"2026-05-26T07:12:09Z","updated_at":"2026-05-26T07:12:09Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887941","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887941"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301887941"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301887941/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":453,"side":"RIGHT","author_association":"NONE","original_position":105,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905308","pull_request_review_id":4361432703,"id":3301905308,"node_id":"PRRC_kwDOSYeQPs7EzxOc","diff_hunk":"@@ -0,0 +1,202 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/** Strip filesystem-hostile characters and trim length. */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ // Forbidden on Windows: \\ / : * ? \" < > | 鈥?plus control chars.\n+ // Also drop leading dots so we never produce a hidden file.\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ return parts.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trimEnd() + '\\n';","path":"src/chat/archive-export.js","commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`renderSessionMarkdown()` collapses any occurrence of 3+ consecutive newlines across the entire rendered document (`replace(/\\n{3,}/g, ...)`). That will mutate the *verbatim* message text (including inside fenced code blocks or tool outputs), potentially changing archived content and breaking formatting expectations. Prefer only controlling the separators you add between sections, and avoid global whitespace normalization on user/assistant text.\n","created_at":"2026-05-26T07:15:27Z","updated_at":"2026-05-26T07:15:28Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905308","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905308"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905308"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905308/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":null,"original_line":122,"side":"RIGHT","author_association":"NONE","original_position":122,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905345","pull_request_review_id":4361432703,"id":3301905345,"node_id":"PRRC_kwDOSYeQPs7EzxPB","diff_hunk":"@@ -0,0 +1,202 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/** Strip filesystem-hostile characters and trim length. */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ // Forbidden on Windows: \\ / : * ? \" < > | 鈥?plus control chars.\n+ // Also drop leading dots so we never produce a hidden file.\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ return parts.join('\\n').replace(/\\n{3,}/g, '\\n\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/** Find a non-colliding path by appending \"-1\", \"-2\", 鈥?before \".md\". */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ let candidate = path.join(dir, stem + ext);\n+ for (let i = 1; i < 1000; i++) {\n+ try {\n+ await fs.access(candidate);\n+ } catch {\n+ return candidate;\n+ }\n+ candidate = path.join(dir, `${stem}-${i}${ext}`);\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ let target;\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ throw new Error('Resolved archive path escapes the workspace root.');\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });\n+ target = await _uniquePath(archiveDir, fileName);\n+ } else {\n+ // No workspace open 鈥?ask the user where to put it.\n+ const uri = await vscode.window.showSaveDialog({\n+ saveLabel: t('archiveSaveLabel'),\n+ filters: { Markdown: ['md'] },\n+ defaultUri: vscode.Uri.file(fileName),\n+ });\n+ if (!uri) return null;\n+ target = uri.fsPath;\n+ }","path":"src/chat/archive-export.js","commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","original_commit_id":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"In the no-workspace fallback, `showSaveDialog` sets `defaultUri: vscode.Uri.file(fileName)`, but `Uri.file()` expects an absolute filesystem path. Passing a relative filename can resolve to an unexpected location (e.g. `/2026...` on *nix or drive-root behavior on Windows) or fail. Consider omitting `defaultUri`, or build an absolute default (e.g. user home / last-used folder) and then append `fileName`.","created_at":"2026-05-26T07:15:28Z","updated_at":"2026-05-26T07:15:28Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905345","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905345"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301905345"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301905345/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":188,"start_side":"RIGHT","line":null,"original_line":196,"side":"RIGHT","author_association":"NONE","original_position":196,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921606","pull_request_review_id":4361453957,"id":3301921606,"node_id":"PRRC_kwDOSYeQPs7Ez1NG","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks (PR #166 review).\n+ // Instead, each section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive\n+ * create). This closes the TOCTOU window that an `fs.access` pre-check\n+ * would leave open: two concurrent archive clicks in the same second\n+ * could otherwise pick the same name and one would overwrite the other.\n+ * The caller is responsible for writing content into the returned path;\n+ * the empty placeholder file we create is overwritten by `fs.writeFile`.\n+ */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ try {\n+ const handle = await fs.open(candidate, 'wx');\n+ await handle.close();\n+ return candidate;\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);","path":"src/chat/archive-export.js","commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繖涓?hunk 涓紝鎮ㄥ紩鍏ヤ簡涓€涓柊鐨勮矾寰勭敓鎴愰€昏緫锛屼娇鐢ㄤ簡 `fs.open` 鏂规硶鏉ョ‘淇濊矾寰勭殑鍞竴鎬с€傝繖鏄竴涓緢濂界殑鏀硅繘锛屼絾闇€瑕佹敞鎰忕殑鏄紝鎮ㄥ湪澶勭悊寮傚父鏃朵粎妫€鏌ヤ簡 `EEXIST` 閿欒銆傚缓璁偍鍦ㄦ崟鑾峰紓甯告椂锛岃褰曞叾浠栧彲鑳界殑閿欒锛屼互渚夸簬鍚庣画鐨勮皟璇曞拰闂鎺掓煡銆傛澶栵紝鎮ㄥ湪寰幆涓娇鐢ㄤ簡 `i < 1000` 鐨勯檺鍒讹紝杩欏彲鑳戒細瀵艰嚧鍦ㄦ瀬绔儏鍐典笅鏃犳硶鐢熸垚鍞竴璺緞銆傚缓璁€冭檻浣跨敤鏇寸伒娲荤殑鏈哄埗鏉ュ鐞嗚矾寰勫啿绐併€?,"created_at":"2026-05-26T07:18:40Z","updated_at":"2026-05-26T07:18:40Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921606","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921606"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921606"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921606/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":null,"original_line":181,"side":"RIGHT","author_association":"NONE","original_position":181,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921619","pull_request_review_id":4361453957,"id":3301921619,"node_id":"PRRC_kwDOSYeQPs7Ez1NT","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄦ浠g爜娈典腑锛屽紓甯稿鐞嗛€昏緫宸茬粡寰楀埌鏀瑰杽锛岀‘淇濅簡鍦ㄥ鍑哄け璐ユ椂涓嶄細灏嗕細璇濈姸鎬佽缃负宸插綊妗c€傜劧鑰岋紝寤鸿鍦ㄦ崟鑾峰紓甯告椂锛岄櫎浜嗘樉绀洪敊璇俊鎭锛岃繕鍙互鑰冭檻璁板綍閿欒鏃ュ織锛屼互渚垮悗缁帓鏌ャ€傚悓鏃讹紝纭繚 `exportSessionToMarkdown` 鍑芥暟鐨勫疄鐜版槸瀹夊叏鐨勶紝閬垮厤娼滃湪鐨勫懡浠ゆ敞鍏ユ垨璺緞绌胯秺闂銆?,"created_at":"2026-05-26T07:18:40Z","updated_at":"2026-05-26T07:18:40Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921619","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921619"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301921619"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301921619/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":413,"original_line":413,"side":"RIGHT","author_association":"NONE","original_position":65,"position":65,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953841","pull_request_review_id":4361495340,"id":3301953841,"node_id":"PRRC_kwDOSYeQPs7Ez9Ex","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks (PR #166 review).\n+ // Instead, each section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive\n+ * create). This closes the TOCTOU window that an `fs.access` pre-check\n+ * would leave open: two concurrent archive clicks in the same second\n+ * could otherwise pick the same name and one would overwrite the other.\n+ * The caller is responsible for writing content into the returned path;\n+ * the empty placeholder file we create is overwritten by `fs.writeFile`.\n+ */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ try {\n+ const handle = await fs.open(candidate, 'wx');\n+ await handle.close();\n+ return candidate;\n+ } catch (err) {","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`_uniquePath()` reserves a candidate by creating an empty file (`fs.open(...,'wx')`) and later overwrites it with `fs.writeFile()`. If `writeFile` throws (disk full/permission/etc.), the placeholder file will be left behind, causing future archives to skip the intended name and potentially leaving many empty artifacts. Consider writing through the exclusive handle (or writing to a temp file and `rename`) and cleaning up on error.","created_at":"2026-05-26T07:24:56Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953841","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953841"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953841"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953841/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":182,"original_start_line":158,"start_side":"RIGHT","line":202,"original_line":175,"side":"RIGHT","author_association":"NONE","original_position":175,"position":202,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953905","pull_request_review_id":4361495340,"id":3301953905,"node_id":"PRRC_kwDOSYeQPs7Ez9Fx","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"Several comments reference 鈥淧R #166 review鈥?/ 鈥減er #166 review鈥? but this change is for issue/PR #165. This makes the rationale harder to track later; please update these references to the correct issue/PR (or remove the PR number if it鈥檚 not important).","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953905","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953905"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953905"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953905/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":25,"original_start_line":25,"start_side":"RIGHT","line":33,"original_line":33,"side":"RIGHT","author_association":"NONE","original_position":33,"position":33,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953937","pull_request_review_id":4361495340,"id":3301953937,"node_id":"PRRC_kwDOSYeQPs7Ez9GR","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"The archive flow鈥檚 comments reference 鈥淧R #166 review鈥? but the linked feature is #165. This incorrect reference will confuse future readers when trying to understand the intended contract; please update/remove the PR number in this comment block.","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953937","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953937"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953937"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953937/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":392,"original_start_line":392,"start_side":"RIGHT","line":400,"original_line":400,"side":"RIGHT","author_association":"NONE","original_position":52,"position":52,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953965","pull_request_review_id":4361495340,"id":3301953965,"node_id":"PRRC_kwDOSYeQPs7Ez9Gt","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path.\n+ */\n+ _notifyArchived(absPath) {\n+ const path = require('path');\n+ const folders = vscode.workspace.workspaceFolders || [];\n+ let display = absPath;\n+ for (const f of folders) {\n+ const root = f.uri.fsPath;\n+ const rel = path.relative(root, absPath);\n+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {\n+ display = rel.replace(/\\\\/g, '/');\n+ break;\n+ }\n+ }","path":"src/chat/session-store.js","commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`_notifyArchived()` reimplements workspace-relative path resolution with a manual loop over `workspaceFolders`. The repo already has `findContainingFolder()` in `src/utils/paths.js` that is multi-root aware and prefers the longest matching root (nested roots). Using it here would avoid duplication and ensure the displayed relative path is correct in nested multi-root setups.","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953965","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953965"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953965"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953965/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":429,"start_side":"RIGHT","line":null,"original_line":440,"side":"RIGHT","author_association":"NONE","original_position":92,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953984","pull_request_review_id":4361495340,"id":3301953984,"node_id":"PRRC_kwDOSYeQPs7Ez9HA","diff_hunk":"@@ -360,17 +360,98 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk. PR #166 review:\n+ // setting `archived = true` on export failure (or on a user-cancelled\n+ // save dialog) would leave the session inconsistent 鈥?invisible in\n+ // the list while nothing was actually archived. The new contract is:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"PR description says 鈥淓xport failures 鈥?do not block UI state advance鈥? but the implementation returns early on export errors/cancel and explicitly does *not* set `s.archived = true`. Either update the PR description to match the code鈥檚 behavior, or adjust the code if the intended UX is to still soft-hide on failure.","created_at":"2026-05-26T07:24:57Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953984","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953984"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301953984"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301953984/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":401,"original_start_line":401,"start_side":"RIGHT","line":410,"original_line":410,"side":"RIGHT","author_association":"NONE","original_position":62,"position":62,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301954002","pull_request_review_id":4361495340,"id":3301954002,"node_id":"PRRC_kwDOSYeQPs7Ez9HS","diff_hunk":"@@ -0,0 +1,227 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes (per #166 review):\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks (PR #166 review).\n+ // Instead, each section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive\n+ * create). This closes the TOCTOU window that an `fs.access` pre-check\n+ * would leave open: two concurrent archive clicks in the same second\n+ * could otherwise pick the same name and one would overwrite the other.\n+ * The caller is responsible for writing content into the returned path;\n+ * the empty placeholder file we create is overwritten by `fs.writeFile`.\n+ */\n+async function _uniquePath(dir, baseName) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ try {\n+ const handle = await fs.open(candidate, 'wx');\n+ await handle.close();\n+ return candidate;\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ }\n+ // Extremely unlikely; bail out with a timestamped name.\n+ return path.join(dir, `${stem}-${Date.now()}${ext}`);\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ let target;\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ throw new Error('Resolved archive path escapes the workspace root.');\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"The thrown error message `Resolved archive path escapes the workspace root.` is surfaced directly to users via `archiveFailed` 鈫?`{msg}` (see SessionStore.archive catch). This will appear in English even in ZH locale and reads like an internal assertion. Consider mapping this case to an i18n鈥檇, user-oriented message (or throwing a typed error code and formatting it at the call site).","created_at":"2026-05-26T07:24:58Z","updated_at":"2026-05-26T07:24:58Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301954002","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301954002"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301954002"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301954002/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":239,"original_start_line":198,"start_side":"RIGHT","line":247,"original_line":205,"side":"RIGHT","author_association":"NONE","original_position":205,"position":247,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985817","pull_request_review_id":4361539251,"id":3301985817,"node_id":"PRRC_kwDOSYeQPs7E0E4Z","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path AND write content atomically through an\n+ * exclusive handle. `fs.open(..., 'wx')` closes the TOCTOU window that an\n+ * `fs.access` pre-check would leave open (two concurrent archive clicks in\n+ * the same second could otherwise pick the same name).\n+ *\n+ * Writing through the exclusive handle 鈥?rather than reserving an empty\n+ * placeholder and then re-opening with `fs.writeFile` 鈥?prevents zero-byte\n+ * residue when the write itself fails (disk full / permission revoked\n+ * mid-write). On error we close the handle and `unlink` the placeholder so\n+ * subsequent archives don't skip the now-orphaned name.\n+ */\n+async function _writeUnique(dir, baseName, content) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ let handle;\n+ try {\n+ handle = await fs.open(candidate, 'wx');\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ try {\n+ await handle.writeFile(content, 'utf8');\n+ await handle.close();\n+ return candidate;\n+ } catch (writeErr) {\n+ // Close best-effort, then remove the empty/partial placeholder.\n+ try { await handle.close(); } catch { /* ignore */ }\n+ try { await fs.unlink(candidate); } catch { /* ignore */ }\n+ throw writeErr;\n+ }\n+ }\n+ // Extremely unlikely (1000 same-second collisions); bail out with a\n+ // timestamped name and a regular write 鈥?still safer than overwriting.\n+ const fallback = path.join(dir, `${stem}-${Date.now()}${ext}`);\n+ await fs.writeFile(fallback, content, { encoding: 'utf8', flag: 'wx' });\n+ return fallback;\n+}\n+\n+/**","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`_writeUnique` 鍑芥暟涓紝铏界劧澶勭悊浜嗗啓鍏ュけ璐ョ殑鎯呭喌锛屼絾鍦?`handle.writeFile(content, 'utf8');` 杩欎竴琛岋紝`handle` 鍙兘浼氭槸鏈畾涔夌殑锛屽缓璁湪浣跨敤鍓嶇‘淇?`handle` 宸叉垚鍔熸墦寮€銆傛澶栵紝`fs.unlink(candidate)` 鐨勮皟鐢ㄥ彲鑳戒細鍥犱负鏂囦欢涓嶅瓨鍦ㄨ€屾姏鍑哄紓甯革紝寤鸿鍦ㄨ皟鐢ㄦ椂鍔犱笂閿欒澶勭悊銆?,"created_at":"2026-05-26T07:29:59Z","updated_at":"2026-05-26T07:29:59Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985817","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985817"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985817"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985817/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":224,"original_line":200,"side":"RIGHT","author_association":"NONE","original_position":200,"position":224,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985826","pull_request_review_id":4361539251,"id":3301985826,"node_id":"PRRC_kwDOSYeQPs7E0E4i","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}\n+\n+/**\n+ * Reserve a non-colliding path AND write content atomically through an\n+ * exclusive handle. `fs.open(..., 'wx')` closes the TOCTOU window that an\n+ * `fs.access` pre-check would leave open (two concurrent archive clicks in\n+ * the same second could otherwise pick the same name).\n+ *\n+ * Writing through the exclusive handle 鈥?rather than reserving an empty\n+ * placeholder and then re-opening with `fs.writeFile` 鈥?prevents zero-byte\n+ * residue when the write itself fails (disk full / permission revoked\n+ * mid-write). On error we close the handle and `unlink` the placeholder so\n+ * subsequent archives don't skip the now-orphaned name.\n+ */\n+async function _writeUnique(dir, baseName, content) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ let handle;\n+ try {\n+ handle = await fs.open(candidate, 'wx');\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ try {\n+ await handle.writeFile(content, 'utf8');\n+ await handle.close();\n+ return candidate;\n+ } catch (writeErr) {\n+ // Close best-effort, then remove the empty/partial placeholder.\n+ try { await handle.close(); } catch { /* ignore */ }\n+ try { await fs.unlink(candidate); } catch { /* ignore */ }\n+ throw writeErr;\n+ }\n+ }\n+ // Extremely unlikely (1000 same-second collisions); bail out with a\n+ // timestamped name and a regular write 鈥?still safer than overwriting.\n+ const fallback = path.join(dir, `${stem}-${Date.now()}${ext}`);\n+ await fs.writeFile(fallback, content, { encoding: 'utf8', flag: 'wx' });\n+ return fallback;\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or null if the user cancelled the\n+ * save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the\n+ // resolved path stays inside the chosen root before writing.\n+ const resolved = path.resolve(archiveDir, fileName);\n+ const rel = path.relative(root, resolved);\n+ if (rel.startsWith('..') || path.isAbsolute(rel)) {\n+ // i18n'd, user-facing 鈥?see archiveErrEscape in src/utils/i18n.js.\n+ throw new Error(t('archiveErrEscape'));\n+ }\n+ await fs.mkdir(archiveDir, { recursive: true });\n+ return await _writeUnique(archiveDir, fileName, md);\n+ }\n+\n+ // No workspace open 鈥?ask the user where to put it. `Uri.file()` requires\n+ // an absolute path: passing a bare filename resolves to a confusing\n+ // location (drive root on Windows, `/` on POSIX). Anchor the default at\n+ // the user's home so the dialog opens somewhere predictable.\n+ const os = require('os');\n+ const uri = await vscode.window.showSaveDialog({\n+ saveLabel: t('archiveSaveLabel'),\n+ filters: { Markdown: ['md'] },\n+ defaultUri: vscode.Uri.file(path.join(os.homedir(), fileName)),\n+ });\n+ if (!uri) return null;\n+ await fs.writeFile(uri.fsPath, md, 'utf8');\n+ return uri.fsPath;\n+}\n+\n+module.exports = { exportSessionToMarkdown, renderSessionMarkdown };","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦?`exportSessionToMarkdown` 鍑芥暟涓紝`_writeUnique` 鍑芥暟鐨勮皟鐢ㄥ悗娌℃湁妫€鏌ヨ繑鍥炲€兼槸鍚︽湁鏁堬紝寤鸿鍦ㄥ啓鍏ユ枃浠跺悗妫€鏌ヨ繑鍥炲€硷紝纭繚鏂囦欢鎴愬姛鍐欏叆銆傚悓鏃讹紝`await fs.writeFile(uri.fsPath, md, 'utf8');` 杩欎竴琛屾病鏈夊鐞嗗彲鑳界殑寮傚父锛屽缓璁姞涓婂紓甯稿鐞嗛€昏緫銆?,"created_at":"2026-05-26T07:29:59Z","updated_at":"2026-05-26T07:29:59Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985826","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985826"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985826"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985826/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":266,"original_line":240,"side":"RIGHT","author_association":"NONE","original_position":240,"position":266,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985832","pull_request_review_id":4361539251,"id":3301985832,"node_id":"PRRC_kwDOSYeQPs7E0E4o","diff_hunk":"@@ -360,17 +360,91 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk 鈥?otherwise setting\n+ // `archived = true` on export failure (or on a user-cancelled save\n+ // dialog) would leave the session invisible in the list while nothing\n+ // was actually archived. Contract:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path. Delegates the relativisation to\n+ * `findContainingFolder` so multi-root + nested-root cases stay correct.\n+ */\n+ _notifyArchived(absPath) {\n+ const { findContainingFolder } = require('../utils/paths');\n+ const hit = findContainingFolder(absPath);\n+ const display = hit ? hit.rel : absPath;\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繖涓€娈典唬鐮佷腑锛屼娇鐢ㄤ簡 `findContainingFolder` 鍑芥暟鏉ユ浛浠e師鏈夌殑璺緞澶勭悊閫昏緫銆傝纭繚 `findContainingFolder` 鍑芥暟鐨勫疄鐜版槸瀹夊叏鐨勶紝鐗瑰埆鏄湪澶勭悊鐢ㄦ埛杈撳叆鐨勮矾寰勬椂锛岄伩鍏嶈矾寰勭┛瓒婄瓑瀹夊叏婕忔礊銆傛澶栵紝寤鸿瀵?`hit` 缁撴灉杩涜绌哄€兼鏌ワ紝浠ラ槻姝㈡綔鍦ㄧ殑绌烘寚閽堝紓甯搞€?,"created_at":"2026-05-26T07:29:59Z","updated_at":"2026-05-26T07:29:59Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985832","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985832"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3301985832"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3301985832/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":436,"original_line":436,"side":"RIGHT","author_association":"NONE","original_position":88,"position":88,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019809","pull_request_review_id":4361579830,"id":3302019809,"node_id":"PRRC_kwDOSYeQPs7E0NLh","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Pick the target workspace folder. Returns the folder fsPath or null.\n+ * - 0 folders 鈫?null (caller falls back to save dialog).\n+ * - 1 folder 鈫?use it.\n+ * - 2+ 鈫?prompt the user.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : null;\n+}","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"In multi-root workspaces, cancelling the folder picker currently returns null from `_pickWorkspaceRoot()`, which makes `exportSessionToMarkdown()` fall back to `showSaveDialog()` as if no workspace were open. This breaks the expected cancel semantics and can unexpectedly allow saving outside the workspace. Consider distinguishing 鈥渘o workspace鈥?vs 鈥渦ser cancelled picker鈥?(e.g., return a sentinel/throw a cancellation error) so cancellation cleanly aborts the archive export (return null) without opening the save dialog.","created_at":"2026-05-26T07:35:04Z","updated_at":"2026-05-26T07:35:06Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019809","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019809"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019809"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019809/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":168,"original_start_line":144,"start_side":"RIGHT","line":180,"original_line":156,"side":"RIGHT","author_association":"NONE","original_position":156,"position":180,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019863","pull_request_review_id":4361579830,"id":3302019863,"node_id":"PRRC_kwDOSYeQPs7E0NMX","diff_hunk":"@@ -0,0 +1,240 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"`renderSessionMarkdown()` uses `session.title` directly in the `# ...` heading. Session titles can contain newlines/control characters (e.g., derived from the first user message or rename), which can break the Markdown structure. Consider normalizing the title for display (e.g., collapse whitespace/newlines to a single space) before injecting it into the heading.","created_at":"2026-05-26T07:35:05Z","updated_at":"2026-05-26T07:35:06Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019863","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019863"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019863"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019863/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":122,"original_start_line":109,"start_side":"RIGHT","line":127,"original_line":113,"side":"RIGHT","author_association":"NONE","original_position":113,"position":127,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019894","pull_request_review_id":4361579830,"id":3302019894,"node_id":"PRRC_kwDOSYeQPs7E0NM2","diff_hunk":"@@ -360,17 +360,91 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk 鈥?otherwise setting\n+ // `archived = true` on export failure (or on a user-cancelled save\n+ // dialog) would leave the session invisible in the list while nothing\n+ // was actually archived. Contract:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path. Delegates the relativisation to\n+ * `findContainingFolder` so multi-root + nested-root cases stay correct.\n+ */\n+ _notifyArchived(absPath) {\n+ const { findContainingFolder } = require('../utils/paths');\n+ const hit = findContainingFolder(absPath);\n+ const display = hit ? hit.rel : absPath;\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ vscode.window\n+ .showInformationMessage(tf('archiveSaved', { path: display }), openLabel, revealLabel)\n+ .then((choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ if (choice === openLabel) {\n+ vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ vscode.commands.executeCommand('revealFileInOS', uri);\n+ }","path":"src/chat/session-store.js","commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","original_commit_id":"34ed95903a593741b31af6e296e85ac8b9ad2fa9","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"The 鈥淥pen File鈥?/ 鈥淩eveal in Explorer鈥?handlers call `showTextDocument()` / `executeCommand()` without awaiting or handling rejections. If either fails (missing file, permission issues, etc.) this can surface as an unhandled promise rejection in the extension host. Consider using `await` inside an async handler with try/catch, or attach `.then(() => {}, () => {})` / `.catch(...)` to explicitly swallow/report errors.\n","created_at":"2026-05-26T07:35:05Z","updated_at":"2026-05-26T07:35:06Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019894","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019894"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302019894"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302019894/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":439,"start_side":"RIGHT","line":null,"original_line":446,"side":"RIGHT","author_association":"NONE","original_position":98,"position":1,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044180","pull_request_review_id":4361608530,"id":3302044180,"node_id":"PRRC_kwDOSYeQPs7E0TIU","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Collapse newlines/tabs/control chars in a session title down to a single\n+ * space before it is injected into a Markdown `# ...` heading. Without this,\n+ * a title that contains \"\\n\" (e.g. taken from the first user message or a\n+ * pasted rename) would split the heading and break the document structure.\n+ */\n+function _safeHeadingTitle(raw) {\n+ return String(raw || '')\n+ .replace(/[\\^@-\\^_\\u007f]+/g, ' ')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const heading = _safeHeadingTitle(session.title) || t('sessionUntitled');\n+ const parts = [head, `# ${heading}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Sentinel returned by `_pickWorkspaceRoot` when the user explicitly\n+ * dismissed the multi-root workspace folder picker. We MUST distinguish this\n+ * from the \"no workspace open\" case (returns `null`): in the cancel case we\n+ * should abort the archive cleanly, not silently fall back to a save dialog\n+ * (which would happily let the user save outside any workspace).\n+ */\n+const PICK_CANCELLED = Symbol('pick-cancelled');\n+\n+/**\n+ * Pick the target workspace folder.\n+ * - 0 folders 鈫?returns `null` (caller falls back to save dialog).\n+ * - 1 folder 鈫?returns its fsPath.\n+ * - 2+ 鈫?returns the picked fsPath, or `PICK_CANCELLED` if the\n+ * user dismissed the picker.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : PICK_CANCELLED;\n+}\n+\n+/**","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繑鍥炲€间腑寮曞叆浜?`PICK_CANCELLED` 绗﹀彿锛岃櫧鐒跺彲浠ュ府鍔╁尯鍒嗙敤鎴峰彇娑堥€夋嫨鐨勬儏鍐碉紝浣嗛渶瑕佺‘淇濊皟鐢ㄦ鍑芥暟鐨勫湴鏂归兘鑳芥纭鐞嗚繖涓€杩斿洖鍊硷紝閬垮厤鍑虹幇鏈鐞嗙殑寮傚父鎴栭€昏緫閿欒銆傚缓璁湪鐩稿叧璋冪敤澶勬坊鍔犵浉搴旂殑澶勭悊閫昏緫銆?,"created_at":"2026-05-26T07:39:41Z","updated_at":"2026-05-26T07:39:41Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044180","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044180"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044180"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044180/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":182,"original_line":182,"side":"RIGHT","author_association":"NONE","original_position":182,"position":182,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044186","pull_request_review_id":4361608530,"id":3302044186,"node_id":"PRRC_kwDOSYeQPs7E0TIa","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);\n+ }\n+ lines.push('---', '');\n+ return lines.join('\\n');\n+}\n+\n+/** Wrap reasoning/thoughts in a collapsible <details> block. */\n+function _renderThoughts(thoughts) {\n+ if (!thoughts) return '';\n+ return [\n+ '<details>',\n+ `<summary>${t('archiveThoughtsLabel')}</summary>`,\n+ '',\n+ thoughts.trim(),\n+ '',\n+ '</details>',\n+ '',\n+ ].join('\\n');\n+}\n+\n+/**\n+ * Collapse newlines/tabs/control chars in a session title down to a single\n+ * space before it is injected into a Markdown `# ...` heading. Without this,\n+ * a title that contains \"\\n\" (e.g. taken from the first user message or a\n+ * pasted rename) would split the heading and break the document structure.\n+ */\n+function _safeHeadingTitle(raw) {\n+ return String(raw || '')\n+ .replace(/[\\^@-\\^_\\u007f]+/g, ' ')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+}\n+\n+/**\n+ * Render a session record to a Markdown string.\n+ * The record shape mirrors what SessionStore.append() persists:\n+ * { id, title, createdAt, updatedAt, model, mode, ws, msgCount,\n+ * messages: [{ role: 'user'|'assistant', text, thoughts? }, ...] }\n+ */\n+function renderSessionMarkdown(session) {\n+ const created = session.createdAt ? new Date(session.createdAt).toISOString() : '';\n+ const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : '';\n+ const archived = new Date().toISOString();\n+\n+ const head = _frontmatter({\n+ sessionId: session.id || '',\n+ title: session.title || '',\n+ createdAt: created,\n+ updatedAt: updated,\n+ archivedAt: archived,\n+ model: session.model || '',\n+ mode: session.mode || '',\n+ messageCount: session.msgCount || (session.messages || []).length,\n+ workspace: session.ws || '',\n+ });\n+\n+ const heading = _safeHeadingTitle(session.title) || t('sessionUntitled');\n+ const parts = [head, `# ${heading}`, ''];\n+ const messages = Array.isArray(session.messages) ? session.messages : [];\n+ for (const m of messages) {\n+ if (!m) continue;\n+ if (m.role === 'user') {\n+ parts.push(`### 馃 ${t('archiveRoleUser')}`, '', String(m.text || '').trim(), '');\n+ } else if (m.role === 'assistant') {\n+ parts.push(`### 馃 ${t('archiveRoleAssistant')}`, '');\n+ const thoughts = _renderThoughts(m.thoughts);\n+ if (thoughts) parts.push(thoughts);\n+ const body = String(m.text || '').trim();\n+ if (body) parts.push(body, '');\n+ } else {\n+ // Defensive: render unknown roles verbatim so nothing is silently lost.\n+ parts.push(`### ${m.role || 'message'}`, '', String(m.text || '').trim(), '');\n+ }\n+ }\n+\n+ // Compose the document. We intentionally do NOT run a global\n+ // `\\n{3,}` collapse here 鈥?that would mutate verbatim user/assistant\n+ // text and break formatting inside fenced code blocks. Instead, each\n+ // section pushes its own controlled trailing blank line.\n+ return parts.join('\\n').trimEnd() + '\\n';\n+}\n+\n+/**\n+ * Sentinel returned by `_pickWorkspaceRoot` when the user explicitly\n+ * dismissed the multi-root workspace folder picker. We MUST distinguish this\n+ * from the \"no workspace open\" case (returns `null`): in the cancel case we\n+ * should abort the archive cleanly, not silently fall back to a save dialog\n+ * (which would happily let the user save outside any workspace).\n+ */\n+const PICK_CANCELLED = Symbol('pick-cancelled');\n+\n+/**\n+ * Pick the target workspace folder.\n+ * - 0 folders 鈫?returns `null` (caller falls back to save dialog).\n+ * - 1 folder 鈫?returns its fsPath.\n+ * - 2+ 鈫?returns the picked fsPath, or `PICK_CANCELLED` if the\n+ * user dismissed the picker.\n+ * @param {string} sessionWs 鈥?the workspace the session was created in; used\n+ * as a strong hint to skip the picker in multi-root scenarios.\n+ */\n+async function _pickWorkspaceRoot(sessionWs) {\n+ const folders = vscode.workspace.workspaceFolders;\n+ if (!folders || folders.length === 0) return null;\n+ if (folders.length === 1) return folders[0].uri.fsPath;\n+ if (sessionWs) {\n+ const match = folders.find((f) => f.uri.fsPath === sessionWs);\n+ if (match) return match.uri.fsPath;\n+ }\n+ const picked = await vscode.window.showWorkspaceFolderPick({\n+ placeHolder: t('archivePickWorkspace'),\n+ });\n+ return picked ? picked.uri.fsPath : PICK_CANCELLED;\n+}\n+\n+/**\n+ * Reserve a non-colliding path AND write content atomically through an\n+ * exclusive handle. `fs.open(..., 'wx')` closes the TOCTOU window that an\n+ * `fs.access` pre-check would leave open (two concurrent archive clicks in\n+ * the same second could otherwise pick the same name).\n+ *\n+ * Writing through the exclusive handle 鈥?rather than reserving an empty\n+ * placeholder and then re-opening with `fs.writeFile` 鈥?prevents zero-byte\n+ * residue when the write itself fails (disk full / permission revoked\n+ * mid-write). On error we close the handle and `unlink` the placeholder so\n+ * subsequent archives don't skip the now-orphaned name.\n+ */\n+async function _writeUnique(dir, baseName, content) {\n+ const ext = '.md';\n+ const stem = baseName.replace(/\\.md$/i, '');\n+ for (let i = 0; i < 1000; i++) {\n+ const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);\n+ let handle;\n+ try {\n+ handle = await fs.open(candidate, 'wx');\n+ } catch (err) {\n+ if (err && err.code === 'EEXIST') continue;\n+ throw err;\n+ }\n+ try {\n+ await handle.writeFile(content, 'utf8');\n+ await handle.close();\n+ return candidate;\n+ } catch (writeErr) {\n+ // Close best-effort, then remove the empty/partial placeholder.\n+ try { await handle.close(); } catch { /* ignore */ }\n+ try { await fs.unlink(candidate); } catch { /* ignore */ }\n+ throw writeErr;\n+ }\n+ }\n+ // Extremely unlikely (1000 same-second collisions); bail out with a\n+ // timestamped name and a regular write 鈥?still safer than overwriting.\n+ const fallback = path.join(dir, `${stem}-${Date.now()}${ext}`);\n+ await fs.writeFile(fallback, content, { encoding: 'utf8', flag: 'wx' });\n+ return fallback;\n+}\n+\n+/**\n+ * Resolve the destination path, then write the markdown.\n+ * Returns the absolute path written, or `null` if:\n+ * - the user cancelled the multi-root workspace folder picker, OR\n+ * - the user cancelled the save dialog in the no-workspace fallback.\n+ * Throws on filesystem errors so the caller can surface a friendly message.\n+ */\n+async function exportSessionToMarkdown(session) {\n+ const md = renderSessionMarkdown(session);\n+ const fileName = `${_timestamp()}-${_safeTitle(session.title)}.md`;\n+\n+ const root = await _pickWorkspaceRoot(session.ws);\n+ if (root === PICK_CANCELLED) return null; // user dismissed the picker\n+ if (root) {\n+ const archiveDir = path.join(root, ARCHIVE_SUBDIR);\n+ // Defence in depth: even though fileName is sanitised, verify the","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄥ鐞嗙敤鎴峰彇娑堢殑鎯呭喌鏃讹紝杩斿洖 `null` 鏄悎鐞嗙殑锛屼絾闇€瑕佺‘淇濊皟鐢ㄦ鍑芥暟鐨勫湴鏂硅兘澶熸纭鐞?`null` 杩斿洖鍊硷紝閬垮厤鍑虹幇绌烘寚閽堝紓甯搞€傚缓璁湪璋冪敤 `exportSessionToMarkdown` 鐨勫湴鏂规坊鍔犲杩斿洖鍊肩殑妫€鏌ャ€?,"created_at":"2026-05-26T07:39:41Z","updated_at":"2026-05-26T07:39:41Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044186","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044186"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044186"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044186/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":239,"original_line":239,"side":"RIGHT","author_association":"NONE","original_position":239,"position":239,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044192","pull_request_review_id":4361608530,"id":3302044192,"node_id":"PRRC_kwDOSYeQPs7E0TIg","diff_hunk":"@@ -360,17 +360,103 @@ class SessionStore {\n this.postList();\n }\n \n+ /**\n+ * \"Archive\" a session 鈥?issue #165.\n+ *\n+ * Original behaviour (pre-#165) was a soft-hide toggle: flip `archived`,\n+ * disappear from the sidebar list, data stays in globalState. Users\n+ * reported that this didn't match the menu label's promise: nothing was\n+ * actually *archived* anywhere they could see, find, or grep.\n+ *\n+ * New behaviour: render the session to Markdown and write it under\n+ * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md\n+ * Then perform the original soft-hide so the session leaves the sidebar.\n+ *\n+ * The \"un-archive\" gesture (clicking again on an already-archived item)\n+ * is still supported via the boolean toggle 鈥?it simply un-hides the\n+ * record without touching the Markdown file on disk.\n+ */\n async archive(id) {\n const list = this.all();\n const s = list.find(x => x.id === id);\n if (!s) return;\n- s.archived = !s.archived;\n- if (this.sessionId === id && s.archived) {\n+\n+ // Un-archive: just flip back to visible. No file I/O needed.\n+ if (s.archived) {\n+ s.archived = false;\n+ await this.set(list);\n+ this.postList();\n+ return;\n+ }\n+\n+ // Archive: render to Markdown FIRST. Only hide the session from the\n+ // sidebar if we actually produced a file on disk 鈥?otherwise setting\n+ // `archived = true` on export failure (or on a user-cancelled save\n+ // dialog) would leave the session invisible in the list while nothing\n+ // was actually archived. Contract:\n+ // - savedPath is a string 鈫?write succeeded, hide the session.\n+ // - savedPath is null 鈫?user cancelled the save dialog; keep\n+ // session visible, do nothing more.\n+ // - throw caught below 鈫?fs error; surface message, keep visible.\n+ let savedPath = null;\n+ try {\n+ const { exportSessionToMarkdown } = require('./archive-export');\n+ savedPath = await exportSessionToMarkdown(s);\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showErrorMessage(tf('archiveFailed', { msg }));\n+ return; // do not toggle archived 鈥?keep state consistent\n+ }\n+ if (!savedPath) return; // user cancelled; keep state consistent\n+\n+ s.archived = true;\n+ if (this.sessionId === id) {\n this.sessionId = null;\n this._post({ type: 'sessionLoaded', id: null, messages: [] });\n }\n await this.set(list);\n this.postList();\n+\n+ this._notifyArchived(savedPath);\n+ }\n+\n+ /**\n+ * Show the bottom-right toast with \"Open\" / \"Reveal in Explorer\" buttons.\n+ * Path display is workspace-relative when possible so users see\n+ * \".deepcopilot/archives/20260526-鈥?md\"\n+ * instead of a long absolute path. Delegates the relativisation to\n+ * `findContainingFolder` so multi-root + nested-root cases stay correct.\n+ */\n+ _notifyArchived(absPath) {\n+ const { findContainingFolder } = require('../utils/paths');\n+ const hit = findContainingFolder(absPath);\n+ const display = hit ? hit.rel : absPath;\n+\n+ const openLabel = t('archiveOpenFile');\n+ const revealLabel = t('archiveRevealInOS');\n+ // Wrap both the toast .then() and the VS Code calls inside it so that\n+ // a missing/deleted file or permission error after the user clicks\n+ // does not surface as an unhandled promise rejection. We swallow the\n+ // error after surfacing it as a non-fatal toast 鈥?failure to *open*\n+ // the archive is not the archive itself failing.\n+ Promise.resolve(\n+ vscode.window.showInformationMessage(\n+ tf('archiveSaved', { path: display }), openLabel, revealLabel,\n+ ),\n+ ).then(async (choice) => {\n+ if (!choice) return;\n+ const uri = vscode.Uri.file(absPath);\n+ try {\n+ if (choice === openLabel) {\n+ await vscode.window.showTextDocument(uri);\n+ } else if (choice === revealLabel) {\n+ await vscode.commands.executeCommand('revealFileInOS', uri);\n+ }\n+ } catch (err) {\n+ const msg = (err && err.message) || String(err);\n+ vscode.window.showWarningMessage(tf('archiveOpenFailed', { msg }));\n+ }\n+ }).catch(() => { /* showInformationMessage itself never rejects, but be defensive */ });\n }\n \n // 鈹€鈹€鈹€ Auto-naming 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€","path":"src/chat/session-store.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"github-actions[bot]","id":41898282,"node_id":"MDM6Qm90NDE4OTgyODI=","avatar_url":"https://avatars.githubusercontent.com/in/15368?v=4","gravatar_id":"","url":"https://api.github.com/users/github-actions%5Bbot%5D","html_url":"https://github.com/apps/github-actions","followers_url":"https://api.github.com/users/github-actions%5Bbot%5D/followers","following_url":"https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}","gists_url":"https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}","starred_url":"https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/github-actions%5Bbot%5D/subscriptions","organizations_url":"https://api.github.com/users/github-actions%5Bbot%5D/orgs","repos_url":"https://api.github.com/users/github-actions%5Bbot%5D/repos","events_url":"https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}","received_events_url":"https://api.github.com/users/github-actions%5Bbot%5D/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"鍦ㄨ繖涓唬鐮佹涓紝澧炲姞浜嗗鏈鐞嗗紓甯哥殑鎹曡幏锛岃繖鏄竴椤硅壇濂界殑瀹炶返銆傜劧鑰岋紝浠嶉渶娉ㄦ剰浠ヤ笅鍑犵偣锛歕n\n1. **瀹夊叏鎬?*锛氬湪澶勭悊鐢ㄦ埛杈撳叆鎴栨枃浠惰矾寰勬椂锛岀‘淇濇病鏈夎矾寰勭┛瓒婃垨鍛戒护娉ㄥ叆鐨勯闄┿€傝櫧鐒跺湪姝ゆ浠g爜涓病鏈夌洿鎺ョ殑椋庨櫓锛屼絾寤鸿鍦ㄥ叾浠栧湴鏂瑰 `absPath` 杩涜楠岃瘉锛屼互纭繚鍏跺畨鍏ㄦ€с€俓n\n2. **鎬ц兘**锛氫娇鐢?`Promise.resolve()` 鍖呰9 `showInformationMessage` 鐨勮皟鐢ㄦ槸澶氫綑鐨勶紝鍥犱负璇ユ柟娉曟湰韬凡缁忚繑鍥炰竴涓?Promise銆傚彲浠ョ洿鎺ヨ皟鐢?`vscode.window.showInformationMessage(...)`銆俓n\n3. **鍙淮鎶ゆ€?*锛氬缓璁湪鎹曡幏寮傚父鏃讹紝璁板綍璇︾粏鐨勯敊璇俊鎭紝浠ヤ究鍚庣画璋冭瘯銆傚綋鍓嶇殑瀹炵幇浠呮樉绀轰簡閿欒娑堟伅锛屼絾娌℃湁璁板綍鍏蜂綋鐨勯敊璇爢鏍堜俊鎭€俓n\n4. **浠g爜椋庢牸**锛氱‘淇濋伒寰」鐩殑浠g爜椋庢牸锛屼緥濡傜缉杩涖€佺┖琛岀瓑锛屼互淇濇寔浠g爜鐨勪竴鑷存€с€?,"created_at":"2026-05-26T07:39:41Z","updated_at":"2026-05-26T07:39:41Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044192","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044192"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302044192"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302044192/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":462,"original_line":462,"side":"RIGHT","author_association":"NONE","original_position":114,"position":114,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067336","pull_request_review_id":4361638020,"id":3302067336,"node_id":"PRRC_kwDOSYeQPs7E0YyI","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"_safeTitle() doesn鈥檛 strip trailing spaces/dots from the generated filename stem. On Windows, paths ending with a space or dot are invalid/normalized by Win32 APIs, which can cause write failures or unexpected collisions. Consider trimming trailing `[ .]+` (and optionally collapsing them) after sanitization so the resulting archive filename is always Windows-safe.\n","created_at":"2026-05-26T07:43:49Z","updated_at":"2026-05-26T07:43:50Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067336","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067336"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067336"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067336/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":42,"original_start_line":42,"start_side":"RIGHT","line":43,"original_line":43,"side":"RIGHT","author_association":"NONE","original_position":43,"position":43,"subject_type":"line"},{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067394","pull_request_review_id":4361638020,"id":3302067394,"node_id":"PRRC_kwDOSYeQPs7E0YzC","diff_hunk":"@@ -0,0 +1,266 @@\n+// Export a chat session to a Markdown file under the workspace.\n+//\n+// Issue #165: the right-click \"馃摝 Archive\" action used to be a soft hide\n+// (toggle `archived` flag). Users expected real archiving 鈥?a Markdown\n+// snapshot they can grep, commit, or share. This module renders the\n+// session record to Markdown and writes it under\n+// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`.\n+//\n+// Edge cases handled:\n+// - No workspace open 鈫?fall back to vscode.window.showSaveDialog.\n+// - Multi-root workspace 鈫?showWorkspaceFolderPick to choose target.\n+// - Path traversal 鈫?resolved path must stay under chosen root\n+// (defence in depth even though titles are\n+// already sanitised).\n+// - Name collision 鈫?append \"-1\", \"-2\", 鈥?suffix.\n+'use strict';\n+\n+const vscode = require('vscode');\n+const path = require('path');\n+const fs = require('fs/promises');\n+const { t } = require('../utils/i18n');\n+\n+const ARCHIVE_SUBDIR = '.deepcopilot/archives';\n+\n+/**\n+ * Strip filesystem-hostile characters and trim length.\n+ * Removed character classes:\n+ * - `\\ / : * ? \" < > |` are reserved on Windows.\n+ * - `\\^@-\\^_` covers C0 control codes (NUL, newlines, tabs, ESC, 鈥?,\n+ * which corrupt filenames and can be abused for terminal injection when\n+ * the path is later printed to a log.\n+ * Leading dots are also stripped so we never produce a hidden file (`.foo`)\n+ * or a relative-path escape (`..`).\n+ */\n+function _safeTitle(raw) {\n+ const s = String(raw || '').trim();\n+ if (!s) return 'untitled';\n+ const cleaned = s\n+ .replace(/[\\\\/:*?\"<>|\\^@-\\^_]/g, '_')\n+ .replace(/^\\.+/, '_')\n+ .replace(/\\s+/g, ' ')\n+ .trim();\n+ return (cleaned || 'untitled').slice(0, 60);\n+}\n+\n+/** \"20260526-143012\" 鈥?local time, fixed-width, sortable. */\n+function _timestamp(d = new Date()) {\n+ const pad = (n) => String(n).padStart(2, '0');\n+ return (\n+ d.getFullYear().toString() +\n+ pad(d.getMonth() + 1) +\n+ pad(d.getDate()) +\n+ '-' +\n+ pad(d.getHours()) +\n+ pad(d.getMinutes()) +\n+ pad(d.getSeconds())\n+ );\n+}\n+\n+/** Render YAML frontmatter from primitive key/value pairs. */\n+function _frontmatter(meta) {\n+ const lines = ['---'];\n+ for (const [k, v] of Object.entries(meta)) {\n+ if (v == null || v === '') continue;\n+ // YAML-safe: quote strings containing colons or leading whitespace.\n+ const s = String(v);\n+ const needsQuote = /[:#\\n]/.test(s) || /^\\s/.test(s);\n+ lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);","path":"src/chat/archive-export.js","commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","original_commit_id":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","user":{"login":"Copilot","id":175728472,"node_id":"BOT_kgDOCnlnWA","avatar_url":"https://avatars.githubusercontent.com/in/946600?v=4","gravatar_id":"","url":"https://api.github.com/users/Copilot","html_url":"https://github.com/apps/copilot-pull-request-reviewer","followers_url":"https://api.github.com/users/Copilot/followers","following_url":"https://api.github.com/users/Copilot/following{/other_user}","gists_url":"https://api.github.com/users/Copilot/gists{/gist_id}","starred_url":"https://api.github.com/users/Copilot/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/Copilot/subscriptions","organizations_url":"https://api.github.com/users/Copilot/orgs","repos_url":"https://api.github.com/users/Copilot/repos","events_url":"https://api.github.com/users/Copilot/events{/privacy}","received_events_url":"https://api.github.com/users/Copilot/received_events","type":"Bot","user_view_type":"public","site_admin":false},"body":"_frontmatter() only quotes strings containing `:`/`#`/newlines/leading whitespace. YAML will still coerce many unquoted scalars (e.g. `true`, `2026-05-26`, `123`, `null`) into non-string types, which can make the exported metadata incorrect or inconsistent depending on the session title/model/etc. Consider always quoting string values (or at least forcing quoting for any non-numeric fields) to keep frontmatter stable and unambiguous.\n","created_at":"2026-05-26T07:43:50Z","updated_at":"2026-05-26T07:43:50Z","html_url":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067394","pull_request_url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166","_links":{"self":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067394"},"html":{"href":"https://github.com/deep-copilot/DeepCopilot/pull/166#discussion_r3302067394"},"pull_request":{"href":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/166"}},"reactions":{"url":"https://api.github.com/repos/deep-copilot/DeepCopilot/pulls/comments/3302067394/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":65,"original_start_line":65,"start_side":"RIGHT","line":68,"original_line":68,"side":"RIGHT","author_association":"NONE","original_position":68,"position":68,"subject_type":"line"}] diff --git a/.tmp-pr.json b/.tmp-pr.json deleted file mode 100644 index 95e47b1..0000000 --- a/.tmp-pr.json +++ /dev/null @@ -1 +0,0 @@ -{"comments":[],"headRefOid":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287","reviews":[{"id":"PRR_kwDOSYeQPs8AAAABA_Xa_Q","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:12:08Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb"}},{"id":"PRR_kwDOSYeQPs8AAAABA_Yufw","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nThis 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.\n\n**Changes:**\n- Added a new session 鈫?Markdown exporter (frontmatter + role sections + reasoning in `<details>`).\n- Updated `SessionStore.archive(id)` to export-then-hide and added an archive toast with Open/Reveal actions.\n- Added new i18n keys (EN/ZH) for archive UI strings.\n\n### Reviewed changes\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.\n\n| File | Description |\n| ---- | ----------- |\n| src/chat/archive-export.js | New module to render and persist a session as a Markdown archive under the workspace (or via save dialog). |\n| src/chat/session-store.js | Archive now triggers export + toast, while un-archive remains a pure visibility toggle. |\n| src/utils/i18n.js | Adds localized strings for archive export dialogs/toasts and role headers. 缁撹锛氶渶淇敼 |\n\n\n\n\n","submittedAt":"2026-05-26T07:15:28Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"ace3b12b1a8fe8f50975c0eff7310ecd20392dbb"}},{"id":"PRR_kwDOSYeQPs8AAAABA_aBhQ","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:18:39Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc"}},{"id":"PRR_kwDOSYeQPs8AAAABA_cjLA","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.\n\n\n\n\n","submittedAt":"2026-05-26T07:24:58Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"58d549e9ec54ae91b9c73d5f00e27ef7d707f0bc"}},{"id":"PRR_kwDOSYeQPs8AAAABA_fOsw","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:29:59Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"34ed95903a593741b31af6e296e85ac8b9ad2fa9"}},{"id":"PRR_kwDOSYeQPs8AAAABA_htNg","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.\n\n\n\n\n","submittedAt":"2026-05-26T07:35:05Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"34ed95903a593741b31af6e296e85ac8b9ad2fa9"}},{"id":"PRR_kwDOSYeQPs8AAAABA_jdUg","author":{"login":"github-actions"},"authorAssociation":"NONE","body":"Code review by ChatGPT","submittedAt":"2026-05-26T07:39:41Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287"}},{"id":"PRR_kwDOSYeQPs8AAAABA_lQhA","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"NONE","body":"## Pull request overview\n\nCopilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.\n\n\n\n\n","submittedAt":"2026-05-26T07:43:50Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"3d53ea6dfc6ad1bceae0ae8825b8122676d45287"}}]} From 1b0611599e53f28050da1e006cb05ef4e0328b44 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 18:03:18 +1000 Subject: [PATCH 07/10] 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). --- src/chat/archive-export.js | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js index c007cfa..5213698 100644 --- a/src/chat/archive-export.js +++ b/src/chat/archive-export.js @@ -122,15 +122,30 @@ function renderSessionMarkdown(session) { const updated = session.updatedAt ? new Date(session.updatedAt).toISOString() : ''; const archived = new Date().toISOString(); + // `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 || '', }); @@ -175,17 +190,19 @@ const PICK_CANCELLED = Symbol('pick-cancelled'); * - 1 folder → returns its fsPath. * - 2+ → returns the picked fsPath, or `PICK_CANCELLED` if the * user dismissed the picker. - * @param {string} sessionWs — the workspace the session was created in; used - * as a strong hint to skip the picker in multi-root scenarios. + * @param {string} _sessionWs — the workspace the session was created in. + * Historically used to skip the picker when it matched a folder, but in + * practice `session.ws` is always derived from `workspaceFolders[0]` (see + * `ChatProvider._currentWs()`), so that shortcut effectively pinned the + * archive to folder[0] and silently bypassed the picker. Now we always + * show the picker when there are 2+ folders — the user explicitly chose + * to archive *something*, asking which root takes a second of their time + * and avoids surprising writes into the wrong project. */ -async function _pickWorkspaceRoot(sessionWs) { +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'), }); From 5e7d9683c6cc438509bfb6ce15c78c17d0ef9677 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 18:24:54 +1000 Subject: [PATCH 08/10] 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 --- src/chat/agent-loop.js | 27 +++++++++++++++++++++++++-- src/chat/tool-executor.js | 10 ++++++++++ src/tools/bg-shell.js | 21 ++++++++++++++++++--- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/chat/agent-loop.js b/src/chat/agent-loop.js index 1cd032d..032fa05 100644 --- a/src/chat/agent-loop.js +++ b/src/chat/agent-loop.js @@ -272,6 +272,16 @@ class AgentLoop { // that an unrelated session's long-running job doesn't trap this loop. const myBgJobs = () => getActiveBgJobsForSession(sid); run._pendingBgJobEvents = []; + // Issue #167: track bg jobs that were started by run_shell_bg WITHIN this + // run. The BG_WAIT_SKIPPED_MODEL_DONE early-exit path is safe for + // pre-existing long-running jobs (dev servers, watchers from earlier + // turns), but it must NOT fire when the model just spawned a new + // background task in this very turn — otherwise the model can + // "promise to notify you when done" and silently end the run before + // any completion event is delivered. tool-executor passes + // ctx.registerBgJob into bg-shell so each freshly started jobId is + // recorded here; we clear entries on end so memory is bounded. + run._sessionStartedBgJobs = new Set(); // Only accept job-end events for jobs that belong to THIS session. // Require an exact sessionId match so events without a sessionId (orphaned // terminals not registered via addActiveBgJob) are also dropped — this @@ -279,6 +289,7 @@ class AgentLoop { const _bgJobEndHandler = (payload) => { if (!payload || payload.sessionId !== sid) return; run._pendingBgJobEvents.push(payload); + if (payload.jobId) run._sessionStartedBgJobs.delete(payload.jobId); }; onBgJobEnded(_bgJobEndHandler); @@ -622,8 +633,20 @@ class AgentLoop { // bypass this guard if we used a text-match heuristic. if (assistantText.trim()) { const _hasPendingEvents = !!(run._pendingBgJobEvents && run._pendingBgJobEvents.length); - if (!_hasPendingEvents) { - Logger.info('BG_WAIT_SKIPPED_MODEL_DONE', { jobs: [...myBgJobs()] }); + // Issue #167: in addition to the pending-event check, + // refuse to break out of the wait loop when the model + // started a new bg job IN THIS RUN that is still alive. + // Without this guard, the model can defuse the wait by + // emitting a single "I'll notify you when it's done" + // sentence, leaving the just-spawned job orphaned. + const _activeJobsNow = myBgJobs(); + const _hasOwnRunningJob = [...run._sessionStartedBgJobs] + .some(j => _activeJobsNow.has(j)); + if (!_hasPendingEvents && !_hasOwnRunningJob) { + Logger.info('BG_WAIT_SKIPPED_MODEL_DONE', { + jobs: [..._activeJobsNow], + sessionStartedJobs: [...run._sessionStartedBgJobs], + }); break; } } diff --git a/src/chat/tool-executor.js b/src/chat/tool-executor.js index fa129c6..1f8abc8 100644 --- a/src/chat/tool-executor.js +++ b/src/chat/tool-executor.js @@ -522,6 +522,16 @@ class ToolExecutor { // the webview tagged with the tool-call id so the card can render // a live tail (GH-Copilot-style terminal card). const ctx = { abortSignal, secrets: this._context.secrets, sessionId: run?.sessionId ?? null }; + // Issue #167: let run_shell_bg register the just-started jobId + // into the run's own "session-started" set, so agent-loop's + // BG_WAIT_SKIPPED_MODEL_DONE guard can refuse to end the turn while + // a freshly spawned background task is still alive. No-op when run + // is null (e.g. one-shot dispatch from tests). + ctx.registerBgJob = (jobId) => { + if (!jobId || !run) return; + if (!(run._sessionStartedBgJobs instanceof Set)) run._sessionStartedBgJobs = new Set(); + run._sessionStartedBgJobs.add(jobId); + }; if (tcId) { ctx.onStreamDelta = (delta) => { if (!delta) return; diff --git a/src/tools/bg-shell.js b/src/tools/bg-shell.js index 6c26ea6..28a78e1 100644 --- a/src/tools/bg-shell.js +++ b/src/tools/bg-shell.js @@ -118,6 +118,10 @@ async function toolRunShellBg(args, ctx = {}) { // removed, causing agent-loop to spin indefinitely. if (usedSI) { addActiveBgJob(jobId, ctx.sessionId || null); + // Issue #167: tell agent-loop this jobId was started in THIS run, so + // its BG_WAIT_SKIPPED_MODEL_DONE early-exit guard refuses to end the + // turn while the freshly spawned job is still alive. + try { ctx.registerBgJob && ctx.registerBgJob(jobId); } catch {} } // ── Early-failure capture: many commands fail within ~1–2 s (missing @@ -175,19 +179,30 @@ async function toolRunShellBg(args, ctx = {}) { terminalName: jobId, status: 'running', shellIntegrationAvailable: usedSI, + // Issue #167: the previous hint promised the agent it would be + // "automatically woken when this job ends" and instructed it to + // "simply end your turn". That contract was incomplete — the agent + // loop used to break out of its wait the moment the model produced + // ANY closing sentence, leaving the just-spawned job orphaned. The + // loop now refuses to end while one of this run's own bg jobs is + // still alive, so the model MUST stay on duty. We restate the + // contract honestly here to discourage the model from emitting + // pseudo-completion messages like "I'll tell you when it's done". hint: [ `Background job "${jobId}" started.`, usedSI ? [ - `Shell integration active — the agent will be suspended and automatically woken when this job ends.`, + `Shell integration active — agent-loop will KEEP YOU ON DUTY until this job ends.`, + `You CANNOT close this turn by saying "I'll let you know later": the loop ignores any such promise while this job is alive and will re-invoke you with the real exit code + tail output as a <system-reminder> when the process exits.`, `CRITICAL: do NOT call ping, sleep, Start-Sleep, or any wait/poll command — it wastes time and blocks failure detection.`, - `CRITICAL: do NOT call read_terminal now. Simply end your turn; the system delivers the job result automatically.`, + `CRITICAL: do NOT call read_terminal now — the system delivers the final result automatically. Either narrate genuine progress, or stay silent until you receive the end-of-job system-reminder.`, ].join('\n') : [ - `Shell integration unavailable — you must poll manually:`, + `Shell integration unavailable — agent-loop CANNOT track this job's exit automatically; you must poll manually:`, ` 1. Wait: run_shell(command: "ping -n 16 127.0.0.1 > nul") ← ~15 s pause on Windows`, ` 2. Check: read_terminal(terminal: "${jobId}")`, ` Output shows "[exit N]" or "[finished]" when done; "[running]" means still active.`, + `Do NOT tell the user "I'll notify you when it's done" in this branch — there is no automatic notification.`, ].join('\n'), `To cancel: ask the user to close the terminal named "${jobId}".`, ].join('\n'), From ede2742df64c555be1e67458fb35290deabbe1a3 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 18:55:04 +1000 Subject: [PATCH 09/10] 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. --- src/chat/archive-export.js | 8 ++++++-- src/chat/session-store.js | 4 ++-- src/tools/bg-shell.js | 33 +++++++++++++++++++++++++++++---- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js index 5213698..b6386e2 100644 --- a/src/chat/archive-export.js +++ b/src/chat/archive-export.js @@ -4,7 +4,7 @@ // (toggle `archived` flag). Users expected real archiving — a Markdown // snapshot they can grep, commit, or share. This module renders the // session record to Markdown and writes it under -// `<workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md`. +// `<workspace>/.deep-copilot/archives/yyyyMMdd-HHmmss-<title>.md`. // // Edge cases handled: // - No workspace open → fall back to vscode.window.showSaveDialog. @@ -20,7 +20,11 @@ const path = require('path'); const fs = require('fs/promises'); const { t } = require('../utils/i18n'); -const ARCHIVE_SUBDIR = '.deepcopilot/archives'; +// PR #168 review: align with the rest of the codebase's workspace-artifact +// convention (`.deep-copilot/plans`, `.deep-copilot/memory.md`, +// `.deep-copilot/logs`). Previously this lived under `.deepcopilot/archives`, +// which produced an inconsistent second hidden directory in user workspaces. +const ARCHIVE_SUBDIR = '.deep-copilot/archives'; /** * Strip filesystem-hostile characters and trim length. diff --git a/src/chat/session-store.js b/src/chat/session-store.js index c0851f2..13b91c4 100644 --- a/src/chat/session-store.js +++ b/src/chat/session-store.js @@ -369,7 +369,7 @@ class SessionStore { * actually *archived* anywhere they could see, find, or grep. * * New behaviour: render the session to Markdown and write it under - * <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md + * <workspace>/.deep-copilot/archives/yyyyMMdd-HHmmss-<title>.md * Then perform the original soft-hide so the session leaves the sidebar. * * The "un-archive" gesture (clicking again on an already-archived item) @@ -423,7 +423,7 @@ class SessionStore { /** * Show the bottom-right toast with "Open" / "Reveal in Explorer" buttons. * Path display is workspace-relative when possible so users see - * ".deepcopilot/archives/20260526-….md" + * ".deep-copilot/archives/20260526-….md" * instead of a long absolute path. Delegates the relativisation to * `findContainingFolder` so multi-root + nested-root cases stay correct. */ diff --git a/src/tools/bg-shell.js b/src/tools/bg-shell.js index 28a78e1..6070b38 100644 --- a/src/tools/bg-shell.js +++ b/src/tools/bg-shell.js @@ -117,11 +117,36 @@ async function toolRunShellBg(args, ctx = {}) { // onDidEndTerminalShellExecution never fires and the job would never be // removed, causing agent-loop to spin indefinitely. if (usedSI) { + // Issue #167 follow-up (PR #168 review): register the jobId in the + // run-scoped set BEFORE publishing to the cross-session active-job + // registry. If the order were reversed, an extremely short-lived + // command could fire its `bg-job-end` event between addActiveBgJob() + // and registerBgJob(); the end handler would then call + // `_sessionStartedBgJobs.delete(jobId)` on a set that doesn't contain + // it yet, and the subsequent registration would leave a stale + // jobId hanging in the set forever (defeating the cleanup contract). + // Doing the local registration first guarantees: either the jobId is + // in the set when the end event fires (and gets cleaned up), or the + // end event is observed after both registrations completed. + if (typeof ctx.registerBgJob === 'function') { + try { + ctx.registerBgJob(jobId); + } catch (e) { + // Do not swallow silently — if this ever fails, the agent-loop + // loses the "started in this run" signal and the original + // orphan-job bug from #167 can resurface. Log so regressions + // surface in debug-logs even when no exception bubbles up. + // Logger only exposes `.info` today (see src/logger.js); the + // tag itself carries the severity for grep-ability. + try { + Logger.info('BG_JOB_REGISTER_FAILED', { + jobId, + err: (e && e.message) || String(e), + }); + } catch {} + } + } addActiveBgJob(jobId, ctx.sessionId || null); - // Issue #167: tell agent-loop this jobId was started in THIS run, so - // its BG_WAIT_SKIPPED_MODEL_DONE early-exit guard refuses to end the - // turn while the freshly spawned job is still alive. - try { ctx.registerBgJob && ctx.registerBgJob(jobId); } catch {} } // ── Early-failure capture: many commands fail within ~1–2 s (missing From 29653a1470a1c646ab7976afeafcbe9b845252e0 Mon Sep 17 00:00:00 2001 From: ZhouChuange <zhouchaunge@gmail.com> Date: Tue, 26 May 2026 19:13:36 +1000 Subject: [PATCH 10/10] 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. --- src/chat/archive-export.js | 2 +- src/tools/bg-shell.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chat/archive-export.js b/src/chat/archive-export.js index b6386e2..ce23c03 100644 --- a/src/chat/archive-export.js +++ b/src/chat/archive-export.js @@ -20,7 +20,7 @@ const path = require('path'); const fs = require('fs/promises'); const { t } = require('../utils/i18n'); -// PR #168 review: align with the rest of the codebase's workspace-artifact +// Post-merge review: align with the rest of the codebase's workspace-artifact // convention (`.deep-copilot/plans`, `.deep-copilot/memory.md`, // `.deep-copilot/logs`). Previously this lived under `.deepcopilot/archives`, // which produced an inconsistent second hidden directory in user workspaces. diff --git a/src/tools/bg-shell.js b/src/tools/bg-shell.js index 6070b38..f0e46ab 100644 --- a/src/tools/bg-shell.js +++ b/src/tools/bg-shell.js @@ -117,7 +117,7 @@ async function toolRunShellBg(args, ctx = {}) { // onDidEndTerminalShellExecution never fires and the job would never be // removed, causing agent-loop to spin indefinitely. if (usedSI) { - // Issue #167 follow-up (PR #168 review): register the jobId in the + // Issue #167 follow-up (post-merge review): register the jobId in the // run-scoped set BEFORE publishing to the cross-session active-job // registry. If the order were reversed, an extremely short-lived // command could fire its `bg-job-end` event between addActiveBgJob()