Skip to content

feat(session): archive action exports session to Markdown (#165)#166

Merged
ZhouChaunge merged 7 commits into
mainfrom
feat/archive-session-to-markdown
May 26, 2026
Merged

feat(session): archive action exports session to Markdown (#165)#166
ZhouChaunge merged 7 commits into
mainfrom
feat/archive-session-to-markdown

Conversation

@ZhouChaunge
Copy link
Copy Markdown
Collaborator

@ZhouChaunge ZhouChaunge commented May 26, 2026

What

Closes #165. Reworks the right-click 📦 Archive menu action from a silent soft-hide into a real archive: the session is rendered to a Markdown file under <workspace>/.deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md and the user gets a bottom-right toast with Open File / Reveal in Explorer buttons. The original soft-hide still happens afterwards so the entry leaves the sidebar.

Why

The pre-existing behaviour only flipped session.archived in globalState. Nothing was archived anywhere the user could grep, commit, or share ��?the menu label promised more than it delivered.

Changes

File Change
src/chat/archive-export.js (new) Renders a session to Markdown (YAML frontmatter + role-tagged sections + <details>-wrapped reasoning). Resolves destination via workspaceFolders with showWorkspaceFolderPick for multi-root and showSaveDialog fallback when no workspace is open. Sanitises titles, appends -1/-2 on collision, verifies resolved path stays under the chosen root before writing.
src/chat/session-store.js archive(id): export-then-hide; un-archive (second click) remains a pure toggle and never touches the file on disk. Export failures and user-cancelled save dialogs keep the session visible (we only soft-hide after a Markdown file is confirmed on disk) and surface a showErrorMessage on failure. New _notifyArchived(absPath) builds the toast with workspace-relative path display + Open/Reveal actions. Adds tf import.
src/utils/i18n.js 9 new keys (EN + ZH) for the toast, dialog labels, role headers, reasoning summary.

Acceptance criteria check (from #165)

  • Click 📦 Archive ��?session written to .deepcopilot/archives/<timestamp>-<title>.md
  • Toast in bottom-right with relative path + Open File / Reveal in Explorer buttons
  • Session leaves the sidebar afterwards (existing archived semantics preserved)
  • No-workspace fallback to showSaveDialog
  • Multi-root workspace picker (biased toward the session's original ws)
  • Markdown renders cleanly: fenced code blocks intact, reasoning in <details>, frontmatter parsable
  • Path traversal / nonexistent dir / write error ��?friendly showErrorMessage
  • i18n via src/utils/i18n.js (EN + ZH)

Security (copilot-instructions.md red-line #3)

  • Filename is sanitised (\/:*?"<>| + control chars stripped, leading dots dropped, length capped).
  • Resolved write path is validated by path.relative + path.isAbsolute against the chosen workspace root before fs.writeFile ��?out-of-root paths throw.
  • No shell command construction, no user-controlled paths interpolated into a process arg.

Manual test

  1. Open Deep Copilot sidebar, send a couple of turns.
  2. Right-click the session ��?📦 存档 / Archive.
  3. Verify:
    • Toast appears bottom-right: Session archived to .deepcopilot/archives/…
    • Open File opens the Markdown in the editor.
    • Reveal in Explorer highlights it in OS file manager.
    • Sidebar entry disappears.
  4. Open the file: frontmatter present, conversation rendered, reasoning in a collapsible block.
  5. Repeat with no workspace open ��?save dialog appears.
  6. Repeat in a multi-root workspace ��?folder picker appears.

Notes

  • Build verified locally with npm run build (esbuild ��?out/extension.js, no errors).
  • No new runtime dependencies.
  • dangerfile.js / commitlint should be satisfied: conventional commit subject, body within 100 cols.

Previously the right-click 'Archive' menu was a soft-hide toggle — it
flipped session.archived in globalState, the entry disappeared from the
sidebar, and that was it. Users reported that the label did not match
what happened: nothing was actually archived anywhere they could grep,
commit, or share.

This change makes Archive do what it says:

1. src/chat/archive-export.js (new): renders a SessionStore record to
   Markdown — YAML frontmatter with sessionId/model/timestamps, role-
   tagged sections (User / Assistant), and <details>-wrapped reasoning
   so it does not drown out the conversation. Picks the destination via
   workspaceFolders (single root -> use it; multi-root -> workspace
   folder pick, biased toward the session's original ws; no folder open
   -> showSaveDialog). Sanitises titles for filesystem hostility,
   appends -1/-2 on name collision, and verifies the resolved path
   stays under the chosen root before writing (defence in depth).

2. SessionStore.archive: on first archive of a visible session, export
   to .deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md, then perform
   the original soft-hide. Clicking again on an archived session still
   un-hides it (toggle preserved; the file on disk is not touched).
   Export failures surface through showErrorMessage but do not block
   the hide, so the gesture always advances UI state.

3. Bottom-right toast on success with Open File / Reveal in Explorer
   actions. Path is shown workspace-relative when possible.

4. i18n: 9 new keys for the toast, dialog labels, and rendered role
   headers. EN and ZH both populated.

Security (per copilot-instructions.md red-line #3): writes are gated
by path.relative containment check + path.resolve normalisation before
any fs.writeFile call; no user input is interpolated into a shell
command.

Closes #165.
Copilot AI review requested due to automatic review settings May 26, 2026 07:11
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review by ChatGPT

return target;
}

module.exports = { exportSessionToMarkdown, renderSessionMarkdown };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 安全性: 在 _uniquePath 函数中,虽然使用了 fs.access 来检查文件是否存在,但没有考虑到并发写入的情况,可能会导致竞态条件。建议在生成文件名时加锁或使用其他机制确保唯一性。

  2. 异常处理: 在 exportSessionToMarkdown 函数中,虽然有抛出错误的处理,但在调用 fs.mkdirfs.writeFile 时,如果发生异常,应该有更详细的错误处理机制,以便于调试和用户友好提示。

  3. 代码风格: 代码整体风格较为一致,但在某些地方(如 _safeTitle 函数)可以考虑增加注释以提高可读性,尤其是正则表达式的部分。

  4. 性能: 在 _uniquePath 函数中,循环最多会执行 1000 次,这可能会影响性能,尤其是在文件系统较慢的情况下。建议考虑更高效的文件名生成策略。

  5. 可维护性: 函数 _renderThoughts 中对 thoughts 的处理较为简单,建议增加对输入的验证,以防止潜在的 XSS 攻击。

Comment thread src/chat/session-store.js
const { t, tf } = require('../utils/i18n');

// ─── Orphan tool_calls sanitizer ───────────────────────────────────────────
// Removes ANY incomplete assistant{tool_calls} group from a message array,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

引入了新的 tf 函数,但未提供其来源或用途的说明。请确保 tf 函数的安全性和功能性,避免引入潜在的安全漏洞。

Comment thread src/chat/session-store.js
});
}

// ─── Auto-naming ────────────────────────────────────────────────────────
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

archive 方法中,存在未处理的异常情况。如果 exportSessionToMarkdown 失败,虽然有错误提示,但 s.archived 仍然会被设置为 true,这可能导致状态不一致。建议在捕获异常后,添加逻辑以确保 s.archived 不会被错误地设置。

Comment thread src/chat/session-store.js
});
}

