Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ai-chat): signed-in user identity + optional onDismiss (v0.60.0)

#678 — wire /api/me identity into every editor's system prompt
- new src/lib/user-identity.ts: fetchUserIdentity() with Tailscale + localStorage fallback
- new src/lib/user-identity-cache.ts: process-wide memoized accessor
- buildSystemMessage gains userIdentity option that injects a single sentence;
drops parenthetical when name == login; omits line entirely when null
- all 6 chat panels (docs, sheets, slides, diagrams, forms, calendar)
warm the cache on wire and pass identity to buildSystemMessage per send
- 16 new tests: fallback chain, memoization, empty-name guard, identity
across editor types

#680 — remove dead onDismiss stubs
- ActionCardCallbacks.onDismiss is now optional; the UI state change
(dismissed class + status label) runs inside appendActionCard regardless
- removed empty () => {} no-ops from all 6 editor panels

+321 -9
+2
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Added 11 + - 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) 11 12 - 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). 12 13 - Sheets fit-and-finish (v0.58.0) — four Excel/Sheets-parity wins for daily spreadsheet work: 13 14 - **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. ··· 22 23 - 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) 23 24 24 25 ### Changed 26 + - `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) 25 27 - 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) 26 28 - 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) 27 29 - 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)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.59.0", 3 + "version": "0.60.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+3 -1
src/calendar/ai-chat-panel.ts
··· 15 15 type ChatMessage, 16 16 } from '../lib/ai-chat.js'; 17 17 import { splitResponse, isCalendarAction } from '../lib/ai-actions.js'; 18 + import { getUserIdentityCached } from '../lib/user-identity-cache.js'; 18 19 19 20 // ── Types ─────────────────────────────────────────────────── 20 21 ··· 123 124 const actionsEnabled = deps.chatUI.actionsToggle.checked; 124 125 const contextText = includeContext ? getCalendarContextText(deps.getEvents(), deps.currentDate()) : ''; 125 126 127 + const userIdentity = await getUserIdentityCached(); 126 128 const systemPrompt = buildSystemMessage(title, contextText, { 127 129 editorType: 'calendar', 128 130 actionsEnabled, 131 + userIdentity, 129 132 }); 130 133 131 134 const calDeps = { ··· 168 171 } 169 172 deps.renderView(); 170 173 }, 171 - onDismiss: () => {}, 172 174 }); 173 175 } 174 176 }
+6 -1
src/diagrams/ai-chat-wiring.ts
··· 11 11 type ChatMessage, 12 12 } from '../lib/ai-chat.js'; 13 13 import { splitResponse, isDiagramAction } from '../lib/ai-actions.js'; 14 + import { getUserIdentityCached } from '../lib/user-identity-cache.js'; 14 15 import { executeDiagramAction } from './ai-diagram-actions.js'; 15 16 16 17 // --------------------------------------------------------------------------- ··· 60 61 deps.mainContent.appendChild(chatUI.container); 61 62 62 63 const chatState = createChatState(); 64 + 65 + // #678 — warm shared identity cache so first send has it ready. 66 + void getUserIdentityCached(); 63 67 64 68 const chatWiring = initChatWiring({ 65 69 chatUI, ··· 109 113 selectionContext = `Selected shapes: ${selLines.join(', ')}`; 110 114 } 111 115 116 + const userIdentity = await getUserIdentityCached(); 112 117 const systemPrompt = buildSystemMessage(title, contextText, { 113 118 editorType: 'diagram', 114 119 actionsEnabled, 115 120 selectionContext, 121 + userIdentity, 116 122 }); 117 123 118 124 const diagramDeps = { ··· 154 160 appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 155 161 } 156 162 }, 157 - onDismiss: () => {}, 158 163 }); 159 164 } 160 165 }
+6 -1
src/docs/ai-chat-wiring.ts
··· 13 13 type ChatMessage, 14 14 } from '../lib/ai-chat.js'; 15 15 import { splitResponse, isDocAction } from '../lib/ai-actions.js'; 16 + import { getUserIdentityCached } from '../lib/user-identity-cache.js'; 16 17 import { executeDocAction } from './ai-doc-actions.js'; 17 18 18 19 // ── Types ─────────────────────────────────────────────────── ··· 38 39 mainContent.appendChild(chatUI.container); 39 40 40 41 const chatState = createChatState(); 42 + 43 + // #678 — warm the shared identity cache so the first send has it ready. 44 + void getUserIdentityCached(); 41 45 42 46 const chatWiring = initChatWiring({ 43 47 chatUI, ··· 78 82 const docText = includeContext ? editor.getText() : ''; 79 83 const { from, to } = editor.state.selection; 80 84 const selectionText = from !== to ? editor.state.doc.textBetween(from, to) : ''; 85 + const userIdentity = await getUserIdentityCached(); 81 86 const systemPrompt = buildSystemMessage(docTitle, docText, { 82 87 editorType: 'doc', 83 88 actionsEnabled, 84 89 selectionContext: selectionText || undefined, 90 + userIdentity, 85 91 }); 86 92 87 93 // Streaming response ··· 129 135 appendMessage(chatUI.messageList, { role: 'assistant', content: `Suggestion failed: ${result.error}`, ts: Date.now() }); 130 136 } 131 137 }, 132 - onDismiss: () => {}, 133 138 }); 134 139 } 135 140 }
+3 -1
src/forms/ai-chat-panel.ts
··· 14 14 type ChatMessage, 15 15 } from '../lib/ai-chat.js'; 16 16 import { splitResponse, isFormAction } from '../lib/ai-actions.js'; 17 + import { getUserIdentityCached } from '../lib/user-identity-cache.js'; 17 18 18 19 // ── Types ─────────────────────────────────────────────────── 19 20 ··· 86 87 const actionsEnabled = deps.chatUI.actionsToggle.checked; 87 88 const contextText = includeContext ? getFormContextText(deps.getForm()) : ''; 88 89 90 + const userIdentity = await getUserIdentityCached(); 89 91 const systemPrompt = buildSystemMessage(title, contextText, { 90 92 editorType: 'form', 91 93 actionsEnabled, 94 + userIdentity, 92 95 }); 93 96 94 97 const formDeps = { ··· 130 133 appendMessage(deps.chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 131 134 } 132 135 }, 133 - onDismiss: () => {}, 134 136 }); 135 137 } 136 138 }
+7 -2
src/lib/ai-chat/sidebar-dom.ts
··· 261 261 export interface ActionCardCallbacks { 262 262 onApply: (action: AIAction) => void; 263 263 onSuggest?: (action: AIAction) => void; 264 - onDismiss: (action: AIAction) => void; 264 + /** 265 + * Optional hook invoked when the user clicks "Dismiss". The UI state change 266 + * (dismissed class + status label) always runs regardless — provide this only 267 + * if the caller needs to react to the dismissal. (#680) 268 + */ 269 + onDismiss?: (action: AIAction) => void; 265 270 } 266 271 267 272 /** ··· 313 318 dismissBtn.addEventListener('click', () => { 314 319 card.classList.add('ai-action-card--dismissed'); 315 320 card.querySelector('.ai-action-card-buttons')!.innerHTML = '<span class="ai-action-card-status">Dismissed</span>'; 316 - callbacks.onDismiss(action); 321 + callbacks.onDismiss?.(action); 317 322 }); 318 323 319 324 list.appendChild(card);
+16
src/lib/ai-chat/system-prompt.ts
··· 2 2 * AI Chat — system prompt construction and action instruction templates. 3 3 */ 4 4 5 + import type { UserIdentity } from '../user-identity.js'; 6 + 5 7 // ── System prompt ────────────────────────────────────────────────────── 6 8 7 9 export type EditorType = 'doc' | 'sheet' | 'diagram' | 'slide' | 'form' | 'calendar'; ··· 10 12 editorType?: EditorType; 11 13 actionsEnabled?: boolean; 12 14 selectionContext?: string; 15 + /** 16 + * Signed-in user identity (#678). When present, a single sentence identifying 17 + * the user is injected into the system prompt so the model can address them 18 + * by name and know who is making the request. 19 + */ 20 + userIdentity?: UserIdentity | null; 13 21 } 14 22 15 23 export function buildSystemMessage(docTitle: string, docContext: string, editorTypeOrOpts: EditorType | SystemMessageOptions = 'doc'): string { ··· 32 40 `You are ${role}.`, 33 41 'Be concise and direct. Use markdown formatting where helpful.', 34 42 ]; 43 + if (opts.userIdentity && opts.userIdentity.name) { 44 + const { name, login } = opts.userIdentity; 45 + parts.push( 46 + login && login !== name 47 + ? `The signed-in user is "${name}" (${login}). Address them by first name when appropriate.` 48 + : `The signed-in user is "${name}". Address them by first name when appropriate.`, 49 + ); 50 + } 35 51 if (docTitle) parts.push(`The ${label} is titled "${docTitle}".`); 36 52 if (opts.selectionContext) { 37 53 const maxSelLen = 4000;
+28
src/lib/user-identity-cache.ts
··· 1 + /** 2 + * User identity — process-wide memoized accessor. 3 + * 4 + * `fetchUserIdentity()` from `./user-identity.js` performs a network call plus 5 + * a localStorage lookup. Panels call it on every message send, so we cache the 6 + * in-flight promise and reuse it. `/api/me` identity is effectively static for 7 + * the life of the page; localStorage changes are rare and won't be captured 8 + * without a refresh — acceptable trade-off to avoid repeated fetches. 9 + */ 10 + 11 + import { fetchUserIdentity, type UserIdentity } from './user-identity.js'; 12 + 13 + let cached: Promise<UserIdentity | null> | null = null; 14 + 15 + /** 16 + * Return the cached identity promise, starting the fetch on first call. 17 + */ 18 + export function getUserIdentityCached(): Promise<UserIdentity | null> { 19 + if (!cached) { 20 + cached = fetchUserIdentity(); 21 + } 22 + return cached; 23 + } 24 + 25 + /** Test-only reset; not exported from the barrel. */ 26 + export function __resetUserIdentityCacheForTest(): void { 27 + cached = null; 28 + }
+48
src/lib/user-identity.ts
··· 1 + /** 2 + * User identity — shared helper for detecting the current user. 3 + * 4 + * Prefers Tailscale identity injected at `/api/me` (login + name + avatar), 5 + * falls back to the local display name persisted under `tools-username`. 6 + * Returns `null` when neither source is available (anonymous / first-run). 7 + */ 8 + 9 + export interface UserIdentity { 10 + /** Display name (Tailscale name or localStorage username). Always present. */ 11 + name: string; 12 + /** Tailscale login (e.g. `scott@github`). Omitted when anonymous/local. */ 13 + login?: string; 14 + } 15 + 16 + const LOCAL_NAME_KEY = 'tools-username'; 17 + 18 + /** 19 + * Fetch the current user identity, or `null` if no source resolves. 20 + * 21 + * Never throws — network/IO errors degrade to the localStorage fallback, 22 + * then to `null`. Safe to call from any chat panel initialization path. 23 + */ 24 + export async function fetchUserIdentity(): Promise<UserIdentity | null> { 25 + try { 26 + const res = await fetch('/api/me'); 27 + if (res.ok) { 28 + const data = (await res.json()) as { login?: string; name?: string }; 29 + if (data && typeof data.login === 'string' && data.login.length > 0) { 30 + const name = typeof data.name === 'string' && data.name.length > 0 ? data.name : data.login; 31 + return { name, login: data.login }; 32 + } 33 + } 34 + } catch { 35 + // Ignore — anonymous or offline; fall through to localStorage. 36 + } 37 + 38 + try { 39 + const stored = localStorage.getItem(LOCAL_NAME_KEY); 40 + if (stored && stored.trim().length > 0) { 41 + return { name: stored.trim() }; 42 + } 43 + } catch { 44 + // localStorage may be unavailable (SSR, sandboxed iframes); ignore. 45 + } 46 + 47 + return null; 48 + }
+3 -1
src/sheets/ai-chat-panel.ts
··· 13 13 appendActionCard, appendParseErrorNote, 14 14 } from '../lib/ai-chat.js'; 15 15 import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 16 + import { getUserIdentityCached } from '../lib/user-identity-cache.js'; 16 17 import { executeSheetAction } from './ai-sheet-actions.js'; 17 18 18 19 // ── Types ─────────────────────────────────────────────────── ··· 109 110 const includeContext = deps.chatUI.contextToggle.checked; 110 111 const actionsEnabled = deps.chatUI.actionsToggle.checked; 111 112 const sheetText = includeContext ? getSheetContextText(deps.getCells) : ''; 113 + const userIdentity = await getUserIdentityCached(); 112 114 const systemPrompt = buildSystemMessage(sheetTitle, sheetText, { 113 115 editorType: 'sheet', 114 116 actionsEnabled, 117 + userIdentity, 115 118 }); 116 119 117 120 const sheetActionDeps = { ··· 156 159 appendMessage(deps.chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 157 160 } 158 161 }, 159 - onDismiss: () => {}, 160 162 }); 161 163 } 162 164 }
+6 -1
src/slides/ai-chat-panel.ts
··· 13 13 type ChatMessage, 14 14 } from '../lib/ai-chat.js'; 15 15 import { splitResponse, isSlideAction } from '../lib/ai-actions.js'; 16 + import { getUserIdentityCached } from '../lib/user-identity-cache.js'; 16 17 import { executeSlideAction } from './ai-slide-actions.js'; 17 18 import type { DOMRefs, AppActions } from './types.js'; 18 19 ··· 26 27 $('main-content').appendChild(chatUI.container); 27 28 28 29 const chatState = createChatState(); 30 + 31 + // #678 — warm shared identity cache so first send has it ready. 32 + void getUserIdentityCached(); 29 33 30 34 const chatWiring = initChatWiring({ 31 35 chatUI, ··· 77 81 const actionsEnabled = chatUI.actionsToggle.checked; 78 82 const contextText = includeContext ? getSlideContextText() : ''; 79 83 84 + const userIdentity = await getUserIdentityCached(); 80 85 const systemPrompt = buildSystemMessage(title, contextText, { 81 86 editorType: 'slide', 82 87 actionsEnabled, 88 + userIdentity, 83 89 }); 84 90 85 91 const { deck } = actions.getState(); ··· 124 130 appendMessage(chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 125 131 } 126 132 }, 127 - onDismiss: () => {}, 128 133 }); 129 134 } 130 135 }
+192
tests/user-identity.test.ts
··· 1 + /** 2 + * Tests for user-identity helper and the identity-aware system prompt. 3 + */ 4 + 5 + import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; 6 + import { fetchUserIdentity } from '../src/lib/user-identity.js'; 7 + import { 8 + getUserIdentityCached, 9 + __resetUserIdentityCacheForTest, 10 + } from '../src/lib/user-identity-cache.js'; 11 + import { buildSystemMessage } from '../src/lib/ai-chat/system-prompt.js'; 12 + 13 + const LOCAL_KEY = 'tools-username'; 14 + 15 + // jsdom's localStorage implementation in this project is mocked piecemeal by 16 + // other test files; supply a full mock so this file is self-contained. 17 + const store: Record<string, string> = {}; 18 + const mockLS = { 19 + getItem: (key: string) => store[key] ?? null, 20 + setItem: (key: string, val: string) => { store[key] = val; }, 21 + removeItem: (key: string) => { delete store[key]; }, 22 + clear: () => { for (const k of Object.keys(store)) delete store[k]; }, 23 + get length() { return Object.keys(store).length; }, 24 + key: (i: number) => Object.keys(store)[i] ?? null, 25 + }; 26 + Object.defineProperty(globalThis, 'localStorage', { value: mockLS, writable: true }); 27 + 28 + describe('fetchUserIdentity', () => { 29 + let originalFetch: typeof fetch; 30 + 31 + beforeEach(() => { 32 + originalFetch = globalThis.fetch; 33 + localStorage.clear(); 34 + __resetUserIdentityCacheForTest(); 35 + }); 36 + 37 + afterEach(() => { 38 + globalThis.fetch = originalFetch; 39 + }); 40 + 41 + it('returns Tailscale identity when /api/me responds with a login', async () => { 42 + globalThis.fetch = vi.fn(async () => ({ 43 + ok: true, 44 + json: async () => ({ login: 'scott@example.com', name: 'Scott' }), 45 + })) as unknown as typeof fetch; 46 + 47 + const id = await fetchUserIdentity(); 48 + expect(id).toEqual({ name: 'Scott', login: 'scott@example.com' }); 49 + }); 50 + 51 + it('falls back to login string if name is missing', async () => { 52 + globalThis.fetch = vi.fn(async () => ({ 53 + ok: true, 54 + json: async () => ({ login: 'someone@tailnet' }), 55 + })) as unknown as typeof fetch; 56 + 57 + const id = await fetchUserIdentity(); 58 + expect(id).toEqual({ name: 'someone@tailnet', login: 'someone@tailnet' }); 59 + }); 60 + 61 + it('falls through to localStorage when /api/me has no login', async () => { 62 + globalThis.fetch = vi.fn(async () => ({ 63 + ok: true, 64 + json: async () => ({}), 65 + })) as unknown as typeof fetch; 66 + localStorage.setItem(LOCAL_KEY, 'LocalName'); 67 + 68 + const id = await fetchUserIdentity(); 69 + expect(id).toEqual({ name: 'LocalName' }); 70 + expect(id?.login).toBeUndefined(); 71 + }); 72 + 73 + it('falls through to localStorage when fetch rejects', async () => { 74 + globalThis.fetch = vi.fn(async () => { throw new Error('network'); }) as unknown as typeof fetch; 75 + localStorage.setItem(LOCAL_KEY, 'Offline User'); 76 + 77 + const id = await fetchUserIdentity(); 78 + expect(id).toEqual({ name: 'Offline User' }); 79 + }); 80 + 81 + it('returns null when both sources fail', async () => { 82 + globalThis.fetch = vi.fn(async () => { throw new Error('network'); }) as unknown as typeof fetch; 83 + 84 + const id = await fetchUserIdentity(); 85 + expect(id).toBeNull(); 86 + }); 87 + 88 + it('returns null when /api/me returns non-ok status and no localStorage', async () => { 89 + globalThis.fetch = vi.fn(async () => ({ ok: false, json: async () => ({}) })) as unknown as typeof fetch; 90 + 91 + const id = await fetchUserIdentity(); 92 + expect(id).toBeNull(); 93 + }); 94 + 95 + it('trims whitespace-only localStorage values to null', async () => { 96 + globalThis.fetch = vi.fn(async () => { throw new Error('x'); }) as unknown as typeof fetch; 97 + localStorage.setItem(LOCAL_KEY, ' '); 98 + 99 + const id = await fetchUserIdentity(); 100 + expect(id).toBeNull(); 101 + }); 102 + }); 103 + 104 + describe('getUserIdentityCached', () => { 105 + let originalFetch: typeof fetch; 106 + 107 + beforeEach(() => { 108 + originalFetch = globalThis.fetch; 109 + localStorage.clear(); 110 + __resetUserIdentityCacheForTest(); 111 + }); 112 + 113 + afterEach(() => { 114 + globalThis.fetch = originalFetch; 115 + }); 116 + 117 + it('memoizes the promise across calls', async () => { 118 + const fetchMock = vi.fn(async () => ({ 119 + ok: true, 120 + json: async () => ({ login: 'cached@example.com', name: 'Cached' }), 121 + })) as unknown as typeof fetch; 122 + globalThis.fetch = fetchMock; 123 + 124 + const a = getUserIdentityCached(); 125 + const b = getUserIdentityCached(); 126 + expect(a).toBe(b); 127 + 128 + await a; 129 + await b; 130 + // Only one network call regardless of how many consumers awaited. 131 + expect((fetchMock as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1); 132 + }); 133 + }); 134 + 135 + describe('buildSystemMessage — userIdentity (#678)', () => { 136 + it('injects name-only identity when no login present', () => { 137 + const msg = buildSystemMessage('', '', { userIdentity: { name: 'Alice' } }); 138 + expect(msg).toContain('The signed-in user is "Alice"'); 139 + expect(msg).not.toContain('('); 140 + }); 141 + 142 + it('injects name + login when both present and differ', () => { 143 + const msg = buildSystemMessage('', '', { 144 + userIdentity: { name: 'Scott', login: 'scott@github' }, 145 + }); 146 + expect(msg).toContain('The signed-in user is "Scott" (scott@github)'); 147 + }); 148 + 149 + it('omits duplicate parenthetical when name equals login', () => { 150 + const msg = buildSystemMessage('', '', { 151 + userIdentity: { name: 'same@id', login: 'same@id' }, 152 + }); 153 + expect(msg).toContain('The signed-in user is "same@id"'); 154 + expect(msg).not.toContain('(same@id)'); 155 + }); 156 + 157 + it('omits identity line entirely when userIdentity is null', () => { 158 + const msg = buildSystemMessage('', '', { userIdentity: null }); 159 + expect(msg).not.toContain('signed-in user'); 160 + }); 161 + 162 + it('omits identity line when userIdentity is undefined (default)', () => { 163 + const msg = buildSystemMessage('Title', 'Content', { editorType: 'doc' }); 164 + expect(msg).not.toContain('signed-in user'); 165 + }); 166 + 167 + it('preserves other options alongside identity', () => { 168 + const msg = buildSystemMessage('My Report', 'Hello', { 169 + editorType: 'doc', 170 + actionsEnabled: true, 171 + userIdentity: { name: 'Bob' }, 172 + }); 173 + expect(msg).toContain('My Report'); 174 + expect(msg).toContain('Hello'); 175 + expect(msg).toContain('Bob'); 176 + expect(msg).toContain('doc_insert'); // actions still present 177 + }); 178 + 179 + it('works across editor types', () => { 180 + const sheet = buildSystemMessage('Budget', '', { 181 + editorType: 'sheet', 182 + userIdentity: { name: 'Ada' }, 183 + }); 184 + expect(sheet).toContain('Ada'); 185 + expect(sheet).toContain('spreadsheet'); 186 + }); 187 + 188 + it('empty-name identity is ignored (no broken "is" sentence)', () => { 189 + const msg = buildSystemMessage('', '', { userIdentity: { name: '' } }); 190 + expect(msg).not.toContain('signed-in user'); 191 + }); 192 + });