diff --git a/apps/x/LIVE_NOTE.md b/apps/x/LIVE_NOTE.md index 2fc43786..fe31d019 100644 --- a/apps/x/LIVE_NOTE.md +++ b/apps/x/LIVE_NOTE.md @@ -175,7 +175,7 @@ Internal trigger enum (`LiveNoteTriggerType`) is `'manual' | 'cron' | 'window' | `buildMessage` always emits a `**Trigger:**` paragraph in the agent's run message — one paragraph per kind. `manual` and the two timed variants (`cron`, `window`) include any optional `context` as a `**Context:**` block. `event` includes the eventMatchCriteria + payload + Pass 2 decision directive (no `**Context:**`; the payload *is* the context). -This lets the user-authored objective branch on trigger kind when warranted (the canonical example is the Today.md emails section: cron/window scans `gmail_sync/` from scratch, event integrates the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". +This lets the user-authored objective branch on trigger kind when warranted (for example, an email digest can scan `gmail_sync/` from scratch on cron/window runs, while event runs integrate just the new thread). The skill teaches the pattern under "Per-trigger guidance (advanced)". ### Run flow (`runLiveNoteAgent`) @@ -254,17 +254,15 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know --- -## Daily-Note Template & Migrations +## Default Note Policy -`Today.md` is the canonical demo of what a live note can do. It ships with one objective covering an Overview / Calendar / Emails / What you missed / Priorities layout — driven by three windows and an event-match criterion for in-day signals. +Rowboat no longer creates a default `Today.md` live dashboard for new users. Live notes are user-created notes with an explicit `live:` frontmatter block. -**Versioning** — `packages/core/src/knowledge/ensure_daily_note.ts` carries a `CANONICAL_DAILY_NOTE_VERSION` constant and a `templateVersion` scalar in the frontmatter. On app start, `ensureDailyNote()`: +**Deprecated Today.md migration** — `packages/core/src/knowledge/deprecate_today_note.ts` runs once per workspace on app start: -- File missing → fresh write at canonical version. -- File at-or-above canonical → no-op. -- File below canonical → rename existing to `Today.md.bkp.` (which doesn't end in `.md`, so the scheduler/event router skip it), then write the canonical template body-from-scratch (live notes regenerate their own body). - -The bump from v1 (the old `track:` array model) to v2 (the live-note rewrite) is handled by this same path. Pre-v2 notes get backed up and replaced. +- File missing → mark processed and do nothing. +- File present → set `live.active: false` if a `live:` block exists, prepend a user-facing deprecation notice once, and preserve the note body. +- Future launches → no-op via `config/today-note-deprecation.json`, so a user who re-enables the note is not paused again. --- @@ -393,7 +391,7 @@ Conventions: | Run orchestrator (`runLiveNoteAgent`, `buildMessage`) | `packages/core/src/knowledge/live-note/runner.ts` | | Live-note agent definition (`LIVE_NOTE_AGENT_INSTRUCTIONS`, `buildLiveNoteAgent`) | `packages/core/src/knowledge/live-note/agent.ts` | | Live-note bus (pub-sub for lifecycle events) | `packages/core/src/knowledge/live-note/bus.ts` | -| Daily-note template + version migration | `packages/core/src/knowledge/ensure_daily_note.ts` | +| Deprecated Today.md one-time migration | `packages/core/src/knowledge/deprecate_today_note.ts` | | Gmail event producer | `packages/core/src/knowledge/sync_gmail.ts` | | Calendar event producer + digest | `packages/core/src/knowledge/sync_calendar.ts` | | Copilot skill | `packages/core/src/application/assistant/skills/live-note/skill.ts` | diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b4728739..78f8b55e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -47,6 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -482,6 +483,20 @@ export function setupIpcHandlers() { 'workspace:remove': async (_event, args) => { return workspace.remove(args.path, args.opts); }, + 'gmail:getImportant': async (_event, args) => { + return listImportantThreads({ cursor: args.cursor, limit: args.limit }); + }, + 'gmail:getEverythingElse': async (_event, args) => { + return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit }); + }, + 'gmail:triggerSync': async () => { + triggerGmailSync(); + return {}; + }, + 'gmail:saveMessageHeight': async (_event, args) => { + saveMessageBodyHeight(args.threadId, args.messageId, args.height); + return {}; + }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, diff --git a/apps/x/apps/renderer/DESIGN_LANGUAGE.md b/apps/x/apps/renderer/DESIGN_LANGUAGE.md new file mode 100644 index 00000000..cf412593 --- /dev/null +++ b/apps/x/apps/renderer/DESIGN_LANGUAGE.md @@ -0,0 +1,41 @@ +# Rowboat Design Language + +Rowboat should feel like a command center for people who live in notes, agents, email, meetings, and files all day. The launch direction is quiet, fast, and prosumer: dense enough for repeated work, warm enough to feel personal, and explicit about what the AI is doing. + +## Principles + +1. **Calm density** + Keep the interface compact and scannable. Use tighter rows, restrained borders, and low-contrast panels so users can keep many contexts open without the app feeling heavy. + +2. **Command first** + Primary actions should feel like instant commands, not marketing CTAs. Side navigation, search, model selection, and composer controls use compact icon-led affordances with clear hover and selected states. + +3. **Visible work state** + AI actions, sync, saving, meeting capture, and background tasks need clear status surfaces. Prefer small persistent indicators over large banners. + +4. **Notes as the canvas** + The editor and conversation stay visually dominant. Chrome is supportive, not decorative. Avoid nested cards and oversized empty states in work surfaces. + +5. **Neutral precision** + The palette follows the dev color system: white and graphite surfaces, black/white primary actions, neutral command tools, and reserved semantic colors for destructive and chart states. + +## Tokens + +- Radius: `8px` for controls and cards, smaller where density matters. +- Backgrounds: dev defaults in light and dark mode. +- Borders: one-step darker than surfaces, quiet enough to separate panels without tinting them. +- Shadows: reserved for the composer, menus, dialogs, and active segmented controls. +- Type: system sans with tabular-feeling OpenType features enabled; no negative tracking. +- Accent use: primary and command affordances use the neutral dev palette. Extra hues are reserved for semantic states and charts. + +## Core Surfaces + +- **Sidebar:** persistent workflow switcher with calm selected states. Quick-action icons use neutral ink from the dev palette. +- **Titlebar/tabs:** slim, scan-first navigation. Active tabs get a bottom signal line, not a bulky filled pill. +- **Composer:** the highest-emphasis control outside the active canvas. It is slightly raised, flat, bordered by the primary tone, and sharp enough to feel like an input terminal. +- **Messages:** user messages are compact structured blocks; assistant messages remain full-width and readable. +- **Status:** sync, saving, recording, and task activity stay small but always visible near the surface they affect. + +## Launch Positioning + +The visual story is: **Rowboat is the personal AI workspace for people whose work already spans meetings, mail, notes, browser tasks, and agents.** It should feel closer to a focused desktop tool than a chat website. diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 5c1eabb2..6c6a2e25 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -58,6 +58,886 @@ background-image: radial-gradient(circle, oklch(0.7 0 0 / 0.06) 1px, transparent 1px); } +.gmail-shell { + --gm-bg: #0f0f12; + --gm-bg-card: #131317; + --gm-bg-input: #1c1c20; + --gm-bg-row-hover: #16161a; + --gm-bg-row-selected: #1a1620; + --gm-bg-row-selected-hover: #1d1825; + --gm-bg-iframe: #fafafa; + --gm-bg-pill: #1c1c20; + --gm-bg-pill-hover: #232328; + --gm-text: #e4e4e7; + --gm-text-strong: #fafafa; + --gm-text-muted: #71717a; + --gm-text-faint: #52525b; + --gm-text-body: #d4d4d8; + --gm-border: #1f1f24; + --gm-border-strong: #2e2e35; + --gm-accent: #a78bfa; + --gm-accent-hover: #b9a6ff; + --gm-accent-glow: rgba(167, 139, 250, 0.45); + --gm-accent-fg: #18181b; + --gm-icon-hover-bg: #1f1f24; + --gm-placeholder: #52525b; + + display: flex; + height: 100%; + min-height: 0; + width: 100%; + overflow: hidden; + background: var(--gm-bg); + color: var(--gm-text); + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-feature-settings: "ss01", "cv11"; +} + +.light .gmail-shell { + --gm-bg: #ffffff; + --gm-bg-card: #ffffff; + --gm-bg-input: #f4f4f7; + --gm-bg-row-hover: #f7f7f9; + --gm-bg-row-selected: #f5f0ff; + --gm-bg-row-selected-hover: #ece4ff; + --gm-bg-iframe: #ffffff; + --gm-bg-pill: #ffffff; + --gm-bg-pill-hover: #f4f4f7; + --gm-text: #27272a; + --gm-text-strong: #09090b; + --gm-text-muted: #71717a; + --gm-text-faint: #a1a1aa; + --gm-text-body: #3f3f46; + --gm-border: #e4e4e7; + --gm-border-strong: #d4d4d8; + --gm-accent: #7c3aed; + --gm-accent-hover: #6d28d9; + --gm-accent-glow: rgba(124, 58, 237, 0.3); + --gm-accent-fg: #ffffff; + --gm-icon-hover-bg: #f4f4f7; + --gm-placeholder: #a1a1aa; +} + +.gmail-main { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; + padding: 0; +} + +.gmail-topbar { + display: flex; + align-items: center; + gap: 12px; + height: 52px; + padding: 0 20px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-search { + display: flex; + align-items: center; + gap: 10px; + width: min(520px, 100%); + height: 32px; + padding: 0 12px; + border-radius: 8px; + background: var(--gm-bg-input); + color: var(--gm-text-muted); +} + +.gmail-search input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font-size: 13px; + letter-spacing: 0.01em; +} + +.gmail-search input::placeholder { + color: var(--gm-placeholder); +} + +.gmail-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-icon-button:hover { + background: var(--gm-icon-hover-bg); + color: var(--gm-text); +} + +.gmail-list { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + flex: 1; + overflow: auto; + background: var(--gm-bg); + border: none; + border-radius: 0; +} + +.gmail-row-group { + display: flex; + flex-direction: column; +} + +.gmail-list-header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + justify-content: space-between; + height: 32px; + padding: 0 24px; + align-items: center; + background: var(--gm-bg); + border-bottom: 1px solid var(--gm-border); + color: var(--gm-text-faint); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.gmail-section { + display: flex; + flex-direction: column; +} + +.gmail-section + .gmail-section { + margin-top: 28px; +} + +.gmail-section-sentinel { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + color: var(--gm-text-faint); +} + +.gmail-row { + display: grid; + grid-template-columns: 12px minmax(140px, 0.22fr) minmax(0, 1fr) 60px; + align-items: center; + gap: 16px; + width: 100%; + min-height: 40px; + padding: 0 24px; + border: none; + background: transparent; + color: var(--gm-text-muted); + text-align: left; + cursor: pointer; + font-family: inherit; + transition: background 120ms ease; +} + +.gmail-row:hover { + background: var(--gm-bg-row-hover); + box-shadow: none; +} + +.upcoming-event-row { + background-color: transparent; + transition: background-color 120ms ease; +} + +.upcoming-event-row:hover { + background-color: var(--gm-bg-pill-hover); +} + +.gmail-row-selected { + background: var(--gm-bg-row-selected); + box-shadow: inset 2px 0 0 var(--gm-accent); +} + +.gmail-row-selected:hover { + background: var(--gm-bg-row-selected-hover); +} + +.gmail-row-unread { + color: var(--gm-text); +} + +.gmail-row-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: transparent; +} + +.gmail-row-unread .gmail-row-dot { + background: var(--gm-accent); + box-shadow: 0 0 8px var(--gm-accent-glow); +} + +.gmail-row-sender, +.gmail-row-content strong, +.gmail-row-date { + font-size: 13px; + font-weight: 400; + letter-spacing: -0.005em; +} + +.gmail-row-unread .gmail-row-sender, +.gmail-row-unread .gmail-row-content strong { + font-weight: 600; + color: var(--gm-text-strong); +} + +.gmail-row-unread .gmail-row-date { + color: var(--gm-text); +} + +.gmail-row-sender, +.gmail-row-content, +.gmail-row-content span { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.gmail-row-content { + display: flex; + gap: 8px; + color: var(--gm-text-faint); + font-size: 13px; +} + +.gmail-row-content strong { + flex-shrink: 0; + color: inherit; + font-weight: 400; +} + +.gmail-row-date { + justify-self: end; + color: var(--gm-text-faint); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.gmail-detail { + display: flex; + min-width: 0; + flex-direction: column; + background: transparent; +} + +.gmail-detail-inline { + background: var(--gm-bg-card); + border-top: 1px solid var(--gm-border); + border-bottom: 1px solid var(--gm-border); + box-shadow: inset 2px 0 0 var(--gm-accent); +} + +.gmail-detail-hidden { + display: none; +} + +.gmail-detail-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: 48px; + padding: 0 24px; + border-bottom: 1px solid var(--gm-border); + background: transparent; +} + +.gmail-thread-subject-inline { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--gm-text-strong); + font-size: 15px; + font-weight: 500; + letter-spacing: -0.01em; +} + +.gmail-thread-body { + padding: 20px 24px 28px; + background: transparent; +} + +.gmail-thread-summary { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-thread-summary-label { + display: block; + margin-bottom: 6px; + color: var(--gm-text-faint); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.gmail-thread-summary-text { + display: block; + color: var(--gm-text); + font-size: 13px; + line-height: 1.55; +} + +.gmail-message-stack { + display: flex; + flex-direction: column; + gap: 12px; +} + +.gmail-message { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 12px; + padding: 12px 0; + border-top: 1px solid var(--gm-border); +} + +.gmail-message:first-child { + border-top: 0; + padding-top: 4px; +} + +.gmail-message-header { + display: block; + width: 100%; + padding: 0; + margin: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.gmail-message-snippet { + margin-top: 2px; + color: var(--gm-text-muted); + font-size: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.gmail-message:not(.gmail-message-expanded) .gmail-message-header:hover .gmail-message-from strong { + color: var(--gm-accent); +} + +.gmail-message-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + color: #fafafa; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.02em; +} + +.gmail-message-main { + min-width: 0; +} + +.gmail-message-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.gmail-message-from { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.gmail-message-from strong, +.gmail-message-from span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.gmail-message-from strong { + font-size: 13px; + font-weight: 600; + color: var(--gm-text-strong); + letter-spacing: -0.005em; +} + +.gmail-message-from span, +.gmail-message-to, +.gmail-message-date { + color: var(--gm-text-muted); + font-size: 12px; +} + +.gmail-message-date { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.gmail-message-iframe { + display: block; + width: 100%; + max-width: 820px; + margin-top: 12px; + border: 0; + background: var(--gm-bg-iframe); + border-radius: 6px; +} + +.gmail-message-iframe-adaptive { + background: var(--gm-bg-card); +} + +.gmail-message-plain { + max-width: 820px; + margin-top: 12px; + padding: 10px 14px; + background: var(--gm-bg-iframe); + border-radius: 6px; + color: var(--gm-text); +} + +.gmail-message-pre { + margin: 0; + font: 14px/1.6 Arial, sans-serif; + white-space: pre-wrap; + word-wrap: break-word; +} + +.gmail-message-pre-quoted { + margin-top: 12px; + color: var(--gm-text-muted); +} + +.gmail-quote-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 8px; + height: 22px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 4px; + background: var(--gm-bg-pill); + color: var(--gm-text-muted); + font: inherit; + font-size: 12px; + letter-spacing: 0.04em; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.gmail-quote-toggle:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); + border-color: var(--gm-border-strong); +} + +.gmail-quote-toggle[aria-expanded="true"] { + background: var(--gm-bg-row-selected); + color: var(--gm-accent); + border-color: var(--gm-accent); +} + +.gmail-message-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; + max-width: 820px; +} + +.gmail-attachment { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 320px; + padding: 6px 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.gmail-attachment:hover { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-accent); + color: var(--gm-accent); +} + +.gmail-attachment-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: 500; +} + +.gmail-attachment-size { + flex-shrink: 0; + color: var(--gm-text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.gmail-thread-actions { + display: flex; + gap: 8px; + margin: 20px 0 12px 40px; +} + +.gmail-thread-actions button { + display: inline-flex; + align-items: center; + gap: 8px; + height: 30px; + padding: 0 14px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + background: var(--gm-bg-pill); + color: var(--gm-text-body); + font: inherit; + font-size: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.gmail-thread-actions button:hover { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-border-strong); +} + +.gmail-compose-card { + max-width: 720px; + margin-left: 40px; + border: 1px solid var(--gm-border-strong); + border-radius: 8px; + overflow: hidden; + background: var(--gm-bg-card); +} + +.gmail-compose-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 32px; + padding: 0 12px; + background: var(--gm-bg-input); + color: var(--gm-text-body); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + text-transform: uppercase; +} + +.gmail-compose-header button, +.gmail-compose-link { + border: none; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; +} + +.gmail-compose-line { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 12px; + border-bottom: 1px solid var(--gm-border); + color: var(--gm-text-muted); + font-size: 12px; +} + +.gmail-compose-line input { + min-width: 0; + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--gm-text); + font: inherit; +} + +.gmail-compose-toolbar { + display: flex; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; + justify-content: center; +} + +.gmail-compose-link-popover { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--gm-border); + background: var(--gm-bg-input); +} + +.gmail-compose-link-popover input { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--gm-border-strong); + border-radius: 4px; + background: var(--gm-bg-card); + color: var(--gm-text); + font: inherit; + font-size: 12px; + outline: none; +} + +.gmail-compose-link-popover input:focus { + border-color: var(--gm-accent); +} + +.gmail-compose-link-popover button { + height: 26px; + padding: 0 10px; + border: 1px solid var(--gm-border-strong); + border-radius: 4px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.gmail-compose-link-popover button:hover { + background: var(--gm-bg-pill-hover); +} + +.gmail-compose-link-popover-apply { + background: var(--gm-accent) !important; + border-color: var(--gm-accent) !important; + color: var(--gm-accent-fg) !important; + font-weight: 600; +} + +.gmail-compose-link-popover-apply:hover { + background: var(--gm-accent-hover) !important; + border-color: var(--gm-accent-hover) !important; +} + +.gmail-compose-tool { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-compose-tool:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text); +} + +.gmail-compose-tool.is-active { + background: var(--gm-bg-pill-hover); + color: var(--gm-accent); +} + +.gmail-compose-tool-sep { + display: inline-block; + width: 1px; + height: 18px; + margin: 0 6px; + background: var(--gm-border-strong); +} + +.gmail-compose-editor { + display: block; + width: 100%; + max-height: 360px; + overflow-y: auto; +} + +.gmail-compose-content { + outline: none; + min-height: 120px; + padding: 12px; + background: transparent; + color: var(--gm-text); + font: 13px/1.55 inherit; +} + +.gmail-compose-content p { + margin: 0; +} + +.gmail-compose-content p + p, +.gmail-compose-content p + ul, +.gmail-compose-content p + ol, +.gmail-compose-content p + blockquote { + margin-top: 8px; +} + +.gmail-compose-content ul, +.gmail-compose-content ol { + margin: 0; + padding-left: 22px; +} + +.gmail-compose-content ul { + list-style: disc; +} + +.gmail-compose-content ol { + list-style: decimal; +} + +.gmail-compose-content li { + margin: 2px 0; +} + +.gmail-compose-content li > p { + margin: 0; +} + +.gmail-compose-content blockquote { + margin: 4px 0; + padding-left: 12px; + border-left: 2px solid var(--gm-border-strong); + color: var(--gm-text-muted); +} + +.gmail-compose-content a { + color: var(--gm-accent); + text-decoration: underline; +} + +.gmail-compose-content code { + padding: 1px 4px; + border-radius: 3px; + background: var(--gm-bg-pill-hover); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.gmail-compose-content p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--gm-placeholder); + float: left; + height: 0; + pointer-events: none; +} + +.gmail-compose-actions { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-top: 1px solid var(--gm-border); +} + +.gmail-compose-actions-primary { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.gmail-refine-button { + display: inline-flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + height: 30px; + padding: 0 14px; + border: 1px solid var(--gm-border-strong); + border-radius: 6px; + background: var(--gm-bg-pill); + color: var(--gm-text); + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.gmail-refine-button:hover { + background: var(--gm-bg-pill-hover); + border-color: var(--gm-accent); + color: var(--gm-accent); +} + +.gmail-send-button { + display: inline-flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + height: 30px; + padding: 0 14px; + border: 1px solid transparent; + border-radius: 6px; + background: var(--gm-accent); + color: var(--gm-accent-fg); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.gmail-send-button:hover { + background: var(--gm-accent-hover); +} + +.gmail-empty-state { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 0; + background: transparent; + color: var(--gm-text-faint); + font-size: 13px; +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -100,7 +980,7 @@ } :root { - --radius: 0.625rem; + --radius: 0.5rem; --background: var(--bg-color, oklch(1 0 0)); --foreground: var(--text-color, oklch(0.145 0 0)); --card: var(--bg-color, oklch(1 0 0)); @@ -128,13 +1008,21 @@ --sidebar-foreground: var(--text-color, oklch(0.145 0 0)); --sidebar-primary: var(--main-color, oklch(0.205 0 0)); --sidebar-primary-foreground: var(--bg-color, oklch(0.985 0 0)); - --sidebar-accent: var(--sub-color, oklch(0.90 0 0)); + --sidebar-accent: var(--sub-color, oklch(0.9 0 0)); --sidebar-accent-foreground: var(--text-color, oklch(0.205 0 0)); --sidebar-border: var(--sub-alt-color, oklch(0.922 0 0)); --sidebar-ring: var(--main-color, oklch(0.708 0 0)); --scrollbar-track: oklch(0.95 0 0); --scrollbar-thumb: oklch(0.75 0 0); --scrollbar-thumb-hover: oklch(0.65 0 0); + --rowboat-panel: oklch(0.97 0 0); + --rowboat-raised: oklch(1 0 0); + --rowboat-wash: color-mix(in oklab, var(--background) 88%, var(--primary) 12%); + --rowboat-hairline: color-mix(in oklab, var(--border) 78%, var(--foreground) 22%); + --rowboat-command: oklch(0.205 0 0); + --rowboat-attention: oklch(0.646 0.222 41.116); + --rowboat-shadow: 0 1px 2px rgb(31 38 48 / 0.07), 0 18px 40px rgb(31 38 48 / 0.08); + --rowboat-shadow-soft: 0 1px 2px rgb(31 38 48 / 0.05), 0 8px 24px rgb(31 38 48 / 0.06); } .dark { @@ -172,6 +1060,14 @@ --scrollbar-track: oklch(0.2 0 0); --scrollbar-thumb: oklch(0.4 0 0); --scrollbar-thumb-hover: oklch(0.5 0 0); + --rowboat-panel: oklch(0.269 0 0); + --rowboat-raised: oklch(0.205 0 0); + --rowboat-wash: color-mix(in oklab, var(--background) 80%, var(--primary) 20%); + --rowboat-hairline: color-mix(in oklab, var(--border) 70%, var(--foreground) 30%); + --rowboat-command: oklch(0.922 0 0); + --rowboat-attention: oklch(0.769 0.188 70.08); + --rowboat-shadow: 0 1px 2px rgb(0 0 0 / 0.24), 0 22px 44px rgb(0 0 0 / 0.28); + --rowboat-shadow-soft: 0 1px 2px rgb(0 0 0 / 0.2), 0 12px 28px rgb(0 0 0 / 0.18); } @layer base { @@ -183,6 +1079,9 @@ body { @apply bg-background text-foreground; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + text-rendering: optimizeLegibility; } ::-webkit-scrollbar { @@ -204,6 +1103,94 @@ } } +.rowboat-shell { + background: var(--background); +} + +.rowboat-sidebar [data-sidebar="sidebar"] { + background: var(--sidebar); + border-right: 1px solid var(--sidebar-border); +} + +.rowboat-sidebar [data-sidebar="header"] { + gap: 0.625rem; + border-bottom: 1px solid color-mix(in oklab, var(--sidebar-border) 76%, transparent); + background: var(--sidebar); +} + +.rowboat-section-switcher { + border: 1px solid color-mix(in oklab, var(--sidebar-border) 78%, transparent); + background: color-mix(in oklab, var(--sidebar-accent) 58%, var(--sidebar) 42%); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 9%, transparent); +} + +.rowboat-section-switcher button { + letter-spacing: 0; +} + +.rowboat-section-switcher button[class*="shadow-sm"] { + background: var(--rowboat-raised); + box-shadow: var(--rowboat-shadow-soft); +} + +.rowboat-quick-actions button { + min-height: 2rem; +} + +.rowboat-quick-actions button svg { + color: color-mix(in oklab, var(--rowboat-command) 72%, var(--sidebar-foreground) 28%); +} + +.rowboat-tabbar { + background: color-mix(in oklab, var(--background) 78%, var(--muted) 22%); +} + +.rowboat-titlebar { + background: var(--background); + border-bottom-color: color-mix(in oklab, var(--border) 82%, transparent); +} + +.rowboat-tab { + min-height: 2.5rem; + border-bottom: 1px solid transparent; +} + +.rowboat-tab[class*="bg-background"] { + border-bottom-color: var(--primary); + background: var(--rowboat-raised); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--foreground) 8%, transparent); +} + +.rowboat-composer-dock { + box-shadow: 0 -20px 44px color-mix(in oklab, var(--background) 78%, transparent); +} + +.rowboat-chat-input { + border-color: color-mix(in oklab, var(--border) 74%, var(--primary) 26%); + background: var(--rowboat-raised); + box-shadow: var(--rowboat-shadow); +} + +.rowboat-chat-input:focus-within { + border-color: color-mix(in oklab, var(--ring) 76%, var(--border) 24%); + box-shadow: + 0 0 0 1px color-mix(in oklab, var(--ring) 38%, transparent), + var(--rowboat-shadow); +} + +[data-slot="message-content"] { + line-height: 1.62; +} + +.is-user [data-slot="message-content"] { + background: color-mix(in oklab, var(--secondary) 88%, var(--rowboat-wash) 12%); + border: 1px solid color-mix(in oklab, var(--border) 68%, transparent); +} + +.is-assistant [data-slot="message-content"] { + color: color-mix(in oklab, var(--foreground) 94%, var(--muted-foreground) 6%); +} + /* Markdown content base styles for Streamdown/MessageResponse */ @layer components { /* Target elements inside MessageResponse wrapper */ diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7f76e2e9..e6c050b3 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -24,6 +24,8 @@ import { SidebarContentPanel } from '@/components/sidebar-content'; import { SuggestedTopicsView } from '@/components/suggested-topics-view'; import { LiveNotesView } from '@/components/live-notes-view'; import { BgTasksView } from '@/components/bg-tasks-view'; +import { EmailView } from '@/components/email-view'; +import { MeetingsView } from '@/components/meetings-view'; import { SidebarSectionProvider } from '@/contexts/sidebar-context'; import { Conversation, @@ -176,8 +178,10 @@ const TITLEBAR_BUTTONS_COLLAPSED = 1 const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0 const GRAPH_TAB_PATH = '__rowboat_graph_view__' const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__' +const MEETINGS_TAB_PATH = '__rowboat_meetings__' const LIVE_NOTES_TAB_PATH = '__rowboat_live_notes__' const BG_TASKS_TAB_PATH = '__rowboat_bg_tasks__' +const EMAIL_TAB_PATH = '__rowboat_email__' const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__' const clampNumber = (value: number, min: number, max: number) => @@ -307,8 +311,10 @@ const getAncestorDirectoryPaths = (path: string): string[] => { const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH +const isMeetingsTabPath = (path: string) => path === MEETINGS_TAB_PATH const isLiveNotesTabPath = (path: string) => path === LIVE_NOTES_TAB_PATH const isBgTasksTabPath = (path: string) => path === BG_TASKS_TAB_PATH +const isEmailTabPath = (path: string) => path === EMAIL_TAB_PATH const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH const getSuggestedTopicTargetFolder = (category?: string) => { @@ -556,7 +562,9 @@ type ViewState = | { type: 'graph' } | { type: 'task'; name: string } | { type: 'suggested-topics' } + | { type: 'meetings' } | { type: 'live-notes' } + | { type: 'email' } function viewStatesEqual(a: ViewState, b: ViewState): boolean { if (a.type !== b.type) return false @@ -570,12 +578,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean { * Parse a rowboat:// deep link into a ViewState. Returns null if the URL is * malformed or names an unknown target. * - * Shape: rowboat://open?type=&... + * Shape: rowboat://open?type=&... * file: ?type=file&path=knowledge/foo.md * chat: ?type=chat&runId=abc123 (runId optional) * graph: ?type=graph * task: ?type=task&name=daily-brief * suggested-topics: ?type=suggested-topics + * meetings: ?type=meetings * live-notes: ?type=live-notes */ function parseDeepLink(input: string): ViewState | null { @@ -601,6 +610,8 @@ function parseDeepLink(input: string): ViewState | null { } case 'suggested-topics': return { type: 'suggested-topics' } + case 'meetings': + return { type: 'meetings' } case 'live-notes': return { type: 'live-notes' } default: @@ -652,7 +663,7 @@ function ContentHeader({ const isCollapsed = state === "collapsed" return (
(null) const [baseConfigByPath, setBaseConfigByPath] = useState>({}) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ @@ -1039,8 +1055,10 @@ function App() { const getFileTabTitle = useCallback((tab: FileTab) => { if (isGraphTabPath(tab.path)) return 'Graph View' if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics' + if (isMeetingsTabPath(tab.path)) return 'Meetings' if (isLiveNotesTabPath(tab.path)) return 'Live notes' if (isBgTasksTabPath(tab.path)) return 'Background tasks' + if (isEmailTabPath(tab.path)) return 'Email' if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases' if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base' return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path @@ -2753,7 +2771,7 @@ function App() { setActiveFileTabId(existingTab.id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setSelectedPath(path) return } @@ -2762,7 +2780,7 @@ function App() { setActiveFileTabId(id) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setSelectedPath(path) }, [fileTabs, dismissBrowserOverlay]) @@ -2781,32 +2799,62 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) return } if (isSuggestedTopicsTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) return } if (isLiveNotesTabPath(tab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) setIsLiveNotesOpen(true) return } + if (isBgTasksTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(true) + return + } + if (isMeetingsTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(true) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + return + } + if (isEmailTabPath(tab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(true) + return + } setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setSelectedPath(tab.path) }, [fileTabs, isRightPaneMaximized, dismissBrowserOverlay]) const closeFileTab = useCallback((tabId: string) => { const closingTab = fileTabs.find(t => t.id === tabId) - if (closingTab && !isGraphTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { + if (closingTab && !isGraphTabPath(closingTab.path) && !isSuggestedTopicsTabPath(closingTab.path) && !isLiveNotesTabPath(closingTab.path) && !isBgTasksTabPath(closingTab.path) && !isEmailTabPath(closingTab.path) && !isBaseFilePath(closingTab.path)) { removeEditorCacheForPath(closingTab.path) initialContentByPathRef.current.delete(closingTab.path) untitledRenameReadyPathsRef.current.delete(closingTab.path) @@ -2829,7 +2877,7 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) return [] } const idx = prev.findIndex(t => t.id === tabId) @@ -2843,21 +2891,48 @@ function App() { setSelectedPath(null) setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) } else if (isSuggestedTopicsTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + } else if (isMeetingsTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(true) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) } else if (isLiveNotesTabPath(newActiveTab.path)) { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) setIsLiveNotesOpen(true) + } else if (isBgTasksTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(true) + setIsEmailOpen(false) + } else if (isEmailTabPath(newActiveTab.path)) { + setSelectedPath(null) + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(true) } else { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setSelectedPath(newActiveTab.path) } } @@ -2888,12 +2963,15 @@ function App() { dismissBrowserOverlay() handleNewChat() // Left-pane "new chat" should always open full chat view. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen, + meetings: isMeetingsOpen, liveNotes: isLiveNotesOpen, + bgTasks: isBgTasksOpen, + email: isEmailOpen, }) } else { setExpandedFrom(null) @@ -2902,8 +2980,8 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) - }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen]) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + }, [chatTabs, activeChatTabId, dismissBrowserOverlay, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen]) // Sidebar variant: create/switch chat tab without leaving file/graph context. const handleNewChatTabInSidebar = useCallback(() => { @@ -3035,12 +3113,15 @@ function App() { const handleOpenFullScreenChat = useCallback(() => { // Remember where we came from so the close button can return - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) { setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen, + meetings: isMeetingsOpen, liveNotes: isLiveNotesOpen, + bgTasks: isBgTasksOpen, + email: isEmailOpen, }) } dismissBrowserOverlay() @@ -3048,27 +3129,51 @@ function App() { setSelectedPath(null) setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, dismissBrowserOverlay]) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, dismissBrowserOverlay]) const handleCloseFullScreenChat = useCallback(() => { if (expandedFrom) { if (expandedFrom.graph) { setIsGraphOpen(true) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) } else if (expandedFrom.suggestedTopics) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) + } else if (expandedFrom.meetings) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(true) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) } else if (expandedFrom.liveNotes) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) setIsLiveNotesOpen(true) + } else if (expandedFrom.bgTasks) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(true) + setIsEmailOpen(false) + } else if (expandedFrom.email) { + setIsGraphOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(true) } else if (expandedFrom.path) { setIsGraphOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setSelectedPath(expandedFrom.path) } setExpandedFrom(null) @@ -3078,12 +3183,14 @@ function App() { const currentViewState = React.useMemo(() => { if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask } + if (isEmailOpen) return { type: 'email' } + if (isMeetingsOpen) return { type: 'meetings' } if (isLiveNotesOpen) return { type: 'live-notes' } if (isSuggestedTopicsOpen) return { type: 'suggested-topics' } if (selectedPath) return { type: 'file', path: selectedPath } if (isGraphOpen) return { type: 'graph' } return { type: 'chat', runId } - }, [selectedBackgroundTask, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) + }, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId]) const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => { const last = stack[stack.length - 1] @@ -3151,6 +3258,17 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureMeetingsFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isMeetingsTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: MEETINGS_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + const ensureBgTasksFileTab = useCallback(() => { const existing = fileTabs.find((tab) => isBgTasksTabPath(tab.path)) if (existing) { @@ -3162,12 +3280,38 @@ function App() { setActiveFileTabId(id) }, [fileTabs]) + const ensureEmailFileTab = useCallback(() => { + const existing = fileTabs.find((tab) => isEmailTabPath(tab.path)) + if (existing) { + setActiveFileTabId(existing.id) + return + } + const id = newFileTabId() + setFileTabs((prev) => [...prev, { id, path: EMAIL_TAB_PATH }]) + setActiveFileTabId(id) + }, [fileTabs]) + + const openEmailView = useCallback(() => { + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setSelectedBackgroundTask(null) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setIsEmailOpen(true) + ensureEmailFileTab() + }, [ensureEmailFileTab]) + const openBgTasksView = useCallback(() => { setSelectedPath(null) setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3175,6 +3319,21 @@ function App() { ensureBgTasksFileTab() }, [ensureBgTasksFileTab]) + const openMeetingsView = useCallback(() => { + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(true) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + setSelectedBackgroundTask(null) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + ensureMeetingsFileTab() + }, [ensureMeetingsFileTab]) + const applyViewState = useCallback(async (view: ViewState) => { switch (view.type) { case 'file': @@ -3184,7 +3343,7 @@ function App() { // visible in the middle pane. setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setExpandedFrom(null) // Preserve split vs knowledge-max mode when navigating knowledge files. // Only exit chat-only maximize, because that would hide the selected file. @@ -3199,7 +3358,7 @@ function App() { setSelectedPath(null) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setExpandedFrom(null) setIsGraphOpen(true) ensureGraphFileTab() @@ -3212,7 +3371,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) setExpandedFrom(null) setIsRightPaneMaximized(false) setSelectedBackgroundTask(view.name) @@ -3225,9 +3384,23 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(true) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) ensureSuggestedTopicsFileTab() return + case 'meetings': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(true) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) + ensureMeetingsFileTab() + return case 'live-notes': setSelectedPath(null) setIsGraphOpen(false) @@ -3236,9 +3409,26 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(false) setIsLiveNotesOpen(true) ensureLiveNotesFileTab() return + case 'email': + setSelectedPath(null) + setIsGraphOpen(false) + setIsBrowserOpen(false) + setExpandedFrom(null) + setIsRightPaneMaximized(false) + setSelectedBackgroundTask(null) + setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) + setIsLiveNotesOpen(false) + setIsBgTasksOpen(false) + setIsEmailOpen(true) + ensureEmailFileTab() + return case 'chat': setSelectedPath(null) setIsGraphOpen(false) @@ -3247,7 +3437,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) - setIsLiveNotesOpen(false); setIsBgTasksOpen(false) + setIsMeetingsOpen(false); setIsLiveNotesOpen(false); setIsBgTasksOpen(false); setIsEmailOpen(false) if (view.runId) { await loadRun(view.runId) } else { @@ -3255,7 +3445,7 @@ function App() { } return } - }, [ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) + }, [ensureEmailFileTab, ensureMeetingsFileTab, ensureLiveNotesFileTab, ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun]) const navigateToView = useCallback(async (nextView: ViewState) => { const current = currentViewState @@ -3577,7 +3767,7 @@ function App() { }, []) // Keyboard shortcut: Ctrl+L to toggle main chat view - const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask && !isBrowserOpen + const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask && !isBrowserOpen useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'l') { @@ -3650,19 +3840,23 @@ function App() { const handleTabKeyDown = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey if (!mod) return - const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && isChatSidebarOpen) + const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && isChatSidebarOpen) const targetPane: ShortcutPane = rightPaneAvailable ? (isRightPaneMaximized ? 'right' : activeShortcutPane) : 'left' - const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) + const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : isSuggestedTopicsOpen ? SUGGESTED_TOPICS_TAB_PATH + : isMeetingsOpen + ? MEETINGS_TAB_PATH : isLiveNotesOpen ? LIVE_NOTES_TAB_PATH : isBgTasksOpen ? BG_TASKS_TAB_PATH + : isEmailOpen + ? EMAIL_TAB_PATH : selectedPath const targetFileTabId = activeFileTabId ?? ( selectedKnowledgePath @@ -3717,7 +3911,7 @@ function App() { } document.addEventListener('keydown', handleTabKeyDown) return () => document.removeEventListener('keydown', handleTabKeyDown) - }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isLiveNotesOpen, isBgTasksOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) + }, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isEmailOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab]) const toggleExpand = (path: string, kind: 'file' | 'dir') => { if (kind === 'file') { @@ -3742,7 +3936,7 @@ function App() { }), }, })) - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -3868,14 +4062,14 @@ function App() { }, openGraph: () => { // From chat-only landing state, open graph directly in full knowledge view. - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } void navigateToView({ type: 'graph' }) }, openBases: () => { - if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedBackgroundTask) { + if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedBackgroundTask) { setIsChatSidebarOpen(false) setIsRightPaneMaximized(false) } @@ -4471,7 +4665,7 @@ function App() { const selectedTask = selectedBackgroundTask ? backgroundTasks.find(t => t.name === selectedBackgroundTask) : null - const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) + const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized const shouldCollapseLeftPane = isRightPaneOnlyMode const openMarkdownTabs = React.useMemo(() => { @@ -4488,11 +4682,11 @@ function App() { return ( { - if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen) { + if (section === 'knowledge' && !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen) { void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH }) } }}> -
+
{/* Content sidebar with SidebarProvider for collapse functionality */} { cancelRecordingIfActive() - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { setIsChatSidebarOpen(true) } @@ -4532,7 +4726,7 @@ function App() { return } // In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar. - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t)) loadRun(runIdToLoad) return @@ -4556,14 +4750,14 @@ function App() { } else { // Only one tab, reset it to new chat setChatTabs([{ id: tabForRun.id, runId: null }]) - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { handleNewChat() } else { void navigateToView({ type: 'chat', runId: null }) } } } else if (runId === runIdToDelete) { - if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || isBrowserOpen) { + if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || isBrowserOpen) { setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t)) handleNewChat() } else { @@ -4583,20 +4777,19 @@ function App() { selectedBackgroundTask={selectedBackgroundTask} onNewChat={handleNewChatTab} onOpenSearch={() => setIsSearchOpen(true)} - meetingState={meetingTranscription.state} - meetingSummarizing={meetingSummarizing} - meetingAvailable={voiceAvailable} - onToggleMeeting={() => { void handleToggleMeeting() }} isSearchOpen={isSearchOpen} - isMeetingActionActive={showMeetingPermissions || meetingSummarizing || meetingTranscription.state !== 'idle'} isBrowserOpen={isBrowserOpen} onToggleBrowser={handleToggleBrowser} isSuggestedTopicsOpen={isSuggestedTopicsOpen} onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })} + isMeetingsOpen={isMeetingsOpen} + onOpenMeetings={openMeetingsView} isLiveNotesOpen={isLiveNotesOpen} onOpenLiveNotes={() => void navigateToView({ type: 'live-notes' })} isBgTasksOpen={isBgTasksOpen} onOpenBgTasks={openBgTasksView} + isEmailOpen={isEmailOpen} + onOpenEmail={openEmailView} /> - {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen) && fileTabs.length >= 1 ? ( + {(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen) && fileTabs.length >= 1 ? ( t.id} onSwitchTab={switchFileTab} onCloseTab={closeFileTab} - allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isLiveNotesOpen || isBgTasksOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} + allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || isMeetingsOpen || isLiveNotesOpen || isBgTasksOpen || isEmailOpen || (selectedPath != null && isBaseFilePath(selectedPath)))} /> ) : ( Version history )} - {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isLiveNotesOpen && !isBgTasksOpen && !selectedTask && !isBrowserOpen && ( + {!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isMeetingsOpen && !isLiveNotesOpen && !isBgTasksOpen && !isEmailOpen && !selectedTask && !isBrowserOpen && (
+ ) : isMeetingsOpen ? ( +
+ navigateToFile(path)} + onTakeMeetingNotes={() => { void handleToggleMeeting() }} + meetingState={meetingTranscription.state} + meetingSummarizing={meetingSummarizing} + /> +
) : isLiveNotesOpen ? (
+ ) : isEmailOpen ? ( +
+ +
) : selectedPath && isBaseFilePath(selectedPath) ? (
-
+
{!hasConversation && ( diff --git a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx index bb972eff..3ef3e3ab 100644 --- a/apps/x/apps/renderer/src/components/bg-tasks-view.tsx +++ b/apps/x/apps/renderer/src/components/bg-tasks-view.tsx @@ -18,6 +18,7 @@ import { toast } from '@/lib/toast' import type { ConversationItem } from '@/lib/chat-conversation' import { runLogToConversation } from '@/lib/run-to-conversation' import { CompactConversation } from '@/components/compact-conversation' +import { RichMarkdownViewer } from '@/components/rich-markdown-viewer' // --------------------------------------------------------------------------- // Trigger helpers (inlined; extract to shared as a follow-up) @@ -560,9 +561,7 @@ function OutputPane({ slug, taskName, refreshKey }: { slug: string; taskName: st ) : viewSource ? (
{body}
) : ( - - {body} - + )}
diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 9d552905..ccd96805 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -434,7 +434,7 @@ function ChatInputInner({ }, [addFiles, isActive]) return ( -
+
{attachments.length > 0 && (
{attachments.map((attachment) => { diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx new file mode 100644 index 00000000..674547e0 --- /dev/null +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -0,0 +1,1226 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Paperclip, Quote, RefreshCw, Reply, Search, Send, Sparkles, Strikethrough } from 'lucide-react' +import { useEditor, EditorContent, type Editor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Link from '@tiptap/extension-link' +import Placeholder from '@tiptap/extension-placeholder' +import type { blocks } from '@x/shared' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' +import { useTheme } from '@/contexts/theme-context' + +type GmailThread = blocks.GmailThread +type GmailThreadMessage = blocks.GmailThreadMessage + +function formatInboxTime(value?: string): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.round(diffMs / 60000) + if (diffMin < 1) return 'now' + if (diffMin < 60) return `${diffMin}m` + const sameDay = date.toDateString() === now.toDateString() + if (sameDay) return `${Math.round(diffMin / 60)}h` + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return 'Yest' + if (diffMs < 7 * 24 * 60 * 60 * 1000) return date.toLocaleDateString([], { weekday: 'short' }) + if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: '2-digit' }) +} + +function formatFullDate(value?: string): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString([], { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +function extractName(from?: string): string { + if (!from) return 'Unknown' + const match = from.match(/^([^<]+)\s]+@[^<>\s]+)>?/)?.[1] ?? from + return address.replace(/@.*/, '').replace(/[._+]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +} + +function extractAddress(from?: string): string { + if (!from) return '' + return from.match(/<([^>]+)>/)?.[1] ?? from +} + +function snippet(text?: string): string { + return (text || '').replace(/\s+/g, ' ').trim().slice(0, 180) +} + +function getInitial(from?: string): string { + return (extractName(from)[0] || '?').toUpperCase() +} + +const AVATAR_COLORS = ['#1a73e8', '#e8453c', '#34a853', '#8430ce', '#f29900', '#00796b', '#c62828', '#1565c0'] + +function avatarColor(from?: string): string { + const value = from || 'unknown' + let hash = 0 + for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) >>> 0 + return AVATAR_COLORS[hash % AVATAR_COLORS.length] +} + +function latestMessage(thread: GmailThread): GmailThreadMessage | undefined { + return thread.messages[thread.messages.length - 1] +} + +const PREFETCH_HOVER_MS = 180 +const PREFETCH_MAX_IMAGES_PER_THREAD = 12 + +function extractImageUrls(html: string): string[] { + const urls: string[] = [] + const re = /]*\bsrc=["']([^"']+)["']/gi + let match: RegExpExecArray | null + while ((match = re.exec(html)) !== null) { + const url = match[1] + if (url && (url.startsWith('http://') || url.startsWith('https://'))) { + urls.push(url) + } + } + return urls +} + +function prefetchThreadImages(thread: GmailThread): void { + const seen = new Set() + for (const msg of thread.messages) { + if (!msg.bodyHtml) continue + for (const url of extractImageUrls(msg.bodyHtml)) { + if (seen.has(url)) continue + seen.add(url) + if (seen.size > PREFETCH_MAX_IMAGES_PER_THREAD) return + const img = new Image() + img.decoding = 'async' + img.referrerPolicy = 'no-referrer' + img.src = url + } + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function splitPlainTextQuote(text: string): { visible: string; quoted: string | null } { + const re = /(?:^|\n)On\s+.+?\swrote:\s*(?:\n|$)/ + const match = re.exec(text) + if (!match) return { visible: text, quoted: null } + const start = match.index === 0 ? 0 : match.index + 1 + const visible = text.slice(0, start).trimEnd() + const quoted = text.slice(start) + if (!quoted.trim()) return { visible: text, quoted: null } + return { visible, quoted } +} + +// True if the HTML — after stripping quoted/hidden content — defines its +// own visual layout (real images, tables, explicit backgrounds). Unstyled +// HTML (Gmail replies, Outlook one-liners wrapped in MsoNormal boilerplate, +// outreach emails with only a tracking pixel, reply HTML whose only image +// lives inside the inline-quoted thread) gets an iframe that adapts to the +// app theme; styled HTML keeps the white "paper" look so newsletters / +// branded designs render as their senders intended. +function isStyledHtml(html: string): boolean { + const doc = new DOMParser().parseFromString(html, 'text/html') + doc.querySelectorAll('.gmail_quote, .gmail_attr, blockquote[type="cite"]').forEach((n) => n.remove()) + if (doc.querySelector('table')) return true + for (const img of Array.from(doc.querySelectorAll('img'))) { + const w = parseInt(img.getAttribute('width') || '0', 10) + const h = parseInt(img.getAttribute('height') || '0', 10) + if (w === 1 && h === 1) continue + const style = img.getAttribute('style') || '' + if (/display\s*:\s*none/i.test(style)) continue + if (/visibility\s*:\s*hidden/i.test(style)) continue + return true + } + const visible = doc.body?.innerHTML || '' + if (/bgcolor\s*=/i.test(visible)) return true + if (/background-(color|image)\s*:/i.test(visible)) return true + return false +} + +function buildEmailDocument( + html: string, + opts: { theme: 'light' | 'dark'; adaptToTheme: boolean }, +): string { + const useDark = opts.theme === 'dark' && opts.adaptToTheme + const colorScheme = opts.theme === 'dark' ? 'light dark' : 'light' + const bodyColor = useDark ? '#d4d4d8' : '#202124' + const linkColor = useDark ? '#a78bfa' : '#1a73e8' + const quoteBorder = useDark ? '#2e2e35' : '#dadce0' + const quoteColor = useDark ? '#71717a' : '#5f6368' + return ` + + + + + +${html}` +} + +function MessageBody({ message, threadId }: { message: GmailThreadMessage; threadId: string }) { + const isPlainText = !(message.bodyHtml && message.bodyHtml.trim()) + return isPlainText + ? + : +} + +function PlainTextBody({ message }: { message: GmailThreadMessage }) { + const text = (message.body || '(No message body)').trim() + const { visible, quoted } = splitPlainTextQuote(text) + const [showQuote, setShowQuote] = useState(false) + return ( + <> +
+
{visible}
+ {quoted && showQuote &&
{quoted}
} +
+ {quoted && ( + + )} + {message.attachments && message.attachments.length > 0 && ( + + )} + + ) +} + +function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; threadId: string }) { + const { resolvedTheme } = useTheme() + const iframeRef = useRef(null) + const observerRef = useRef(null) + const saveTimerRef = useRef | null>(null) + const lastSavedHeightRef = useRef(message.bodyHeight ?? 0) + const [height, setHeight] = useState(message.bodyHeight ?? 80) + const [hasQuote, setHasQuote] = useState(false) + const [showQuotes, setShowQuotes] = useState(false) + + const adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml]) + const srcDoc = useMemo( + () => buildEmailDocument(message.bodyHtml!, { theme: resolvedTheme, adaptToTheme }), + [message.bodyHtml, resolvedTheme, adaptToTheme], + ) + + const handleLoad = useCallback(() => { + const iframe = iframeRef.current + const doc = iframe?.contentDocument + if (!doc?.body) return + setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]')) + const measure = () => { + // Measure off body only. documentElement.scrollHeight stretches to fill + // the iframe viewport, so once we size the iframe up (e.g. user expanded + // the quote) it never shrinks back when the body collapses. The body's + // own padding-bottom + last-child margin reset (see buildEmailDocument) + // already prevent under-reporting from collapsed bottom margins. + const next = Math.max(40, doc.body.scrollHeight, doc.body.offsetHeight) + setHeight((current) => (current === next ? current : next)) + if (!message.id) return + if (Math.abs(next - lastSavedHeightRef.current) < 4) return + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => { + lastSavedHeightRef.current = next + void window.ipc.invoke('gmail:saveMessageHeight', { + threadId, + messageId: message.id!, + height: next, + }).catch(() => {}) + }, 500) + } + measure() + observerRef.current?.disconnect() + if (typeof ResizeObserver !== 'undefined') { + observerRef.current = new ResizeObserver(measure) + observerRef.current.observe(doc.body) + } + }, [message.id, threadId]) + + const toggleQuotes = useCallback(() => { + setShowQuotes((prev) => { + const next = !prev + const doc = iframeRef.current?.contentDocument + if (doc) doc.documentElement.dataset.showQuotes = next ? 'true' : '' + return next + }) + }, []) + + useEffect(() => () => { + observerRef.current?.disconnect() + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + }, []) + + return ( + <> +