From ff2c576848c485acce513b374e48dea1d872c91f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Thu, 26 Mar 2026 10:05:15 -0400 Subject: [PATCH 01/31] fix: rename fusion to builder, fix hydration errors, update DevTools shortcut - Rename all `builder.fusion.chatRunning` events to `builder.chatRunning` - Rename `fusionConfig` to `builderConfig` in harness CLI - Update docs to reference `builder` command instead of `fusion` - Change DevTools shortcut from Cmd+Option+I to Cmd+Shift+I, always target main webview - Fix SSR hydration errors: defer localStorage reads to useEffect in AgentPanel - Guard AssistantChat with client-only check (useLayoutEffect breaks SSR) - Fix ThemeToggle hydration mismatch in content app - Add .well-known 404 handler in content entry.server.tsx - Remove misleading "use CLI for full capabilities" dev hint - Calendar, mail, and content template UI updates --- packages/core/src/client/AgentPanel.tsx | 254 ++++++++++++--- packages/core/src/client/AssistantChat.tsx | 15 - .../core/src/client/MultiTabAssistantChat.tsx | 5 +- .../core/src/client/agent-chat-adapter.ts | 4 +- packages/core/src/client/agent-chat.ts | 4 +- .../src/client/terminal/AgentTerminal.tsx | 2 +- packages/core/src/client/use-agent-chat.ts | 7 +- .../core/src/client/useProductionAgent.ts | 4 +- packages/desktop-app/electron.vite.config.ts | 2 +- packages/desktop-app/src/main/index.ts | 16 +- packages/docs/app/components/searchIndex.ts | 2 +- packages/docs/public/llms-full.txt | 2 +- packages/harness-cli/client/App.tsx | 5 +- .../client/components/TerminalTab.tsx | 2 +- packages/harness-cli/client/harnesses.ts | 4 +- .../app/components/calendar/DayView.tsx | 220 ++++++++++--- .../calendar/EventDetailPopover.tsx | 151 +++++++++ .../components/calendar/GoogleSetupWizard.tsx | 2 +- .../app/components/calendar/MonthView.tsx | 42 +-- .../app/components/calendar/WeekView.tsx | 288 ++++++++++++------ .../app/components/layout/AppLayout.tsx | 58 ++-- .../app/components/layout/Sidebar.tsx | 5 +- templates/calendar/app/pages/CalendarView.tsx | 65 ++-- templates/calendar/app/pages/Settings.tsx | 2 +- .../content/app/components/EmptyState.tsx | 12 +- .../content/app/components/ThemeToggle.tsx | 6 +- .../app/components/layout/AppLayout.tsx | 28 +- .../components/sidebar/DocumentSidebar.tsx | 16 +- templates/content/app/entry.server.tsx | 6 + .../mail/app/components/email/EmailThread.tsx | 20 +- .../mail/app/components/layout/AppLayout.tsx | 11 +- templates/mail/app/hooks/use-emails.ts | 34 +-- templates/mail/app/root.tsx | 6 +- 33 files changed, 918 insertions(+), 382 deletions(-) create mode 100644 templates/calendar/app/components/calendar/EventDetailPopover.tsx diff --git a/packages/core/src/client/AgentPanel.tsx b/packages/core/src/client/AgentPanel.tsx index ebdc690e..2aaed9dc 100644 --- a/packages/core/src/client/AgentPanel.tsx +++ b/packages/core/src/client/AgentPanel.tsx @@ -21,7 +21,14 @@ * */ -import React, { useState, useEffect, lazy, Suspense } from "react"; +import React, { + useState, + useEffect, + useRef, + useCallback, + lazy, + Suspense, +} from "react"; import { AssistantChat } from "./AssistantChat.js"; import type { AssistantChatProps } from "./AssistantChat.js"; import { cn } from "./utils.js"; @@ -52,12 +59,13 @@ function useAvailableClis() { } function useCliSelection() { - const [selected, setSelected] = useState(() => { - if (typeof localStorage !== "undefined") { - return localStorage.getItem(CLI_STORAGE_KEY) || CLI_DEFAULT; - } - return CLI_DEFAULT; - }); + const [selected, setSelected] = useState(CLI_DEFAULT); + useEffect(() => { + try { + const saved = localStorage.getItem(CLI_STORAGE_KEY); + if (saved) setSelected(saved); + } catch {} + }, []); const select = (cmd: string) => { setSelected(cmd); try { @@ -108,16 +116,41 @@ function TerminalIcon({ className }: { className?: string }) { ); } +function SidebarIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + // ─── AgentPanel ───────────────────────────────────────────────────────────── export interface AgentPanelProps extends Omit< AssistantChatProps, - "onSwitchToCli" | "showDevHint" + "onSwitchToCli" > { /** Initial mode. Default: "chat" */ defaultMode?: "chat" | "cli"; /** CSS class for the outer container */ className?: string; + /** Called when the user clicks the collapse button. If provided, a collapse button appears in the header. */ + onCollapse?: () => void; +} + +function useClientOnly() { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted; } export function AgentPanel({ @@ -127,7 +160,9 @@ export function AgentPanel({ emptyStateText, suggestions, showHeader = true, + onCollapse, }: AgentPanelProps) { + const mounted = useClientOnly(); const [mode, setMode] = useState<"chat" | "cli">(defaultMode); const availableClis = useAvailableClis(); const [selectedCli, selectCli] = useCliSelection(); @@ -169,39 +204,52 @@ export function AgentPanel({ )} - {/* CLI selector */} - {IS_DEV && availableClis.length > 0 && ( - - )} +
+ {/* CLI selector — only visible in CLI mode */} + {IS_DEV && mode === "cli" && availableClis.length > 0 && ( + + )} + {onCollapse && ( + + )} +
)} - {/* Chat view — always mounted to preserve conversation */} + {/* Chat view — always mounted to preserve conversation (client-only + because @assistant-ui uses useLayoutEffect which breaks SSR) */}
- setMode("cli") : undefined} - /> + {mounted && ( + setMode("cli") : undefined} + /> + )}
{/* CLI terminal — only rendered in dev mode */} @@ -227,6 +275,65 @@ export function AgentPanel({ ); } +// ─── Resize handle ────────────────────────────────────────────────────────── + +const SIDEBAR_STORAGE_KEY = "agent-native-sidebar-width"; +const SIDEBAR_OPEN_KEY = "agent-native-sidebar-open"; +const SIDEBAR_MIN = 280; +const SIDEBAR_MAX = 700; + +function ResizeHandle({ + position, + onDrag, +}: { + position: "left" | "right"; + onDrag: (delta: number) => void; +}) { + const dragging = useRef(false); + const lastX = useRef(0); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + dragging.current = true; + lastX.current = e.clientX; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, []); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragging.current) return; + const delta = e.clientX - lastX.current; + lastX.current = e.clientX; + // For a left sidebar, dragging right = wider (positive delta) + // For a right sidebar, dragging left = wider (negative delta) + onDrag(position === "left" ? delta : -delta); + }, + [onDrag, position], + ); + + const onPointerUp = useCallback(() => { + dragging.current = false; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, []); + + return ( +
+ ); +} + // ─── AgentSidebar — wraps content with a toggleable agent panel ───────────── export interface AgentSidebarProps { @@ -255,35 +362,94 @@ export function AgentSidebar({ position = "right", defaultOpen = false, }: AgentSidebarProps) { - const [open, setOpen] = useState(defaultOpen); + const [open, setOpen] = useState(() => { + try { + const saved = localStorage.getItem(SIDEBAR_OPEN_KEY); + if (saved !== null) return saved === "true"; + } catch {} + return defaultOpen; + }); + const [width, setWidth] = useState(sidebarWidth); + useEffect(() => { + try { + const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY); + if (saved) { + const n = parseInt(saved, 10); + if (n >= SIDEBAR_MIN && n <= SIDEBAR_MAX) setWidth(n); + } + } catch {} + }, []); + + const setOpenPersisted = useCallback( + (next: boolean | ((prev: boolean) => boolean)) => { + setOpen((prev) => { + const value = typeof next === "function" ? next(prev) : next; + try { + localStorage.setItem(SIDEBAR_OPEN_KEY, String(value)); + } catch {} + return value; + }); + }, + [], + ); useEffect(() => { const handler = () => { - setOpen((prev) => !prev); + setOpenPersisted((prev) => !prev); }; window.addEventListener("agent-panel:toggle", handler); return () => window.removeEventListener("agent-panel:toggle", handler); + }, [setOpenPersisted]); + + const handleDrag = useCallback((delta: number) => { + setWidth((prev) => { + const next = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, prev + delta)); + try { + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next)); + } catch {} + return next; + }); }, []); const isLeft = position === "left"; - const borderClass = isLeft ? "border-r" : "border-l"; - const sidebar = open ? ( -
setOpenPersisted(true)} + className={cn( + "shrink-0 flex flex-col items-center pt-3 w-10 bg-card text-muted-foreground hover:text-foreground", + isLeft ? "border-r border-border" : "border-l border-border", + )} + title="Open agent sidebar" > - -
- ) : null; + + + ); + + const sidebar = ( + <> + {isLeft ? null : } +
+ setOpenPersisted(false)} + /> +
+ {isLeft ? : null} + + ); return (
- {isLeft && sidebar} + {isLeft && (open ? sidebar : collapsedTab)}
{children}
- {!isLeft && sidebar} + {!isLeft && (open ? sidebar : collapsedTab)}
); } diff --git a/packages/core/src/client/AssistantChat.tsx b/packages/core/src/client/AssistantChat.tsx index bb9462a7..21cd078d 100644 --- a/packages/core/src/client/AssistantChat.tsx +++ b/packages/core/src/client/AssistantChat.tsx @@ -425,8 +425,6 @@ export interface AssistantChatProps { showHeader?: boolean; /** CSS class for the outer container */ className?: string; - /** Whether to show the "Use CLI" hint in dev mode. Default: true */ - showDevHint?: boolean; /** Callback when user clicks "Use CLI" button */ onSwitchToCli?: () => void; } @@ -495,7 +493,6 @@ const AssistantChatInner = forwardRef< emptyStateText, suggestions, showHeader = true, - showDevHint = true, onSwitchToCli, className, apiUrl = "/api/agent-chat", @@ -642,18 +639,6 @@ const AssistantChatInner = forwardRef< ))}
)} - {showDevHint && onSwitchToCli && ( -

- In dev mode you can also use the{" "} - {" "} - for full Claude Code capabilities. -

- )} ) : (
diff --git a/packages/core/src/client/MultiTabAssistantChat.tsx b/packages/core/src/client/MultiTabAssistantChat.tsx index 60783729..aa20593b 100644 --- a/packages/core/src/client/MultiTabAssistantChat.tsx +++ b/packages/core/src/client/MultiTabAssistantChat.tsx @@ -113,9 +113,8 @@ export function MultiTabAssistantChat(props: MultiTabAssistantChatProps) { }), ); }; - window.addEventListener("builder.fusion.chatRunning", handler); - return () => - window.removeEventListener("builder.fusion.chatRunning", handler); + window.addEventListener("builder.chatRunning", handler); + return () => window.removeEventListener("builder.chatRunning", handler); }, []); const closeTab = useCallback( diff --git a/packages/core/src/client/agent-chat-adapter.ts b/packages/core/src/client/agent-chat-adapter.ts index e1b8ec00..4536e17b 100644 --- a/packages/core/src/client/agent-chat-adapter.ts +++ b/packages/core/src/client/agent-chat-adapter.ts @@ -55,7 +55,7 @@ export function createAgentChatAdapter(options?: { // Signal that generation is starting if (typeof window !== "undefined") { window.dispatchEvent( - new CustomEvent("builder.fusion.chatRunning", { + new CustomEvent("builder.chatRunning", { detail: { isRunning: true, tabId }, }), ); @@ -258,7 +258,7 @@ export function createAgentChatAdapter(options?: { } finally { if (typeof window !== "undefined") { window.dispatchEvent( - new CustomEvent("builder.fusion.chatRunning", { + new CustomEvent("builder.chatRunning", { detail: { isRunning: false, tabId }, }), ); diff --git a/packages/core/src/client/agent-chat.ts b/packages/core/src/client/agent-chat.ts index d4d5ba05..f25b05c8 100644 --- a/packages/core/src/client/agent-chat.ts +++ b/packages/core/src/client/agent-chat.ts @@ -34,9 +34,9 @@ const AGENT_CHAT_MESSAGE_TYPE = "builder.submitChat"; */ if (typeof window !== "undefined") { window.addEventListener("message", (event) => { - if (event.data?.type === "builder.fusion.chatRunning") { + if (event.data?.type === "builder.chatRunning") { window.dispatchEvent( - new CustomEvent("builder.fusion.chatRunning", { + new CustomEvent("builder.chatRunning", { detail: event.data.detail, }), ); diff --git a/packages/core/src/client/terminal/AgentTerminal.tsx b/packages/core/src/client/terminal/AgentTerminal.tsx index f9308c5c..5fdf2737 100644 --- a/packages/core/src/client/terminal/AgentTerminal.tsx +++ b/packages/core/src/client/terminal/AgentTerminal.tsx @@ -257,7 +257,7 @@ export function AgentTerminal({ function notifyAgentRunning(running: boolean) { onAgentRunningChange?.(running); window.dispatchEvent( - new CustomEvent("builder.fusion.chatRunning", { + new CustomEvent("builder.chatRunning", { detail: { isRunning: running }, }), ); diff --git a/packages/core/src/client/use-agent-chat.ts b/packages/core/src/client/use-agent-chat.ts index 7e620ec2..c65d0242 100644 --- a/packages/core/src/client/use-agent-chat.ts +++ b/packages/core/src/client/use-agent-chat.ts @@ -6,7 +6,7 @@ import { sendToAgentChat, type AgentChatMessage } from "./agent-chat.js"; * * Returns [isGenerating, send] where: * - isGenerating: true after send() is called, false when the - * builder.fusion.chatRunning event fires with detail.isRunning === false + * builder.chatRunning event fires with detail.isRunning === false * - send: wrapper around sendToAgentChat that sets isGenerating to true */ export function useAgentChatGenerating(): [ @@ -22,9 +22,8 @@ export function useAgentChatGenerating(): [ setIsGenerating(false); } }; - window.addEventListener("builder.fusion.chatRunning", handler); - return () => - window.removeEventListener("builder.fusion.chatRunning", handler); + window.addEventListener("builder.chatRunning", handler); + return () => window.removeEventListener("builder.chatRunning", handler); }, []); const send = useCallback((opts: AgentChatMessage): string => { diff --git a/packages/core/src/client/useProductionAgent.ts b/packages/core/src/client/useProductionAgent.ts index e46d8568..971059ba 100644 --- a/packages/core/src/client/useProductionAgent.ts +++ b/packages/core/src/client/useProductionAgent.ts @@ -48,7 +48,7 @@ export function useProductionAgent( // Notify any listeners that generation is running window.dispatchEvent( - new CustomEvent("builder.fusion.chatRunning", { + new CustomEvent("builder.chatRunning", { detail: { running: true }, }), ); @@ -175,7 +175,7 @@ export function useProductionAgent( } finally { setIsGenerating(false); window.dispatchEvent( - new CustomEvent("builder.fusion.chatRunning", { + new CustomEvent("builder.chatRunning", { detail: { running: false }, }), ); diff --git a/packages/desktop-app/electron.vite.config.ts b/packages/desktop-app/electron.vite.config.ts index 4b914b20..758062a9 100644 --- a/packages/desktop-app/electron.vite.config.ts +++ b/packages/desktop-app/electron.vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ main: { plugins: [ externalizeDepsPlugin({ - exclude: ["@agent-native/shared-app-config"], + exclude: ["@agent-native/shared-app-config", "electron-updater"], }), ], resolve: { diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index f5405c44..babb6686 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -67,7 +67,7 @@ function createWindow(): BrowserWindow { // In dev, load from the Vite dev server; in prod, load built files if (IS_DEV && process.env["ELECTRON_RENDERER_URL"]) { win.loadURL(process.env["ELECTRON_RENDERER_URL"]); - // DevTools will be opened for the active webview via Cmd+Option+I + // DevTools will be opened for the active webview via Cmd+Shift+I } else { win.loadFile(path.join(__dirname, "../renderer/index.html")); } @@ -208,14 +208,10 @@ app.on("web-contents-created", (_event, contents) => { const key = input.key.toLowerCase(); - // Cmd+Option+I — toggle devtools for this webview - if (key === "i" && input.alt) { + // Cmd+Shift+I — toggle devtools for the active app webview, not this webview + if (key === "i" && input.shift) { event.preventDefault(); - if (contents.isDevToolsOpened()) { - contents.closeDevTools(); - } else { - contents.openDevTools({ mode: "detach" }); - } + toggleWebviewDevTools(); return; } @@ -288,8 +284,8 @@ app.whenReady().then(() => { if (!(input.meta || input.control) || input.type !== "keyDown") return; const key = input.key.toLowerCase(); - // Cmd+Option+I — open devtools for the active webview, not the shell - if (key === "i" && input.alt) { + // Cmd+Shift+I — open devtools for the active webview, not the shell + if (key === "i" && input.shift) { _event.preventDefault(); toggleWebviewDevTools(); return; diff --git a/packages/docs/app/components/searchIndex.ts b/packages/docs/app/components/searchIndex.ts index c72b594c..b441c7a1 100644 --- a/packages/docs/app/components/searchIndex.ts +++ b/packages/docs/app/components/searchIndex.ts @@ -168,7 +168,7 @@ export const searchIndex: SearchEntry[] = [ path: "/docs/harnesses", section: "Supported CLIs", sectionId: "supported-clis", - text: "Claude Code claude --dangerously-skip-permissions --resume --verbose. Codex codex --full-auto --quiet. Gemini CLI gemini --sandbox. OpenCode opencode. Builder.io fusion. Switch between CLIs at any time from the settings panel.", + text: "Claude Code claude --dangerously-skip-permissions --resume --verbose. Codex codex --full-auto --quiet. Gemini CLI gemini --sandbox. OpenCode opencode. Builder.io builder. Switch between CLIs at any time from the settings panel.", }, { page: "Harnesses", diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 14a41223..7e3b4b2b 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -514,7 +514,7 @@ Apps run inside a **harness** — a host that provides the AI agent alongside yo | Codex | `codex` | | Gemini CLI | `gemini` | | OpenCode | `opencode` | -| Builder.io | `fusion` | +| Builder.io | `builder` | - Auto-installs missing CLIs on first use - Best for: solo development, testing diff --git a/packages/harness-cli/client/App.tsx b/packages/harness-cli/client/App.tsx index 19a43a79..b1921e99 100644 --- a/packages/harness-cli/client/App.tsx +++ b/packages/harness-cli/client/App.tsx @@ -227,9 +227,8 @@ export function App() { }), ); }; - window.addEventListener("builder.fusion.chatRunning", handler); - return () => - window.removeEventListener("builder.fusion.chatRunning", handler); + window.addEventListener("builder.chatRunning", handler); + return () => window.removeEventListener("builder.chatRunning", handler); }, [isAgentUi]); // Process pending messages for agent-ui tabs when refs mount diff --git a/packages/harness-cli/client/components/TerminalTab.tsx b/packages/harness-cli/client/components/TerminalTab.tsx index e2043930..9cd94f21 100644 --- a/packages/harness-cli/client/components/TerminalTab.tsx +++ b/packages/harness-cli/client/components/TerminalTab.tsx @@ -98,7 +98,7 @@ export const TerminalTab = forwardRef( const notifyApp = useCallback( (isRunning: boolean) => { iframeRef.current?.contentWindow?.postMessage( - { type: "builder.fusion.chatRunning", detail: { isRunning } }, + { type: "builder.chatRunning", detail: { isRunning } }, "*", ); }, diff --git a/packages/harness-cli/client/harnesses.ts b/packages/harness-cli/client/harnesses.ts index 2bf0dfe0..1a07ad01 100644 --- a/packages/harness-cli/client/harnesses.ts +++ b/packages/harness-cli/client/harnesses.ts @@ -77,7 +77,7 @@ export const opencodeConfig: HarnessConfig = { customPlaceholder: "e.g. --provider anthropic", }; -export const fusionConfig: HarnessConfig = { +export const builderConfig: HarnessConfig = { name: "Builder.io", command: "builder", installPackage: "", @@ -99,6 +99,6 @@ export const allHarnesses: HarnessConfig[] = [ codexConfig, geminiConfig, opencodeConfig, - fusionConfig, + builderConfig, agentUiConfig, ]; diff --git a/templates/calendar/app/components/calendar/DayView.tsx b/templates/calendar/app/components/calendar/DayView.tsx index b5011d2a..b5645856 100644 --- a/templates/calendar/app/components/calendar/DayView.tsx +++ b/templates/calendar/app/components/calendar/DayView.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import { eachHourOfInterval, format, @@ -9,14 +9,25 @@ import { isToday, } from "date-fns"; import { cn } from "@/lib/utils"; +import { EventDetailPopover } from "./EventDetailPopover"; import type { CalendarEvent } from "@shared/api"; interface DayViewProps { events: CalendarEvent[]; date: Date; - onEventClick: (event: CalendarEvent) => void; + onEditEvent: (event: CalendarEvent) => void; + onDeleteEvent: (eventId: string) => void; + isLoading?: boolean; } +// [startHour, startMin, durationMin, widthPct] +const DAY_SKELETONS: [number, number, number, number][] = [ + [9, 0, 60, 82], + [11, 0, 45, 68], + [14, 0, 90, 76], + [16, 30, 30, 60], +]; + const START_HOUR = 6; const END_HOUR = 23; const HOUR_HEIGHT = 72; @@ -26,7 +37,80 @@ function getEventColor(event: CalendarEvent) { return event.source === "google" ? "#5085C0" : null; } -export function DayView({ events, date, onEventClick }: DayViewProps) { +interface LayoutInfo { + left: number; // percentage 0-100 + width: number; // percentage 0-100 + col: number; + totalCols: number; +} + +function computeLayout(dayEvents: CalendarEvent[]): Map { + const result = new Map(); + if (dayEvents.length === 0) return result; + + const sorted = [...dayEvents].sort((a, b) => { + const aStart = parseISO(a.start).getTime(); + const bStart = parseISO(b.start).getTime(); + if (aStart !== bStart) return aStart - bStart; + return parseISO(b.end).getTime() - parseISO(a.end).getTime(); + }); + + const columns: { id: string; end: number }[][] = []; + const eventCol = new Map(); + + for (const ev of sorted) { + const evStart = parseISO(ev.start).getTime(); + const evEnd = parseISO(ev.end).getTime(); + let placed = false; + for (let c = 0; c < columns.length; c++) { + if (columns[c].every((slot) => slot.end <= evStart)) { + columns[c].push({ id: ev.id, end: evEnd }); + eventCol.set(ev.id, c); + placed = true; + break; + } + } + if (!placed) { + columns.push([{ id: ev.id, end: evEnd }]); + eventCol.set(ev.id, columns.length - 1); + } + } + + const totalCols = columns.length; + + for (const ev of sorted) { + const col = eventCol.get(ev.id)!; + const evStart = parseISO(ev.start).getTime(); + const evEnd = parseISO(ev.end).getTime(); + let span = 1; + for (let c = col + 1; c < totalCols; c++) { + const isBlocked = columns[c].some((slot) => { + const slotEv = sorted.find((e) => e.id === slot.id)!; + const slotStart = parseISO(slotEv.start).getTime(); + const slotEnd = parseISO(slotEv.end).getTime(); + return slotStart < evEnd && slotEnd > evStart; + }); + if (isBlocked) break; + span++; + } + result.set(ev.id, { + left: (col / totalCols) * 100, + width: (span / totalCols) * 100, + col, + totalCols, + }); + } + + return result; +} + +export function DayView({ + events, + date, + onEditEvent, + onDeleteEvent, + isLoading = false, +}: DayViewProps) { const [now, setNow] = useState(new Date()); const currentTimeRef = useRef(null); const scrollContainerRef = useRef(null); @@ -68,8 +152,9 @@ export function DayView({ events, date, onEventClick }: DayViewProps) { }; } - const allDayEvents = events.filter((e) => e.allDay); - const timedEvents = events.filter((e) => !e.allDay); + const allDayEvents = useMemo(() => events.filter((e) => e.allDay), [events]); + const timedEvents = useMemo(() => events.filter((e) => !e.allDay), [events]); + const layout = useMemo(() => computeLayout(timedEvents), [timedEvents]); const today = isToday(date); const nowMinutes = (now.getHours() - START_HOUR) * 60 + now.getMinutes(); @@ -104,24 +189,29 @@ export function DayView({ events, date, onEventClick }: DayViewProps) { {allDayEvents.map((event) => { const color = getEventColor(event); return ( - + + ); })}
@@ -164,36 +254,76 @@ export function DayView({ events, date, onEventClick }: DayViewProps) { )} + {/* Skeleton events when loading */} + {isLoading && + DAY_SKELETONS.map(([startHour, startMin, duration, widthPct], i) => { + const topPx = + ((startHour - START_HOUR) * 60 + startMin) * (HOUR_HEIGHT / 60); + const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 20); + return ( +
+ ); + })} + {/* Timed events */} - {timedEvents.map((event) => { - const style = getEventStyle(event); + {!isLoading && timedEvents.map((event) => { + const posStyle = getEventStyle(event); + const li = layout.get(event.id) ?? { + left: 0, + width: 100, + col: 0, + totalCols: 1, + }; const color = getEventColor(event); + const durationMin = differenceInMinutes( + parseISO(event.end), + parseISO(event.start), + ); return ( - + {durationMin >= 30 && ( +
+ {format(parseISO(event.start), "h:mm a")} –{" "} + {format(parseISO(event.end), "h:mm a")} +
+ )} + {durationMin >= 45 && event.location && ( +
+ 📍 {event.location} +
+ )} + + ); })}
diff --git a/templates/calendar/app/components/calendar/EventDetailPopover.tsx b/templates/calendar/app/components/calendar/EventDetailPopover.tsx new file mode 100644 index 00000000..16684235 --- /dev/null +++ b/templates/calendar/app/components/calendar/EventDetailPopover.tsx @@ -0,0 +1,151 @@ +import { useState } from "react"; +import { format, parseISO, differenceInMinutes } from "date-fns"; +import { X, Clock, MapPin, Trash2, Edit2, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; +import type { CalendarEvent } from "@shared/api"; + +function formatDuration(start: string, end: string): string { + const totalMinutes = differenceInMinutes(parseISO(end), parseISO(start)); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours === 0) return `${minutes}m`; + if (minutes === 0) return `${hours}h`; + return `${hours}h ${minutes}m`; +} + +interface EventDetailPopoverProps { + event: CalendarEvent; + children: React.ReactNode; + onEdit: (event: CalendarEvent) => void; + onDelete: (eventId: string) => void; +} + +export function EventDetailPopover({ + event, + children, + onEdit, + onDelete, +}: EventDetailPopoverProps) { + const [open, setOpen] = useState(false); + + return ( + + {children} + e.preventDefault()} + > + {/* Header */} +
+ + Event + + +
+ + {/* Content */} +
+ {/* Title */} +

+ {event.title} +

+ + {/* Time */} +
+ +
+ {event.allDay ? ( + + All day ·{" "} + {format(parseISO(event.start), "MMMM d, yyyy")} + + ) : ( + <> + + {format(parseISO(event.start), "h:mm a")} + {" → "} + {format(parseISO(event.end), "h:mm a")} + + + {formatDuration(event.start, event.end)} + +
+ {format(parseISO(event.start), "EEE MMM d")} +
+ + )} +
+
+ + {/* Location */} + {event.location && ( +
+ + {event.location} +
+ )} + + {/* Description */} + {event.description && ( +

+ {event.description} +

+ )} + + {/* Google Calendar badge */} + {event.source === "google" && ( +
+ + Synced from Google Calendar +
+ )} +
+ + {/* Actions */} + {event.source !== "google" && ( +
+ +
+ +
+ )} + + + ); +} diff --git a/templates/calendar/app/components/calendar/GoogleSetupWizard.tsx b/templates/calendar/app/components/calendar/GoogleSetupWizard.tsx index 79dad76f..0fe58ca7 100644 --- a/templates/calendar/app/components/calendar/GoogleSetupWizard.tsx +++ b/templates/calendar/app/components/calendar/GoogleSetupWizard.tsx @@ -50,7 +50,7 @@ export function GoogleSetupWizard() { const [error, setError] = useState(null); const [envStatus, setEnvStatus] = useState([]); - const redirectUri = `${window.location.origin}/api/google/callback`; + const redirectUri = `${typeof window !== "undefined" ? window.location.origin : ""}/api/google/callback`; const fetchStatus = useCallback(async () => { try { diff --git a/templates/calendar/app/components/calendar/MonthView.tsx b/templates/calendar/app/components/calendar/MonthView.tsx index 6390ce37..0010b1da 100644 --- a/templates/calendar/app/components/calendar/MonthView.tsx +++ b/templates/calendar/app/components/calendar/MonthView.tsx @@ -13,13 +13,15 @@ import { } from "date-fns"; import { cn } from "@/lib/utils"; import { EventCard } from "./EventCard"; +import { EventDetailPopover } from "./EventDetailPopover"; import type { CalendarEvent } from "@shared/api"; interface MonthViewProps { events: CalendarEvent[]; selectedDate: Date; onDateSelect: (date: Date) => void; - onEventClick?: (event: CalendarEvent) => void; + onEditEvent?: (event: CalendarEvent) => void; + onDeleteEvent?: (eventId: string) => void; onEventDrop?: (eventId: string, newDate: Date) => void; } @@ -29,7 +31,8 @@ export function MonthView({ events, selectedDate, onDateSelect, - onEventClick, + onEditEvent, + onDeleteEvent, onEventDrop, }: MonthViewProps) { const [dragOverDay, setDragOverDay] = useState(null); @@ -133,25 +136,26 @@ export function MonthView({ {/* Events */}
{dayEvents.slice(0, 3).map((event) => ( -
{ - e.stopPropagation(); - onEventClick?.(event); - }} + event={event} + onEdit={onEditEvent ?? (() => {})} + onDelete={onDeleteEvent ?? (() => {})} > - setDraggingId(id)} - onDragEnd={() => { - setDraggingId(null); - setDragOverDay(null); - }} - dimmed={draggingId === event.id} - /> -
+
e.stopPropagation()}> + setDraggingId(id)} + onDragEnd={() => { + setDraggingId(null); + setDragOverDay(null); + }} + dimmed={draggingId === event.id} + /> +
+ ))} {dayEvents.length > 3 && ( + + ); })}
@@ -442,7 +501,7 @@ export function WeekView({
{/* Day columns */} - {dayData.map(({ day, events: dayEvents, layout }) => { + {dayData.map(({ day, events: dayEvents, layout }, dayIndex) => { const isCurrentDay = isToday(day); return ( @@ -474,52 +533,87 @@ export function WeekView({ )} + {/* Skeleton events when loading */} + {isLoading && + WEEK_SKELETONS[dayIndex]?.map( + ([startHour, startMin, duration, widthPct], i) => { + const topPx = + ((startHour - START_HOUR) * 60 + startMin) * + (HOUR_HEIGHT / 60); + const heightPx = Math.max( + (duration / 60) * HOUR_HEIGHT, + 20, + ); + return ( +
+ ); + }, + )} + {/* Timed events */} - {dayEvents.map((event) => { + {!isLoading && dayEvents.map((event) => { const li = layout.get(event.id) ?? { left: 0, width: 100, + col: 0, + totalCols: 1, }; - const style = getEventStyle(event, li); + const style = getEventStyle(event); const color = getEventColor(event); const start = parseISO(event.start); const end = parseISO(event.end); const durationMin = differenceInMinutes(end, start); return ( - + {durationMin >= 45 && ( +
+ {formatEventTime(start, end)} +
+ )} + + ); })}
diff --git a/templates/calendar/app/components/layout/AppLayout.tsx b/templates/calendar/app/components/layout/AppLayout.tsx index dd91cde9..d7c466e3 100644 --- a/templates/calendar/app/components/layout/AppLayout.tsx +++ b/templates/calendar/app/components/layout/AppLayout.tsx @@ -29,36 +29,38 @@ export function AppLayout({ children }: AppLayoutProps) { return (
- setSidebarOpen(false)} /> + +
+ setSidebarOpen(false)} /> -
- {/* Mobile header */} -
- - - Calendar -
+
+ {/* Mobile header */} +
+ + + Calendar +
- -
{children}
-
-
+
{children}
+
+
+
); diff --git a/templates/calendar/app/components/layout/Sidebar.tsx b/templates/calendar/app/components/layout/Sidebar.tsx index cf2c0e67..8bb0ec9f 100644 --- a/templates/calendar/app/components/layout/Sidebar.tsx +++ b/templates/calendar/app/components/layout/Sidebar.tsx @@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button"; import { useGoogleAuthStatus, useGoogleAuthUrl } from "@/hooks/use-google-auth"; import { useCalendarContext } from "./AppLayout"; import { ThemeToggle } from "@/components/ThemeToggle"; +import { AgentToggleButton } from "@agent-native/core/client"; const navItems = [ { path: "/", label: "Calendar", icon: CalendarDays }, @@ -207,9 +208,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { > {/* Logo */}
-
- -
+ Calendar diff --git a/templates/calendar/app/pages/CalendarView.tsx b/templates/calendar/app/pages/CalendarView.tsx index 55b0657c..1207d4d2 100644 --- a/templates/calendar/app/pages/CalendarView.tsx +++ b/templates/calendar/app/pages/CalendarView.tsx @@ -37,7 +37,6 @@ import { import { MonthView } from "@/components/calendar/MonthView"; import { WeekView } from "@/components/calendar/WeekView"; import { DayView } from "@/components/calendar/DayView"; -import { EventDetailPanel } from "@/components/calendar/EventDetailPanel"; import { CreateEventPopover } from "@/components/calendar/CreateEventDialog"; import { CommandPalette } from "@/components/calendar/CommandPalette"; import { KeyboardShortcutsHelp } from "@/components/calendar/KeyboardShortcutsHelp"; @@ -59,9 +58,6 @@ const viewModeLabels: Record = { export default function CalendarView() { const { selectedDate, setSelectedDate } = useCalendarContext(); const [viewMode, setViewMode] = useState("week"); - const [selectedEvent, setSelectedEvent] = useState( - null, - ); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false); @@ -98,7 +94,11 @@ export default function CalendarView() { } }, [viewMode, selectedDate]); - const { data: events = [], error: eventsError } = useEvents(from, to); + const { + data: events = [], + error: eventsError, + isLoading: eventsLoading, + } = useEvents(from, to); // Filter events for day view const dayEvents = useMemo( @@ -121,10 +121,6 @@ export default function CalendarView() { setSelectedDate(new Date()); } - function handleEventClick(event: CalendarEvent) { - setSelectedEvent(event); - } - function handleDateSelect(date: Date) { setSelectedDate(date); if (viewMode === "month") { @@ -137,23 +133,13 @@ export default function CalendarView() { setViewMode("day"); } - function handleCloseDetail() { - setSelectedEvent(null); - } - - function handleEditEvent(event: CalendarEvent) { - // Close detail panel and open create dialog with event data - // For now, keep as a simple close — the CreateEventDialog can be extended for editing - setSelectedEvent(null); + function handleEditEvent(_event: CalendarEvent) { setCreateDialogOpen(true); } function handleDeleteEvent(eventId: string) { deleteEvent.mutate(eventId, { - onSuccess: () => { - toast.success("Event deleted"); - setSelectedEvent(null); - }, + onSuccess: () => toast.success("Event deleted"), onError: () => toast.error("Failed to delete event"), }); } @@ -239,6 +225,7 @@ export default function CalendarView() { setViewMode("day"); break; case "c": + e.preventDefault(); setCreateDialogOpen(true); break; case "/": @@ -248,23 +235,12 @@ export default function CalendarView() { case "?": setShortcutsHelpOpen(true); break; - case "Escape": - if (selectedEvent) { - setSelectedEvent(null); - } - break; } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - createDialogOpen, - shortcutsHelpOpen, - selectedEvent, - isTypingInInput, - viewMode, - ]); + }, [createDialogOpen, shortcutsHelpOpen, isTypingInInput, viewMode]); const headerLabel = (() => { switch (viewMode) { @@ -456,15 +432,16 @@ export default function CalendarView() { {/* Main content: calendar grid + detail panel */}
- {/* Calendar grid */}
{viewMode === "month" && ( )} {viewMode === "week" && ( @@ -472,25 +449,21 @@ export default function CalendarView() { events={events} selectedDate={selectedDate} onDateSelect={handleDateSelect} - onEventClick={handleEventClick} + onEditEvent={handleEditEvent} + onDeleteEvent={handleDeleteEvent} + isLoading={eventsLoading} /> )} {viewMode === "day" && ( )}
- - {/* Event detail side panel */} -
{/* Dialogs */} @@ -501,7 +474,7 @@ export default function CalendarView() { onGoToDate={handleGoToDate} onEventClick={(event) => { setCommandPaletteOpen(false); - handleEventClick(event); + handleGoToDate(parseISO(event.start)); }} onCreateEvent={() => { setCommandPaletteOpen(false); diff --git a/templates/calendar/app/pages/Settings.tsx b/templates/calendar/app/pages/Settings.tsx index 47ee10ad..1c22e14f 100644 --- a/templates/calendar/app/pages/Settings.tsx +++ b/templates/calendar/app/pages/Settings.tsx @@ -89,7 +89,7 @@ export default function Settings() { } return ( -
+

Settings

diff --git a/templates/content/app/components/EmptyState.tsx b/templates/content/app/components/EmptyState.tsx index 848a6b68..e962e7c4 100644 --- a/templates/content/app/components/EmptyState.tsx +++ b/templates/content/app/components/EmptyState.tsx @@ -2,14 +2,22 @@ import { FileText, Plus } from "lucide-react"; import { useNavigate } from "react-router"; import { Button } from "@/components/ui/button"; import { useCreateDocument } from "@/hooks/use-documents"; +import { toast } from "sonner"; export function EmptyState() { const navigate = useNavigate(); const createDocument = useCreateDocument(); const handleCreate = async () => { - const doc = await createDocument.mutateAsync({}); - navigate(`/${doc.id}`); + try { + const doc = await createDocument.mutateAsync({}); + navigate(`/${doc.id}`); + } catch (err) { + toast.error("Failed to create page", { + description: + err instanceof Error ? err.message : "Something went wrong", + }); + } }; return ( diff --git a/templates/content/app/components/ThemeToggle.tsx b/templates/content/app/components/ThemeToggle.tsx index 602c5f78..228f9404 100644 --- a/templates/content/app/components/ThemeToggle.tsx +++ b/templates/content/app/components/ThemeToggle.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from "react"; import { useTheme } from "next-themes"; import { Sun, Moon } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -10,6 +11,9 @@ import { cn } from "@/lib/utils"; export function ThemeToggle({ className }: { className?: string }) { const { theme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); return ( @@ -23,7 +27,7 @@ export function ThemeToggle({ className }: { className?: string }) { className, )} > - {theme === "dark" ? : } + {mounted && theme === "dark" ? : } Toggle theme diff --git a/templates/content/app/components/layout/AppLayout.tsx b/templates/content/app/components/layout/AppLayout.tsx index 0a75321f..1097a905 100644 --- a/templates/content/app/components/layout/AppLayout.tsx +++ b/templates/content/app/components/layout/AppLayout.tsx @@ -9,22 +9,22 @@ interface AppLayoutProps { export function AppLayout({ activeDocumentId, children }: AppLayoutProps) { return ( -

- - + +
+
{children}
- -
+
+ ); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index e6f2f352..d810bcb0 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { useNavigate } from "react-router"; import { Plus, Search, Star, FileText } from "lucide-react"; +import { toast } from "sonner"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; import { ThemeToggle } from "@/components/ThemeToggle"; @@ -33,10 +34,17 @@ export function DocumentSidebar({ activeDocumentId }: DocumentSidebarProps) { const handleCreatePage = useCallback( async (parentId?: string) => { - const doc = await createDocument.mutateAsync({ - parentId: parentId ?? null, - }); - navigate(`/${doc.id}`); + try { + const doc = await createDocument.mutateAsync({ + parentId: parentId ?? null, + }); + navigate(`/${doc.id}`); + } catch (err) { + toast.error("Failed to create page", { + description: + err instanceof Error ? err.message : "Something went wrong", + }); + } }, [createDocument, navigate], ); diff --git a/templates/content/app/entry.server.tsx b/templates/content/app/entry.server.tsx index 5a921b7a..4b1f3931 100644 --- a/templates/content/app/entry.server.tsx +++ b/templates/content/app/entry.server.tsx @@ -21,6 +21,12 @@ export default async function handleRequest( }); } + // Reject Chrome DevTools probes that have no matching route + const url = new URL(request.url); + if (url.pathname.startsWith("/.well-known/")) { + return new Response(null, { status: 404 }); + } + const userAgent = request.headers.get("user-agent"); const waitForAll = (userAgent && isbot(userAgent)) || routerContext.isSpaMode; diff --git a/templates/mail/app/components/email/EmailThread.tsx b/templates/mail/app/components/email/EmailThread.tsx index e8ac2b7b..7b8b72db 100644 --- a/templates/mail/app/components/email/EmailThread.tsx +++ b/templates/mail/app/components/email/EmailThread.tsx @@ -574,16 +574,6 @@ export function EmailThread({ !!threadId, ); - if (!threadId) return null; - - if (!email) { - return ( -
-

Email not found

-
- ); - } - // Extract GitHub PR URL from any message in the thread const githubPrUrl = useMemo(() => { for (const msg of messages) { @@ -596,6 +586,16 @@ export function EmailThread({ return null; }, [messages]); + if (!threadId) return null; + + if (!email) { + return ( +
+

Email not found

+
+ ); + } + // Filter to user labels for display const systemLabels = new Set([ "inbox", diff --git a/templates/mail/app/components/layout/AppLayout.tsx b/templates/mail/app/components/layout/AppLayout.tsx index 592bac12..a1b89a0e 100644 --- a/templates/mail/app/components/layout/AppLayout.tsx +++ b/templates/mail/app/components/layout/AppLayout.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Link, useNavigate, useLocation, useSearchParams } from "react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { cn } from "@/lib/utils"; import { CommandPalette } from "./CommandPalette"; import { ComposeModal } from "@/components/email/ComposeModal"; @@ -433,8 +434,16 @@ export function AppLayout({ children }: AppLayoutProps) { ]); // Sequence shortcuts (g + key = go to view) + const qc = useQueryClient(); useSequenceShortcuts([ - { keys: ["g", "i"], handler: () => navigate("/inbox") }, + { + keys: ["g", "i"], + handler: () => { + navigate("/inbox"); + qc.invalidateQueries({ queryKey: ["emails"] }); + qc.invalidateQueries({ queryKey: ["labels"] }); + }, + }, { keys: ["g", "s"], handler: () => navigate("/starred") }, { keys: ["g", "t"], handler: () => navigate("/sent") }, { keys: ["g", "d"], handler: () => navigate("/drafts") }, diff --git a/templates/mail/app/hooks/use-emails.ts b/templates/mail/app/hooks/use-emails.ts index b67e86a9..d4f0915c 100644 --- a/templates/mail/app/hooks/use-emails.ts +++ b/templates/mail/app/hooks/use-emails.ts @@ -26,6 +26,8 @@ export function useEmails(view: string = "inbox", search?: string) { return apiFetch(`/api/emails?${params}`); }, staleTime: 15_000, + refetchInterval: 60_000, + refetchOnWindowFocus: true, retry: false, }); } @@ -76,10 +78,21 @@ export function useMarkRead() { export function useMarkThreadRead() { const qc = useQueryClient(); + // Stash unread IDs between onMutate (which computes them before the + // optimistic update) and mutationFn (which sends the actual API calls). + let pendingUnreadIds: string[] = []; return useMutation({ - mutationFn: async (threadId: string) => { - // Actual API calls happen in onMutate (before optimistic update) - // This is intentionally empty — the work is done in onMutate + mutationFn: async (_threadId: string) => { + if (pendingUnreadIds.length > 0) { + await Promise.all( + pendingUnreadIds.map((id) => + apiFetch(`/api/emails/${id}/read`, { + method: "PATCH", + body: JSON.stringify({ isRead: true }), + }), + ), + ); + } }, onMutate: async (threadId) => { await qc.cancelQueries({ queryKey: ["emails"] }); @@ -88,22 +101,9 @@ export function useMarkThreadRead() { }); // Capture unread IDs BEFORE optimistic update const allEmails = previous.flatMap(([, data]) => data ?? []) ?? []; - const unreadIds = allEmails + pendingUnreadIds = allEmails .filter((e) => (e.threadId || e.id) === threadId && !e.isRead) .map((e) => e.id); - // Fire API calls for the unread emails - if (unreadIds.length > 0) { - Promise.all( - unreadIds.map((id) => - apiFetch(`/api/emails/${id}/read`, { - method: "PATCH", - body: JSON.stringify({ isRead: true }), - }), - ), - ).catch(() => { - /* errors handled by onError rollback */ - }); - } // Optimistic update qc.setQueriesData({ queryKey: ["emails"] }, (old) => old?.map((e) => diff --git a/templates/mail/app/root.tsx b/templates/mail/app/root.tsx index 1762bf64..bdd47c67 100644 --- a/templates/mail/app/root.tsx +++ b/templates/mail/app/root.tsx @@ -105,7 +105,11 @@ export default function Root() { () => new QueryClient({ defaultOptions: { - queries: { staleTime: 30_000, retry: 1 }, + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: true, + }, }, }), ); From 23b4b89fa15171d249d1dcf9e4c2e05ee271b5fe Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Thu, 26 Mar 2026 08:10:40 -0700 Subject: [PATCH 02/31] feat: resizable chat sidebar, calendar stacking layout, mail refresh improvements - Add drag-to-resize on agent chat sidebar with localStorage persistence (280-700px) - Show CLI selector dropdown only in CLI mode, not chat mode - Calendar: Google Cal-style stacking layout for overlapping events with opaque backgrounds - Mail: refresh emails on window focus + 60s polling interval, gi shortcut forces refetch - Mail: fix React hooks order violation in EmailThread (useMemo after early return) --- .../core/src/application-state/handlers.ts | 12 +- packages/core/src/client/AgentPanel.tsx | 134 +++++++ packages/core/src/client/AssistantChat.tsx | 207 +++++++--- packages/core/src/client/ClientOnly.tsx | 22 ++ packages/core/src/client/DefaultSpinner.tsx | 29 ++ packages/core/src/client/index.ts | 2 + packages/core/src/client/use-dev-mode.ts | 72 +++- packages/core/src/server/agent-chat-plugin.ts | 85 ++++- .../core/src/templates/default/app/root.tsx | 27 +- packages/core/src/vite/dev-api-server.ts | 11 + packages/desktop-app/shared/ipc-channels.ts | 6 + packages/desktop-app/src/main/index.ts | 32 +- packages/desktop-app/src/preload/index.ts | 8 +- .../src/renderer/components/AppWebview.tsx | 65 +++- packages/desktop-app/src/renderer/global.d.ts | 3 + templates/calendar/AGENTS.md | 26 +- .../app/components/calendar/DayView.tsx | 197 +++++----- .../app/components/calendar/MonthView.tsx | 70 ++-- .../app/components/calendar/WeekView.tsx | 219 +++++------ templates/calendar/app/root.tsx | 31 +- templates/content/app/root.tsx | 23 +- .../forms/app/components/layout/AppLayout.tsx | 4 +- templates/forms/app/root.tsx | 32 +- .../mail/app/components/email/EmailList.tsx | 71 +++- .../mail/app/components/email/EmailThread.tsx | 189 +++++++++- .../mail/app/components/layout/AppLayout.tsx | 6 +- templates/mail/app/hooks/use-emails.ts | 30 +- templates/mail/app/root.tsx | 37 +- templates/mail/server/handlers/emails.ts | 352 ++++++++++-------- templates/slides/app/global.css | 6 + templates/slides/app/pages/DeckEditor.tsx | 2 +- templates/slides/app/root.tsx | 44 ++- 32 files changed, 1435 insertions(+), 619 deletions(-) create mode 100644 packages/core/src/client/ClientOnly.tsx create mode 100644 packages/core/src/client/DefaultSpinner.tsx diff --git a/packages/core/src/application-state/handlers.ts b/packages/core/src/application-state/handlers.ts index 9c2f29e0..2e871c27 100644 --- a/packages/core/src/application-state/handlers.ts +++ b/packages/core/src/application-state/handlers.ts @@ -38,11 +38,7 @@ export const getState = defineEventHandler(async (event: H3Event) => { const sessionId = await getSessionId(event); const key = safeKey(String(getRouterParam(event, "key"))); const value = await appStateGet(sessionId, key); - if (!value) { - setResponseStatus(event, 404); - return { error: `No state for ${key}` }; - } - return value; + return value ?? null; }); export const putState = defineEventHandler(async (event: H3Event) => { @@ -78,11 +74,7 @@ export const getComposeDraft = defineEventHandler(async (event: H3Event) => { const sessionId = await getSessionId(event); const id = getRouterParam(event, "id") as string; const value = await appStateGet(sessionId, composeDraftKey(id)); - if (!value) { - setResponseStatus(event, 404); - return { error: "Draft not found" }; - } - return value; + return value ?? null; }); /** Create or update a compose draft */ diff --git a/packages/core/src/client/AgentPanel.tsx b/packages/core/src/client/AgentPanel.tsx index 2aaed9dc..4eeab9ed 100644 --- a/packages/core/src/client/AgentPanel.tsx +++ b/packages/core/src/client/AgentPanel.tsx @@ -31,6 +31,7 @@ import React, { } from "react"; import { AssistantChat } from "./AssistantChat.js"; import type { AssistantChatProps } from "./AssistantChat.js"; +import { useDevMode } from "./use-dev-mode.js"; import { cn } from "./utils.js"; // Lazy-load AgentTerminal to avoid bundling xterm.js when not needed @@ -116,6 +117,23 @@ function TerminalIcon({ className }: { className?: string }) { ); } +function CogIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + function SidebarIcon({ className }: { className?: string }) { return ( void; +}) { + const [open, setOpen] = useState(false); + const popoverRef = useRef(null); + const buttonRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + buttonRef.current && + !buttonRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Close on Escape + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + return ( +
+ + {open && ( +
+
+
+ + Agent mode + + + {isDevMode + ? "Full access — can edit code, run shell commands, and modify files" + : "Restricted — app tools only, no code editing or shell access"} + +
+
+
+ + +
+
+ )} +
+ ); +} + // ─── AgentPanel ───────────────────────────────────────────────────────────── export interface AgentPanelProps extends Omit< @@ -168,6 +287,14 @@ export function AgentPanel({ const [selectedCli, selectCli] = useCliSelection(); const selectedLabel = availableClis.find((c) => c.command === selectedCli)?.label || selectedCli; + const { isDevMode, canToggle, setDevMode } = useDevMode(apiUrl); + const isLocalhost = + mounted && + typeof window !== "undefined" && + (window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" || + window.location.hostname === "::1"); + const showDevToggle = canToggle && isLocalhost; return (
@@ -205,6 +332,13 @@ export function AgentPanel({ )}
+ {/* Agent settings popover — localhost only */} + {showDevToggle && ( + setDevMode(!isDevMode)} + /> + )} {/* CLI selector — only visible in CLI mode */} {IS_DEV && mode === "cli" && availableClis.length > 0 && (