// ─── Auto-naming ────────────────────────────────────────────────────────
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_notifyArchived 方法中,路径处理逻辑可能存在路径穿越风险。建议在生成相对路径时,确保 absPath 是在允许的目录下,避免用户通过修改路径访问不应访问的文件。

Comment thread src/chat/session-store.js
});
}

// ─── Auto-naming ────────────────────────────────────────────────────────
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

archive 方法中,this.set(list)this.postList() 的调用可能会导致性能问题,特别是在频繁调用时。建议在状态变化后,合并这些调用以减少不必要的重复操作。

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR redefines the session right-click 📦 Archive action from a soft-hide toggle into a real archive workflow by exporting the session to a Markdown file and notifying the user with quick actions to open or reveal the exported file.

Changes:

  • Added a new session → Markdown exporter (frontmatter + role sections + reasoning in <details>).
  • Updated SessionStore.archive(id) to export-then-hide and added an archive toast with Open/Reveal actions.
  • Added new i18n keys (EN/ZH) for archive UI strings.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/chat/archive-export.js New module to render and persist a session as a Markdown archive under the workspace (or via save dialog).
src/chat/session-store.js Archive now triggers export + toast, while un-archive remains a pure visibility toggle.
src/utils/i18n.js Adds localized strings for archive export dialogs/toasts and role headers. 结论:需修改

