From 50b7fa784c5b90eff9fddc22eb271cfb69399b1b Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Wed, 25 Feb 2026 07:31:50 +0100 Subject: [PATCH] US/025 --- DEFAULT_PROMPT.md | 22 +- prd.json | 2 +- progress.txt | 27 +++ src/main/router/index.ts | 31 +++ src/main/store.ts | 2 + src/renderer/components/ai/AIChatPanel.tsx | 249 ++++++++++++++++++-- src/renderer/components/layout/AppShell.tsx | 51 ++-- src/renderer/routes/index.tsx | 37 +-- 8 files changed, 335 insertions(+), 86 deletions(-) diff --git a/DEFAULT_PROMPT.md b/DEFAULT_PROMPT.md index f532db3..4fe3a20 100644 --- a/DEFAULT_PROMPT.md +++ b/DEFAULT_PROMPT.md @@ -1,4 +1,4 @@ -## Your Task US-024 +## Your Task US-025 1. Read the full app PRD at `prd-main.md` (in the same directory as this file) 2. Read the PRD at `prd.json` (in the same directory as this file) @@ -23,20 +23,22 @@ APPEND to progress.txt (never replace, always append): ## USER REQUEST { - "id": "US-024", - "title": "AI checkpoint suggestions UI", - "description": "As a user, I want the AI to suggest timeline checkpoints from my meeting notes, which I can approve or reject directly in the timeline.", + "id": "US-025", + "title": "Home dashboard — AI daily brief and suggestion chips", + "description": "As a user, I want the Home screen to greet me with an AI-generated daily brief and pre-populated suggestion chips for quick queries.", "acceptanceCriteria": [ - "'Suggest checkpoints' shadcn/ui Button (variant=outline, sparkles Lucide icon) in the Project Detail timeline header calls ai.chat with a suggest_checkpoints intent for the current project", - "Suggested checkpoints returned by @ProjectAgent are inserted into the checkpoints table via checkpoints.create with isAiSuggested=1, isApproved=0", - "Pending suggestions appear as shadcn/ui Card components (with dashed border via className 'border-dashed') above or below the GanttChart in the Project Detail timeline section", - "'Approve' shadcn/ui Button (variant=default, size=sm) on each card calls checkpoints.update({ id, isApproved: 1 }); the checkpoint then appears as a normal dot on the Gantt", - "'Reject' shadcn/ui Button (variant=ghost, size=sm) calls checkpoints.delete({ id }) and removes the card", + "Greeting rendered as '✦ Hello, {name}' in Geist Semibold 30px with -1px letter-spacing; name sourced from electron-store (defaults to 'there' if not set)", + "Top-right corner stat chip uses shadcn/ui Badge (variant=secondary) showing 'N Task due' where N = count of tasks with dueDate on or before end of today", + "On app open, ai.chat called with global context to generate a daily brief paragraph highlighting tasks due today/this week and recent project activity", + "Brief displayed below greeting in a shadcn/ui Card; bold key phrases rendered as (model wraps them in **markdown bold**)", + "4 suggestion chips rendered in a 4-column flex row below the chat box using shadcn/ui Button (variant=outline); each chip has a Lucide icon + short prompt text", + "Clicking a suggestion chip populates the chat input with the chip's prompt text", + "Chat box uses shadcn/ui Textarea: white bg, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...'; Send uses shadcn/ui Button (black bg) bottom-right", "All UI uses shadcn/ui components (already installed)", "Typecheck passes", "Verify in browser using dev-browser skill" ], - "priority": 24, + "priority": 25, "passes": false, "notes": "" } \ No newline at end of file diff --git a/prd.json b/prd.json index 2305a96..adc082a 100644 --- a/prd.json +++ b/prd.json @@ -454,7 +454,7 @@ "Verify in browser using dev-browser skill" ], "priority": 25, - "passes": false, + "passes": true, "notes": "" } ] diff --git a/progress.txt b/progress.txt index 47ab2ed..e9ce6de 100644 --- a/progress.txt +++ b/progress.txt @@ -562,3 +562,30 @@ - TaskItem type in TaskRow.tsx is manually defined (not auto-inferred from tRPC) — must be updated when adding columns to the tasks select - The ai.chat mutation can be instantiated multiple times for independent suggest flows (suggestCheckpoints vs suggestTasks) --- + +## 2026-02-25 - US-025 +- Implemented Home dashboard with AI daily brief and suggestion chips +- AIChatPanel enhanced to serve as both home page (initial greeting/brief/chips state) and curtain chat +- Added `userName` setting to electron-store (defaults to 'there'), with `getUserName`/`setUserName` tRPC procedures +- Added `tasks.dueToday` query returning tasks with dueDate on or before end of today (status != done) +- AppShell hides content panel on home route, revealing AIChatPanel directly +- Home initial state: greeting "✦ Hello, {name}" (30px, -1px tracking) + Badge (variant=secondary) with due task count +- Daily brief auto-fires via ai.chat on mount, streams into a Card below greeting +- Chat input: white bg, border #d4d4d4, shadow-lg, min-height 109px, send button with foreground/background colors +- 4 suggestion chips (ListTodo, TrendingUp, AlertCircle, Lightbulb) below chat input, populate input on click +- On first user message: greeting + chips disappear, brief card persists, chat messages flow below +- Responsive: flex-wrap on chips for narrow screens, max-w-3xl container +- Files changed: + - src/main/store.ts (added userName field) + - src/main/router/index.ts (getUserName, setUserName, dueToday) + - src/renderer/components/ai/AIChatPanel.tsx (main change — home mode with greeting, brief, chips) + - src/renderer/components/layout/AppShell.tsx (hide content panel on home, pass isHomePage prop) + - src/renderer/routes/index.tsx (simplified to null component) + - prd.json (passes: true) +- **Learnings for future iterations:** + - AIChatPanel is the single source of truth for all AI chat UIs — home page is just a different initial state, not a separate component + - The curtain is disabled on home (`currentPath !== '/'`), so AppShell conditionally hides the motion.div content panel to reveal AIChatPanel + - Stream interleaving prevention: disable chat input while dailyBrief is loading (both use the same `ai:stream` IPC channel) + - `hasFiredBrief` ref prevents double-fire in React strict mode for the auto-fire daily brief effect + - electron-store settings don't need schema migrations — just add to the AppSettings interface with a default +--- diff --git a/src/main/router/index.ts b/src/main/router/index.ts index 028f576..f7ef537 100644 --- a/src/main/router/index.ts +++ b/src/main/router/index.ts @@ -335,6 +335,30 @@ const tasksRouter = router({ getDb().delete(tasks).where(eq(tasks.id, input.id)).run(); return { success: true as const }; }), + + dueToday: publicProcedure.query(() => { + const now = new Date(); + const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime(); + + return getDb() + .select({ + id: tasks.id, + title: tasks.title, + priority: tasks.priority, + dueDate: tasks.dueDate, + projectId: tasks.projectId, + }) + .from(tasks) + .where( + and( + sql`${tasks.dueDate} IS NOT NULL`, + sql`${tasks.dueDate} <= ${endOfToday}`, + sql`${tasks.status} != 'done'`, + ) + ) + .orderBy(asc(tasks.dueDate)) + .all(); + }), }); const checkpointsRouter = router({ @@ -512,6 +536,13 @@ const settingsRouter = router({ getStore().set('sidebarCollapsed', input.collapsed); return null; }), + getUserName: publicProcedure.query(() => getStore().get('userName')), + setUserName: publicProcedure + .input(z.object({ name: z.string() })) + .mutation(({ input }) => { + getStore().set('userName', input.name); + return null; + }), }); const aiRouter = router({ diff --git a/src/main/store.ts b/src/main/store.ts index 5c8106b..50f8c5f 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -4,6 +4,7 @@ interface AppSettings { sidebarCollapsed: boolean; aiProvider: string; encryptedTokens: Record; + userName: string; } let _store: Store | null = null; @@ -15,6 +16,7 @@ export function getStore(): Store { sidebarCollapsed: false, aiProvider: 'copilot', encryptedTokens: {}, + userName: 'there', }, }); } diff --git a/src/renderer/components/ai/AIChatPanel.tsx b/src/renderer/components/ai/AIChatPanel.tsx index 08fde10..d6aca60 100644 --- a/src/renderer/components/ai/AIChatPanel.tsx +++ b/src/renderer/components/ai/AIChatPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { Sparkles, KeyRound, ArrowUp } from 'lucide-react'; +import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { trpc } from '@/lib/trpc'; @@ -16,12 +16,20 @@ interface ChatMessage { error?: boolean; } +const SUGGESTION_CHIPS = [ + { icon: ListTodo, label: "What's on my plate today?" }, + { icon: TrendingUp, label: 'Summarize this week' }, + { icon: AlertCircle, label: 'Any overdue tasks?' }, + { icon: Lightbulb, label: 'Suggest next actions' }, +] as const; + interface AIChatPanelProps { onOpenSettings?: () => void; contextType: 'global' | 'project'; projectId?: string; projectName?: string; curtainOpen: boolean; + isHomePage?: boolean; } export function AIChatPanel({ @@ -30,14 +38,25 @@ export function AIChatPanel({ projectId, projectName, curtainOpen, + isHomePage, }: AIChatPanelProps) { const hasTokenQuery = trpc.ai.hasToken.useQuery(); + // Home-specific queries + const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage }); + const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage }); + const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(''); + // Daily brief state (home page only) + const [dailyBrief, setDailyBrief] = useState(null); + const [briefLoading, setBriefLoading] = useState(false); + const briefContentRef = useRef(''); + const hasFiredBrief = useRef(false); + const messagesContainerRef = useRef(null); const streamingContentRef = useRef(''); @@ -62,9 +81,49 @@ export function AIChatPanel({ scrollToBottom(); }, [messages, streamingContent, scrollToBottom]); + // Auto-fire daily brief on home page + useEffect(() => { + if (!isHomePage || hasFiredBrief.current || hasTokenQuery.data !== true) return; + hasFiredBrief.current = true; + setBriefLoading(true); + + const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => { + if (done) { + setDailyBrief(briefContentRef.current); + setBriefLoading(false); + unsubscribe(); + return; + } + briefContentRef.current += token; + setDailyBrief(briefContentRef.current); + }); + + chatMutation.mutate( + { + message: + 'Give me a concise daily brief for today. Highlight tasks due today and this week, any overdue items, and recent project activity. Use **bold** for key phrases. Keep it to 3-5 sentences.', + context: { type: 'global' }, + }, + { + onSuccess: (data) => { + if (data.error) { + unsubscribe(); + setDailyBrief(null); + setBriefLoading(false); + } + }, + onError: () => { + unsubscribe(); + setDailyBrief(null); + setBriefLoading(false); + }, + }, + ); + }, [isHomePage, hasTokenQuery.data]); // chatMutation excluded — only fire once + const handleSend = useCallback(() => { const trimmed = input.trim(); - if (!trimmed || isStreaming) return; + if (!trimmed || isStreaming || briefLoading) return; const userMsg: ChatMessage = { id: crypto.randomUUID(), @@ -128,7 +187,7 @@ export function AIChatPanel({ }, }, ); - }, [input, isStreaming, contextType, projectId, chatMutation]); + }, [input, isStreaming, briefLoading, contextType, projectId, chatMutation]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -149,7 +208,7 @@ export function AIChatPanel({ }, []); // No token configured — show settings prompt - if (hasTokenQuery.data === false) { + if (hasTokenQuery.data === false && !isHomePage) { return (
@@ -178,22 +237,149 @@ export function AIChatPanel({ ? `Chatting about: ${projectName}` : 'Global workspace'; + // Derived values for home page + const dueCount = dueTodayQuery.data?.length ?? 0; + const userName = userNameQuery.data ?? 'there'; + return (
- {/* Context header */} -
- {contextLabel} -
+ {/* Context header (non-home) */} + {!isHomePage && ( +
+ {contextLabel} +
+ )} {/* Scrollable messages area */} div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end' + } onWheel={handleWheel} > - {/* Messages */} - {hasMessages && ( + {/* Home page initial state: greeting + brief */} + {isHomePage && !hasMessages && ( +
+
+ {/* Greeting + badge */} +
+

+ ✦ Hello, {userName} +

+ + {dueCount} Task{dueCount !== 1 ? 's' : ''} due + +
+ + {/* Daily brief card */} + + + {hasTokenQuery.data === false ? ( +
+ +

+ Configure your AI provider in Settings to enable the daily brief. +

+ +
+ ) : briefLoading && !dailyBrief ? ( +
+ + + +
+ ) : dailyBrief ? ( + + ) : ( +

+ Your daily brief will appear here. +

+ )} +
+
+
+
+ )} + + {/* Home page with messages: brief stays, then messages */} + {isHomePage && hasMessages && ( +
+
+ {/* Brief card persists */} + {dailyBrief && ( + + + + + + )} + + {/* Chat messages */} + {messages.map((msg) => { + if (msg.role === 'user') { + return ( +
+
+ +
+
+ ); + } + + if (msg.error) { + return ( +
+

+ {msg.content} +

+
+ ); + } + + return ( +
+
+ + Adiuva +
+
+ +
+
+ ); + })} + + {/* Streaming AI response */} + {isStreaming && ( +
+
+ + Adiuva +
+ {streamingContent ? ( +
+ +
+ ) : ( +
+ + +
+ )} +
+ )} +
+
+ )} + + {/* Non-home messages */} + {!isHomePage && hasMessages && (
{messages.map((msg) => { @@ -257,14 +443,32 @@ export function AIChatPanel({ {/* Fixed input — pinned to the bottom */}
-
+
+ + {/* Suggestion chips (home page only, before first message) */} + {isHomePage && !hasMessages && ( +
+ {SUGGESTION_CHIPS.map((chip) => ( + + ))} +
+ )}
@@ -279,6 +483,7 @@ interface ChatInputProps { onInputChange: (value: string) => void; onKeyDown: (e: React.KeyboardEvent) => void; onSend: () => void; + isHomePage?: boolean; } function ChatInput({ @@ -287,22 +492,34 @@ function ChatInput({ onInputChange, onKeyDown, onSend, + isHomePage, }: ChatInputProps) { return ( -
+