feat(writing): token-usage in version history + draft drag-source

Two small UX wins.

Version-history shows generation cost
- VersionHistory takes a generations[] prop (DetailView already pulls
  one via useGenerationsForDraft) and looks up each AI version's linked
  Generation by id. When found, renders a monospace cost line under the
  version's wordcount: "1234 → 567 Tokens · 1.4s · ollama/gemma3:4b".
- Skips silently when the generation row isn't there (e.g. older drafts
  before the field was tracked, or a generation that was reverted).
- Lets the user see what each draft cost without digging into the
  Workbench audit timeline.

Drafts as drag source
- DraftCard wires `use:dragSource` with type='draft' + a payload
  carrying id / title / kind / content / wordCount / topic. Cards in
  the Writing list view are now drag origins for any drop target that
  declares acceptsDropFrom: ['draft'].
- App-registry entry for 'writing' gets the matching collection /
  paramKey / dragType / getDisplayData fields so the workbench layer
  treats drafts as full first-class drag-citizens (sibling navigation,
  display fallbacks).
- @mana/shared-ui DragType union extended with 'draft'.

No drop-target wiring yet — articles' acceptsDropFrom can pick up
'draft' as a follow-up, but the M10 ExportMenu's "Als Artikel
speichern" already covers that flow from the editor side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 12:31:29 +02:00
parent b7a54ccd10
commit 2e9ec76d60
5 changed files with 68 additions and 4 deletions

View file

@ -1354,6 +1354,16 @@ registerApp({
),
},
],
collection: 'writingDrafts',
paramKey: 'draftId',
dragType: 'draft',
getDisplayData: (item) => ({
title: (item.title as string) || (item.topic as string) || 'Draft',
subtitle:
typeof item.wordCount === 'number' && item.wordCount > 0
? `${item.wordCount} Wörter`
: undefined,
}),
});
registerApp({

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { formatDate } from '$lib/i18n/format';
import { dragSource } from '@mana/shared-ui/dnd';
import StatusBadge from './StatusBadge.svelte';
import { KIND_LABELS } from '../constants';
import type { Draft, DraftVersion } from '../types';
@ -41,7 +42,22 @@
}
</script>
<button type="button" class="card" onclick={open}>
<button
type="button"
class="card"
onclick={open}
use:dragSource={{
type: 'draft',
data: () => ({
id: draft.id,
title: draft.title || draft.briefing.topic || 'Unbenannt',
kind: draft.kind,
content: currentVersion?.content ?? '',
wordCount: currentVersion?.wordCount ?? 0,
topic: draft.briefing.topic,
}),
}}
>
<header>
<span class="kind" title={kind.de}>
<span aria-hidden="true">{kind.emoji}</span>

View file

@ -3,17 +3,26 @@
"Wiederherstellen" flips the draft's `currentVersionId` back via the
store. Versions are immutable snapshots so Restore is a pointer change,
not a destructive revert.
AI-generated versions also show their generation cost (token usage +
duration) when the linked Generation row is available, so the user can
see what each draft cost without digging into Workbench audit views.
-->
<script lang="ts">
import { draftsStore } from '../stores/drafts.svelte';
import type { DraftVersion } from '../types';
import type { DraftVersion, Generation } from '../types';
import { formatDate as formatLocaleDate } from '$lib/i18n/format';
let {
versions,
generations = [],
currentVersionId,
draftId,
}: {
versions: DraftVersion[];
/** Generations for this draft. Used to show token-usage / duration
* next to AI-generated versions; harmless to omit. */
generations?: Generation[];
currentVersionId: string | null;
draftId: string;
} = $props();
@ -21,6 +30,8 @@
// Newest on top.
const sorted = $derived([...versions].sort((a, b) => b.versionNumber - a.versionNumber));
const generationById = $derived(new Map(generations.map((g) => [g.id, g])));
async function restore(versionId: string) {
await draftsStore.restoreVersion(draftId, versionId);
}
@ -28,18 +39,34 @@
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', {
return formatLocaleDate(d, {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function genCostLine(version: DraftVersion): string | null {
if (!version.generationId) return null;
const gen = generationById.get(version.generationId);
if (!gen) return null;
const parts: string[] = [];
if (gen.tokenUsage) {
parts.push(`${gen.tokenUsage.input} → ${gen.tokenUsage.output} Tokens`);
}
if (gen.durationMs) {
parts.push(`${(gen.durationMs / 1000).toFixed(1)}s`);
}
if (gen.model) parts.push(gen.model);
return parts.length > 0 ? parts.join(' · ') : null;
}
</script>
<ul class="history">
{#each sorted as version (version.id)}
{@const isCurrent = version.id === currentVersionId}
{@const costLine = genCostLine(version)}
<li class="version" class:current={isCurrent}>
<div class="meta">
<strong>v{version.versionNumber}</strong>
@ -54,6 +81,9 @@
<span>{version.wordCount} Wörter</span>
<span class="date">{formatDate(version.createdAt)}</span>
</div>
{#if costLine}
<div class="cost" title="Verbrauch + Modell der zugehörigen Generation">{costLine}</div>
{/if}
{#if version.summary}
<p class="summary">{version.summary}</p>
{/if}
@ -114,6 +144,12 @@
font-size: 0.75rem;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
}
.cost {
font-size: 0.7rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--color-text-muted, rgba(0, 0, 0, 0.55));
opacity: 0.8;
}
.summary {
margin: 0;
font-size: 0.8rem;

View file

@ -510,6 +510,7 @@
<h2>Versionen</h2>
<VersionHistory
versions={versions ?? []}
generations={generations ?? []}
currentVersionId={draft.currentVersionId}
draftId={draft.id}
/>

View file

@ -27,7 +27,8 @@ export type DragType =
| 'dream'
| 'journal-entry'
| 'first'
| 'wish';
| 'wish'
| 'draft';
export interface DragPayload<T = Record<string, unknown>> {
type: DragType;