Comment thread src/chat/archive-export.js Outdated
}
}

return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
Comment thread src/chat/archive-export.js Outdated
Comment on lines +188 to +196
// No workspace open — ask the user where to put it.
const uri = await vscode.window.showSaveDialog({
saveLabel: t('archiveSaveLabel'),
filters: { Markdown: ['md'] },
defaultUri: vscode.Uri.file(fileName),
});
if (!uri) return null;
target = uri.fsPath;
}
Four corrections from Copilot's PR review on #166:

1. archive-export.js renderSessionMarkdown: drop the global
   `replace(/\\n{3,}/g, '\\n\\n')` pass. It would collapse blank lines
   inside fenced code blocks / tool output, mutating verbatim message
   content. Each section already pushes its own controlled trailing
   blank line, so the global normalisation was both redundant and
   harmful. Issue raised by Copilot inline review.

2. archive-export.js exportSessionToMarkdown: showSaveDialog's
   `defaultUri` was `vscode.Uri.file(fileName)` — `Uri.file()` requires
   an absolute path. On Windows that resolves to a drive root, on POSIX
   to `/`, which is a confusing and possibly unwritable default. Anchor
   it at `os.homedir() + fileName` so the dialog opens somewhere the
   user expects.

3. session-store.js archive(): only set `archived = true` when a file
   was actually written. Previously, both `exportSessionToMarkdown`
   throwing AND returning null (user cancelled the save dialog) would
   still hide the session from the sidebar even though nothing was
   archived — leaving the UI inconsistent with disk state. New contract:
   - returns absolute path → hide + show toast
   - returns null (cancel)  → keep visible, no toast
   - throws (fs error)      → keep visible, show error message

4. archive-export.js _uniquePath: replace the fs.access pre-check with
   `fs.open(candidate, 'wx')` (exclusive create). Closes the TOCTOU
   window where two concurrent archive clicks in the same second could
   both pass the existence check and one would overwrite the other. The
   placeholder zero-byte file is then overwritten by the subsequent
   fs.writeFile.

Also added a comment to `_safeTitle` explaining the regex classes
(reserved-on-Windows set + C0 control codes + leading-dot strip),
addressing a readability note from the AI reviewer.

Build verified locally; no behavioural change outside the four fixes.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review by ChatGPT

