From 0b2e4e03fbd1c0d934986a90abe2d4de3cb7d844 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 6 Oct 2026 10:23:16 +0300 Subject: [PATCH] =?UTF-8?q?sessions:=20export=20as=20markdown=20=E2=80=94?= =?UTF-8?q?=20the=20sidebar=20right-click,=20the=20route,=20the=20practice?= =?UTF-8?q?=20join=20(release=20polish=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner round (2026-10-06): markdown export of sessions, actioned from the sidebar context menu — "Export as Markdown…" sits with the management actions, above Delete (the destructive action stays last). - GET /api/chats/:id/markdown (404 unknown): the whole session as one attachment — text/markdown; charset=utf-8, filename from the slugified title ("relational-databases-joins-normal-forms.md", non-ascii titles fall back to "session.md"). Both session kinds export. - The client is a same-origin anchor download; the route's Content-Disposition names the file. - sessionMarkdown's user-facing debut — minted ANCHORLESS (message-id anchors are extraction provenance, not learner-facing). The engine joins the core barrel (pure, browser-safe) for its first consumer. The practice join (owner-approved mid-round): practice tool results are bare acks — statement, outcome, verdict, and the model solution all live in the assistant's call arguments. The engine now folds them the same way the view seam folds cards: Problem blocks render the pose's statement (solution never leaks at pose time); Concluded blocks render the outcome as prose ("Solved with hints.") + verdict, then the pose-committed model solution as a blockquoted reveal via problemInputForConclude (clean-poses-only, its refusal law). A dismissed problem concludes as "The learner skipped this problem." — no reveal. Errored poses render nothing (the failed-tool law). 1427 tests (route pins: headers, slug, anchorless body, 404; engine pins: pose-time statement, the reveal, the dismissal refusal, the errored-pose silence); tsc, lint, build green. --- app/api/chats/[id]/markdown/route.ts | 37 +++++++++++++ components/sidebar.tsx | 22 ++++++++ src/core/index.ts | 4 ++ src/core/session-markdown.ts | 79 +++++++++++++++++++++++++-- tests/markdown-export.test.ts | 81 ++++++++++++++++++++++++++++ tests/session-markdown.test.ts | 74 +++++++++++++++++++++++++ 6 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 app/api/chats/[id]/markdown/route.ts create mode 100644 tests/markdown-export.test.ts diff --git a/app/api/chats/[id]/markdown/route.ts b/app/api/chats/[id]/markdown/route.ts new file mode 100644 index 0000000..98aadc1 --- /dev/null +++ b/app/api/chats/[id]/markdown/route.ts @@ -0,0 +1,37 @@ +/** + * THE SESSION MARKDOWN EXPORT (owner, release polish 2026-10-06): the + * user-facing export the session-markdown engine was built for — the + * same document the extractor reads, minted anchorless (anchors are + * extraction provenance; a learner's export carries no message ids). + * + * GET /api/chats/:id/markdown → the whole session as one markdown file + * (Content-Disposition attachment; filename from the slugified title). + * 404 unknown chat. Both session kinds export — practice sessions ride + * the same engine (problem/conclude results render as tool blocks, + * markers are invisible by the engine's law). + */ +import { sessionMarkdown } from "@/src/core"; +import { getChat } from "@/src/server"; + +/** Filename-safe title (own module scope on purpose: tiny + pure). */ +function slugify(title: string): string { + const slug = title + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug.length > 0 ? slug : "session"; +} + +export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }): Promise { + const { id } = await ctx.params; + const record = getChat(id); + if (!record) return Response.json({ error: "Unknown chat" }, { status: 404 }); + const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false }); + return new Response(markdown, { + headers: { + "content-type": "text/markdown; charset=utf-8", + "content-disposition": `attachment; filename="${slugify(record.title)}.md"`, + }, + }); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 0cabe1a..503745b 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -864,6 +864,28 @@ function SessionsSidebar() { > Rename + {/* Export as Markdown (owner, release polish 2026-10-06): the + session-markdown engine's user-facing debut — same-origin + anchor download; the route's Content-Disposition names the + file. Sits with the management actions, above Delete (the + destructive action stays last — the danger-fence law). */} +