···88## [Unreleased]
991010### Added
1111+- AI chat now identifies the signed-in user in every editor's system prompt (v0.60.0, #678). A shared `fetchUserIdentity()` helper (`src/lib/user-identity.ts`) resolves the current user by first probing `/api/me` (Tailscale identity injected by the serve layer) and falling back to the `tools-username` key in localStorage for anonymous/local access. A process-wide cache (`src/lib/user-identity-cache.ts`) memoizes the promise so all 6 chat panels (docs, sheets, slides, diagrams, forms, calendar) share a single network call. `buildSystemMessage` gained a `userIdentity` option that injects a single sentence (`The signed-in user is "Name" (login). Address them by first name when appropriate.`); when name equals login, the parenthetical is dropped; when identity is `null`/missing, the line is omitted entirely. 16 new tests cover fetch fallback chain, memoization, empty-name guards, and identity-line presence across editor types. (#678)
1112- AI chat endpoint URL validation (v0.59.0, #675) — the AI chat settings panel now validates the Endpoint field before persisting. Invalid inputs (empty strings, `ftp://…`, `javascript:`, `file://`, bare hostnames without a protocol, protocol-relative `//…`, malformed URLs) no longer overwrite a working endpoint in localStorage: the previous value is retained. Invalid inputs also set `aria-invalid="true"` on the input and surface an inline error message (`role="alert"`) explaining the accepted formats (`/api/ai` same-origin path or an absolute https URL). A permanent helper hint sits below the input so users understand the format up-front; both elements are linked via `aria-describedby`. Built on the existing `validateEndpoint()` helper, which was previously only consulted to toggle the onboarding banner. 17 new tests (7 unit tests for `validateEndpoint`, 10 jsdom tests for the persist flow + a11y attributes).
1213- Sheets fit-and-finish (v0.58.0) — four Excel/Sheets-parity wins for daily spreadsheet work:
1314 - **Auto-format on entry**: typing `$1,234.56`, `75%`, `2025-03-15`, or `1,234` in a cell now stores the parsed numeric value and stamps an appropriate format (`currency`, `percent`, `date`, `number`) instead of keeping the raw string. Preserves existing cell formats so explicit user choices aren't clobbered. New `src/sheets/auto-format.ts` module with 30 unit tests.
···2223- Slides: JSON deck export — the previously dead "Export" button in slides now downloads the full deck state (slides, theme, transitions, animations) as a `.deck.json` file. Enables backup/clone-a-deck workflows until native PPTX/PDF export lands. (#686)
23242425### Changed
2626+- `ActionCardCallbacks.onDismiss` is now optional (v0.60.0, #680). All 6 editor chat panels were passing an empty `() => {}` callback because the UI state change (dismissed class + status label) happens inside `appendActionCard` regardless. The dead no-op stubs have been removed across docs, sheets, slides, diagrams, forms, and calendar; the field is still available for callers that genuinely need to react to dismissal. (#680)
2527- Dark-mode contrast: bumped `--color-text-faint` lightness from `oklch(0.50 …)` (≈3:1 contrast on dark bg) to `oklch(0.62 …)` (≈4.5:1, meeting WCAG AA for normal text) in both the explicit `[data-theme="dark"]` block and the `@media (prefers-color-scheme: dark)` fallback. Affects every timestamp, helper hint, and secondary label (70+ call sites in app.css use this var). (#690)
2628- Wired the shared export-feedback helper into silent-failing export paths across every editor: diagrams (SVG, PNG, ASCII with correct `.shapes.size` Map accessor), forms (CSV/XLSX response exports), sheets (CSV, TSV, XLSX, PDF), docs (HTML, Markdown, TXT, PDF, DOCX). Every export now either confirms success with a row/slide/document count or surfaces the real error via toast instead of failing silently. (#686)
2729- Promoted save-indicator + save-status-ui from sheets-only modules (`src/sheets/save-*.ts`) to shared library modules (`src/lib/save-*.ts`). Docs, slides, forms, diagrams, and calendar now share a single `wireSaveStatus({ provider, ydoc })` helper with consistent "Saved / Saving… / Saved locally / Unsaved changes" feedback — previously only sheets and docs had live autosave state, and docs carried a 50-line inline copy. Slides, forms, diagrams, and calendar HTML upgraded from an empty `<span class="save-status">` to the full indicator DOM (dot + text). Docs main.ts replaces its inline implementation with a one-line call. 8 new jsdom tests pin the wiring contract (saving→saved transitions, dot class flips, offline "Saved locally" fallback, ydoc-origin echo filtering). (#689)
···261261export interface ActionCardCallbacks {
262262 onApply: (action: AIAction) => void;
263263 onSuggest?: (action: AIAction) => void;
264264- onDismiss: (action: AIAction) => void;
264264+ /**
265265+ * Optional hook invoked when the user clicks "Dismiss". The UI state change
266266+ * (dismissed class + status label) always runs regardless — provide this only
267267+ * if the caller needs to react to the dismissal. (#680)
268268+ */
269269+ onDismiss?: (action: AIAction) => void;
265270}
266271267272/**
···313318 dismissBtn.addEventListener('click', () => {
314319 card.classList.add('ai-action-card--dismissed');
315320 card.querySelector('.ai-action-card-buttons')!.innerHTML = '<span class="ai-action-card-status">Dismissed</span>';
316316- callbacks.onDismiss(action);
321321+ callbacks.onDismiss?.(action);
317322 });
318323319324 list.appendChild(card);
+16
src/lib/ai-chat/system-prompt.ts
···22 * AI Chat — system prompt construction and action instruction templates.
33 */
4455+import type { UserIdentity } from '../user-identity.js';
66+57// ── System prompt ──────────────────────────────────────────────────────
6879export type EditorType = 'doc' | 'sheet' | 'diagram' | 'slide' | 'form' | 'calendar';
···1012 editorType?: EditorType;
1113 actionsEnabled?: boolean;
1214 selectionContext?: string;
1515+ /**
1616+ * Signed-in user identity (#678). When present, a single sentence identifying
1717+ * the user is injected into the system prompt so the model can address them
1818+ * by name and know who is making the request.
1919+ */
2020+ userIdentity?: UserIdentity | null;
1321}
14221523export function buildSystemMessage(docTitle: string, docContext: string, editorTypeOrOpts: EditorType | SystemMessageOptions = 'doc'): string {
···3240 `You are ${role}.`,
3341 'Be concise and direct. Use markdown formatting where helpful.',
3442 ];
4343+ if (opts.userIdentity && opts.userIdentity.name) {
4444+ const { name, login } = opts.userIdentity;
4545+ parts.push(
4646+ login && login !== name
4747+ ? `The signed-in user is "${name}" (${login}). Address them by first name when appropriate.`
4848+ : `The signed-in user is "${name}". Address them by first name when appropriate.`,
4949+ );
5050+ }
3551 if (docTitle) parts.push(`The ${label} is titled "${docTitle}".`);
3652 if (opts.selectionContext) {
3753 const maxSelLen = 4000;
+28
src/lib/user-identity-cache.ts
···11+/**
22+ * User identity — process-wide memoized accessor.
33+ *
44+ * `fetchUserIdentity()` from `./user-identity.js` performs a network call plus
55+ * a localStorage lookup. Panels call it on every message send, so we cache the
66+ * in-flight promise and reuse it. `/api/me` identity is effectively static for
77+ * the life of the page; localStorage changes are rare and won't be captured
88+ * without a refresh — acceptable trade-off to avoid repeated fetches.
99+ */
1010+1111+import { fetchUserIdentity, type UserIdentity } from './user-identity.js';
1212+1313+let cached: Promise<UserIdentity | null> | null = null;
1414+1515+/**
1616+ * Return the cached identity promise, starting the fetch on first call.
1717+ */
1818+export function getUserIdentityCached(): Promise<UserIdentity | null> {
1919+ if (!cached) {
2020+ cached = fetchUserIdentity();
2121+ }
2222+ return cached;
2323+}
2424+2525+/** Test-only reset; not exported from the barrel. */
2626+export function __resetUserIdentityCacheForTest(): void {
2727+ cached = null;
2828+}
+48
src/lib/user-identity.ts
···11+/**
22+ * User identity — shared helper for detecting the current user.
33+ *
44+ * Prefers Tailscale identity injected at `/api/me` (login + name + avatar),
55+ * falls back to the local display name persisted under `tools-username`.
66+ * Returns `null` when neither source is available (anonymous / first-run).
77+ */
88+99+export interface UserIdentity {
1010+ /** Display name (Tailscale name or localStorage username). Always present. */
1111+ name: string;
1212+ /** Tailscale login (e.g. `scott@github`). Omitted when anonymous/local. */
1313+ login?: string;
1414+}
1515+1616+const LOCAL_NAME_KEY = 'tools-username';
1717+1818+/**
1919+ * Fetch the current user identity, or `null` if no source resolves.
2020+ *
2121+ * Never throws — network/IO errors degrade to the localStorage fallback,
2222+ * then to `null`. Safe to call from any chat panel initialization path.
2323+ */
2424+export async function fetchUserIdentity(): Promise<UserIdentity | null> {
2525+ try {
2626+ const res = await fetch('/api/me');
2727+ if (res.ok) {
2828+ const data = (await res.json()) as { login?: string; name?: string };
2929+ if (data && typeof data.login === 'string' && data.login.length > 0) {
3030+ const name = typeof data.name === 'string' && data.name.length > 0 ? data.name : data.login;
3131+ return { name, login: data.login };
3232+ }
3333+ }
3434+ } catch {
3535+ // Ignore — anonymous or offline; fall through to localStorage.
3636+ }
3737+3838+ try {
3939+ const stored = localStorage.getItem(LOCAL_NAME_KEY);
4040+ if (stored && stored.trim().length > 0) {
4141+ return { name: stored.trim() };
4242+ }
4343+ } catch {
4444+ // localStorage may be unavailable (SSR, sandboxed iframes); ignore.
4545+ }
4646+4747+ return null;
4848+}