Comment thread src/chat/archive-export.js Outdated
}
}
// Extremely unlikely; bail out with a timestamped name.
return path.join(dir, `${stem}-${Date.now()}${ext}`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这个 hunk 中,您引入了一个新的路径生成逻辑,使用了 fs.open 方法来确保路径的唯一性。这是一个很好的改进,但需要注意的是,您在处理异常时仅检查了 EEXIST 错误。建议您在捕获异常时,记录其他可能的错误,以便于后续的调试和问题排查。此外,您在循环中使用了 i < 1000 的限制,这可能会导致在极端情况下无法生成唯一路径。建议考虑使用更灵活的机制来处理路径冲突。

Comment thread src/chat/session-store.js
if (!savedPath) return; // user cancelled; keep state consistent

s.archived = true;
if (this.sessionId === id) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在此代码段中,异常处理逻辑已经得到改善,确保了在导出失败时不会将会话状态设置为已归档。然而,建议在捕获异常时,除了显示错误信息外,还可以考虑记录错误日志,以便后续排查。同时,确保 exportSessionToMarkdown 函数的实现是安全的,避免潜在的命令注入或路径穿越问题。

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

Comment on lines +158 to +175
/**
* Reserve a non-colliding path atomically by `open(..., 'wx')` (exclusive
* create). This closes the TOCTOU window that an `fs.access` pre-check
* would leave open: two concurrent archive clicks in the same second
* could otherwise pick the same name and one would overwrite the other.
* The caller is responsible for writing content into the returned path;
* the empty placeholder file we create is overwritten by `fs.writeFile`.
*/
async function _uniquePath(dir, baseName) {
const ext = '.md';
const stem = baseName.replace(/\.md$/i, '');
for (let i = 0; i < 1000; i++) {
const candidate = path.join(dir, i === 0 ? stem + ext : `${stem}-${i}${ext}`);
try {
const handle = await fs.open(candidate, 'wx');
await handle.close();
return candidate;
} catch (err) {
Comment on lines +25 to +33
/**
* 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 (`..`).
Comment thread src/chat/session-store.js
Comment on lines +392 to +400
// 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.
Comment thread src/chat/session-store.js Outdated
Comment on lines +429 to +440
_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;
}
}
Comment thread src/chat/session-store.js
Comment on lines +401 to +410
let savedPath = null;
try {
const { exportSessionToMarkdown } = require('./archive-export');
savedPath = await exportSessionToMarkdown(s);
} catch (err) {
const msg = (err && err.message) || String(err);
vscode.window.showErrorMessage(tf('archiveFailed', { msg }));
return; // do not toggle archived — keep state consistent
}
if (!savedPath) return; // user cancelled; keep state consistent
Comment on lines +198 to +205
// Defence in depth: even though fileName is sanitised, verify the
// resolved path stays inside the chosen root before writing.
const resolved = path.resolve(archiveDir, fileName);
const rel = path.relative(root, resolved);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Resolved archive path escapes the workspace root.');
}
await fs.mkdir(archiveDir, { recursive: true });
- _writeUnique: write through the exclusive handle so a failed write
  no longer leaves a zero-byte placeholder behind (closes Copilot
  comment on _uniquePath placeholder leak).
- Replace ad-hoc workspace-relative path logic in _notifyArchived with
  the multi-root + longest-prefix-aware findContainingFolder() helper
  from src/utils/paths.js.
- Move the 'archive path escapes workspace' error string into i18n
  (archiveErrEscape) so ZH users no longer see an English assertion in
  the failure toast.
- Drop the self-referential 'PR #166 review' rationale comments left
  over from the previous round; keep only the substantive 'why'.
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review by ChatGPT

return fallback;
}

/**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_writeUnique 函数中,虽然处理了写入失败的情况,但在 handle.writeFile(content, 'utf8'); 这一行,handle 可能会是未定义的,建议在使用前确保 handle 已成功打开。此外,fs.unlink(candidate) 的调用可能会因为文件不存在而抛出异常,建议在调用时加上错误处理。

return uri.fsPath;
}

module.exports = { exportSessionToMarkdown, renderSessionMarkdown };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exportSessionToMarkdown 函数中,_writeUnique 函数的调用后没有检查返回值是否有效,建议在写入文件后检查返回值,确保文件成功写入。同时,await fs.writeFile(uri.fsPath, md, 'utf8'); 这一行没有处理可能的异常,建议加上异常处理逻辑。

Comment thread src/chat/session-store.js
const display = hit ? hit.rel : absPath;

const openLabel = t('archiveOpenFile');
const revealLabel = t('archiveRevealInOS');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这一段代码中,使用了 findContainingFolder 函数来替代原有的路径处理逻辑。请确保 findContainingFolder 函数的实现是安全的,特别是在处理用户输入的路径时,避免路径穿越等安全漏洞。此外,建议对 hit 结果进行空值检查,以防止潜在的空指针异常。

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread src/chat/archive-export.js Outdated
Comment on lines +144 to +156
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;
}
Comment on lines +109 to +113
});

const parts = [head, `# ${session.title || t('sessionUntitled')}`, ''];
const messages = Array.isArray(session.messages) ? session.messages : [];
for (const m of messages) {
Comment thread src/chat/session-store.js Outdated
Comment on lines +439 to +446
.then((choice) => {
if (!choice) return;
const uri = vscode.Uri.file(absPath);
if (choice === openLabel) {
vscode.window.showTextDocument(uri);
} else if (choice === revealLabel) {
vscode.commands.executeCommand('revealFileInOS', uri);
}
- archive-export: sanitize session.title for Markdown `#` heading (collapse newlines/control chars)
- archive-export: distinguish 'user cancelled folder picker' (sentinel) from 'no workspace open' (null) so cancel no longer falls back to showSaveDialog
- session-store: wrap Open File / Reveal in Explorer handlers in try/catch and surface failures as a non-fatal toast (archiveOpenFailed i18n key)
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review by ChatGPT

return picked ? picked.uri.fsPath : PICK_CANCELLED;
}

/**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在返回值中引入了 PICK_CANCELLED 符号,虽然可以帮助区分用户取消选择的情况,但需要确保调用此函数的地方都能正确处理这一返回值,避免出现未处理的异常或逻辑错误。建议在相关调用处添加相应的处理逻辑。

if (root === PICK_CANCELLED) return null; // user dismissed the picker
if (root) {
const archiveDir = path.join(root, ARCHIVE_SUBDIR);
// Defence in depth: even though fileName is sanitised, verify the
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在处理用户取消的情况时,返回 null 是合理的,但需要确保调用此函数的地方能够正确处理 null 返回值,避免出现空指针异常。建议在调用 exportSessionToMarkdown 的地方添加对返回值的检查。

Comment thread src/chat/session-store.js
}).catch(() => { /* showInformationMessage itself never rejects, but be defensive */ });
}

// ─── Auto-naming ────────────────────────────────────────────────────────
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这个代码段中,增加了对未处理异常的捕获,这是一项良好的实践。然而,仍需注意以下几点:

  1. 安全性:在处理用户输入或文件路径时,确保没有路径穿越或命令注入的风险。虽然在此段代码中没有直接的风险,但建议在其他地方对 absPath 进行验证,以确保其安全性。

  2. 性能:使用 Promise.resolve() 包裹 showInformationMessage 的调用是多余的,因为该方法本身已经返回一个 Promise。可以直接调用 vscode.window.showInformationMessage(...)

  3. 可维护性:建议在捕获异常时,记录详细的错误信息,以便后续调试。当前的实现仅显示了错误消息,但没有记录具体的错误堆栈信息。

  4. 代码风格:确保遵循项目的代码风格,例如缩进、空行等,以保持代码的一致性。

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread src/chat/archive-export.js Outdated
Comment on lines +42 to +43
.trim();
return (cleaned || 'untitled').slice(0, 60);
Comment thread src/chat/archive-export.js Outdated
Comment on lines +65 to +68
// YAML-safe: quote strings containing colons or leading whitespace.
const s = String(v);
const needsQuote = /[:#\n]/.test(s) || /^\s/.test(s);
lines.push(`${k}: ${needsQuote ? JSON.stringify(s) : s}`);
- _safeTitle: strip trailing space/dot so Win32 path normalisation can't silently mutate the on-disk filename or defeat the collision counter
- _frontmatter: always quote string scalars so titles/models that look like YAML keywords (true/null/2026-05-26/123) aren't coerced into bool/date/null/number
- _notifyArchived: drop redundant Promise.resolve() wrapper around showInformationMessage (it already returns a thenable); attach rejection handler as the second .then() arg
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review by ChatGPT

Comment thread .gitignore


# scratch files used by AI review triage
.tmp-*.json
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

新增的 .tmp-*.json 文件可能会导致敏感数据泄露,建议在版本控制中忽略此类临时文件。同时,确保这些临时文件不会被意外上传到生产环境中。请考虑使用 .gitignore 文件来排除这些临时文件。

return (cleaned || 'untitled').slice(0, 60).replace(/[. ]+$/, '') || 'untitled';
}

/** "20260526-143012" — local time, fixed-width, sortable. */
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这一段中,增加了对尾部空格和点的处理,这有助于避免在文件系统中出现不一致的文件名。然而,最后的 replace(/[. ]+$/, '') 可能会导致返回的字符串为空,因此在 slice(0, 60) 后面需要确保返回值不为空。建议在返回前添加一个检查,确保返回的字符串至少有一个字符,避免潜在的空指针异常。

}
}
lines.push('---', '');
return lines.join('\n');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这一段中,增加了对所有字符串值的引号处理,这样可以避免 YAML 解析时的潜在问题。然而,当前的实现对数字类型的处理可能会导致意外的行为,特别是在处理 nullundefined 时。建议在处理 nullundefined 时,明确地将其转换为字符串,以避免在 YAML 中出现不一致的表现。

Comment thread src/chat/session-store.js
tf('archiveSaved', { path: display }), openLabel, revealLabel,
).then(async (choice) => {
if (!choice) return;
const uri = vscode.Uri.file(absPath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这一段代码中,虽然 showInformationMessage 本身不会拒绝,但在 .then() 中处理 choice 的时候,仍然需要确保 choice 的值是有效的。建议在处理 choice 之前增加对其类型的检查,以防止潜在的空指针异常。此外,虽然 .catch() 被移除,但建议保留一个简单的错误处理,以防在链式调用中发生意外错误。

Comment thread src/chat/session-store.js
}, () => { /* swallow toast-promise rejection, if any */ });
}

// ─── Auto-naming ────────────────────────────────────────────────────────
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在这里,虽然注释提到要吞掉 toast-promise 的拒绝,但没有明确处理可能的错误情况。建议在 .then() 的最后添加一个 .catch() 来处理可能的错误,以确保在发生异常时能够记录或处理错误,而不是简单地吞掉。这样可以提高代码的健壮性和可维护性。

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

Comment thread src/chat/archive-export.js Outdated
Comment on lines +185 to +188
if (sessionWs) {
const match = folders.find((f) => f.uri.fsPath === sessionWs);
if (match) return match.uri.fsPath;
}
Comment on lines +139 to +143
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(), '');
Comment on lines +125 to +129
const head = _frontmatter({
sessionId: session.id || '',
title: session.title || '',
createdAt: created,
updatedAt: updated,
Round 4 review (#166):

- _pickWorkspaceRoot: drop sessionWs shortcut. session.ws is always folder[0] so the shortcut silently bypassed the picker in multi-root workspaces.

- _frontmatter: add provider (from config), promptTokens / completionTokens / totalTokens (from session.totals).
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review by ChatGPT

totalTokens: Number(totals.total_tokens) || 0,
workspace: session.ws || '',
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在获取配置时,使用了 try-catch 来处理可能的异常,但没有明确捕获异常类型,可能会掩盖其他潜在问题。建议在 catch 中添加错误处理逻辑,以便于调试和日志记录。此外,provider 的默认值为空字符串,可能会导致后续逻辑中出现空值问题,建议在使用前进行有效性检查。

if (folders.length === 1) return folders[0].uri.fsPath;
const picked = await vscode.window.showWorkspaceFolderPick({
placeHolder: t('archivePickWorkspace'),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

函数参数 _sessionWs 的命名不符合现有代码风格,建议保持一致性,使用 sessionWs。此外,虽然注释中提到的逻辑变更是合理的,但需要确保在多根文件夹的情况下,用户体验不会受到影响,建议在实现后进行充分的测试。

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

Comment thread src/utils/i18n.js
archiveSaved: 'Session archived to {path}',
archiveFailed: 'Deep Copilot: failed to archive session — {msg}',
archiveOpenFile: 'Open File',
archiveRevealInOS: 'Reveal in Explorer',
Comment thread src/utils/i18n.js
archiveSaved: '会话已存档到 {path}',
archiveFailed: 'Deep Copilot:会话存档失败 — {msg}',
archiveOpenFile: '打开文件',
archiveRevealInOS: '在资源管理器中显示',
Comment on lines +125 to +149
// `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 || '',
@ZhouChaunge ZhouChaunge merged commit 5b19c0e into main May 26, 2026
18 checks passed
@ZhouChaunge ZhouChaunge deleted the feat/archive-session-to-markdown branch May 26, 2026 08:14
ZhouChaunge added a commit that referenced this pull request May 26, 2026
#167) (#168)

* feat(session): archive action now exports session to Markdown (#165)

Previously the right-click 'Archive' menu was a soft-hide toggle — it
flipped session.archived in globalState, the entry disappeared from the
sidebar, and that was it. Users reported that the label did not match
what happened: nothing was actually archived anywhere they could grep,
commit, or share.

This change makes Archive do what it says:

1. src/chat/archive-export.js (new): renders a SessionStore record to
   Markdown — YAML frontmatter with sessionId/model/timestamps, role-
   tagged sections (User / Assistant), and <details>-wrapped reasoning
   so it does not drown out the conversation. Picks the destination via
   workspaceFolders (single root -> use it; multi-root -> workspace
   folder pick, biased toward the session's original ws; no folder open
   -> showSaveDialog). Sanitises titles for filesystem hostility,
   appends -1/-2 on name collision, and verifies the resolved path
   stays under the chosen root before writing (defence in depth).

2. SessionStore.archive: on first archive of a visible session, export
   to .deepcopilot/archives/yyyyMMdd-HHmmss-<title>.md, then perform
   the original soft-hide. Clicking again on an archived session still
   un-hides it (toggle preserved; the file on disk is not touched).
   Export failures surface through showErrorMessage but do not block
   the hide, so the gesture always advances UI state.

3. Bottom-right toast on success with Open File / Reveal in Explorer
   actions. Path is shown workspace-relative when possible.

4. i18n: 9 new keys for the toast, dialog labels, and rendered role
   headers. EN and ZH both populated.

Security (per copilot-instructions.md red-line #3): writes are gated
by path.relative containment check + path.resolve normalisation before
any fs.writeFile call; no user input is interpolated into a shell
command.

Closes #165.

* fix(archive): address PR #166 review feedback

Four corrections from Copilot's PR review on #166:

1. archive-export.js renderSessionMarkdown: drop the global
   `replace(/\\n{3,}/g, '\\n\\n')` pass. It would collapse blank lines
   inside fenced code blocks / tool output, mutating verbatim message
   content. Each section already pushes its own controlled trailing
   blank line, so the global normalisation was both redundant and
   harmful. Issue raised by Copilot inline review.

2. archive-export.js exportSessionToMarkdown: showSaveDialog's
   `defaultUri` was `vscode.Uri.file(fileName)` — `Uri.file()` requires
   an absolute path. On Windows that resolves to a drive root, on POSIX
   to `/`, which is a confusing and possibly unwritable default. Anchor
   it at `os.homedir() + fileName` so the dialog opens somewhere the
   user expects.

3. session-store.js archive(): only set `archived = true` when a file
   was actually written. Previously, both `exportSessionToMarkdown`
   throwing AND returning null (user cancelled the save dialog) would
   still hide the session from the sidebar even though nothing was
   archived — leaving the UI inconsistent with disk state. New contract:
   - returns absolute path → hide + show toast
   - returns null (cancel)  → keep visible, no toast
   - throws (fs error)      → keep visible, show error message

4. archive-export.js _uniquePath: replace the fs.access pre-check with
   `fs.open(candidate, 'wx')` (exclusive create). Closes the TOCTOU
   window where two concurrent archive clicks in the same second could
   both pass the existence check and one would overwrite the other. The
   placeholder zero-byte file is then overwritten by the subsequent
   fs.writeFile.

Also added a comment to `_safeTitle` explaining the regex classes
(reserved-on-Windows set + C0 control codes + leading-dot strip),
addressing a readability note from the AI reviewer.

Build verified locally; no behavioural change outside the four fixes.

* refactor(archive): address PR review #166 round 2

- _writeUnique: write through the exclusive handle so a failed write
  no longer leaves a zero-byte placeholder behind (closes Copilot
  comment on _uniquePath placeholder leak).
- Replace ad-hoc workspace-relative path logic in _notifyArchived with
  the multi-root + longest-prefix-aware findContainingFolder() helper
  from src/utils/paths.js.
- Move the 'archive path escapes workspace' error string into i18n
  (archiveErrEscape) so ZH users no longer see an English assertion in
  the failure toast.
- Drop the self-referential 'PR #166 review' rationale comments left
  over from the previous round; keep only the substantive 'why'.

* refactor(archive): address PR #166 round 3 review

- archive-export: sanitize session.title for Markdown `#` heading (collapse newlines/control chars)
- archive-export: distinguish 'user cancelled folder picker' (sentinel) from 'no workspace open' (null) so cancel no longer falls back to showSaveDialog
- session-store: wrap Open File / Reveal in Explorer handlers in try/catch and surface failures as a non-fatal toast (archiveOpenFailed i18n key)

* refactor(archive): address PR #166 round 4 review

- _safeTitle: strip trailing space/dot so Win32 path normalisation can't silently mutate the on-disk filename or defeat the collision counter
- _frontmatter: always quote string scalars so titles/models that look like YAML keywords (true/null/2026-05-26/123) aren't coerced into bool/date/null/number
- _notifyArchived: drop redundant Promise.resolve() wrapper around showInformationMessage (it already returns a thenable); attach rejection handler as the second .then() arg

* chore: drop accidentally-committed .tmp-*.json scratch files and ignore them

* fix(archive): always show picker in multi-root; export provider+tokens

Round 4 review (#166):

- _pickWorkspaceRoot: drop sessionWs shortcut. session.ws is always folder[0] so the shortcut silently bypassed the picker in multi-root workspaces.

- _frontmatter: add provider (from config), promptTokens / completionTokens / totalTokens (from session.totals).

* fix(bg-shell): keep agent on duty until session-started bg jobs finish (#167)

Track jobIds started by run_shell_bg within the current run in run._sessionStartedBgJobs, and refuse to take the BG_WAIT_SKIPPED_MODEL_DONE early exit while any of them is still alive. Previously the model could defuse the wait loop by emitting a single closing sentence (e.g. 'I'll tell you when it's done'), leaving the just-spawned background task orphaned with no one listening for its end event.

Also restate the run_shell_bg tool hint honestly so the model understands it cannot end the turn via a verbal promise while a bg job is live.

Refs #167

* fix(bg-shell): address PR #168 review feedback

1. Register jobId in run-scoped _sessionStartedBgJobs BEFORE publishing to addActiveBgJob, closing a race where an extremely short-lived command could fire bg-job-end between the two calls, leaving a stale entry in the run set.

2. Stop swallowing ctx.registerBgJob errors. Guard with typeof check and log via Logger.info (Logger has no warn today) so regressions surface in debug-logs instead of silently losing the keep-alive signal.

3. Rename archive directory from .deepcopilot/archives to .deep-copilot/archives to match the rest of the codebase's workspace-artifact convention (plans, memory, logs). Updates docstrings and toast example path accordingly.

* chore(bg-shell): drop PR# from inline comments to avoid review confusion

Copilot reviewer kept reading the in-code 'PR #168 review' note as a wrong PR reference (the issue is #167 and the branch name carries 167). Rephrase to 'post-merge review' so the rationale stays accurate regardless of which PR ends up shipping the commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 把右键「存档」改为真正归档:导出会话到 .deepcopilot/archives/*.md 并弹出通知

2 participants