US/025
This commit is contained in:
@@ -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)
|
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)
|
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
|
## USER REQUEST
|
||||||
{
|
{
|
||||||
"id": "US-024",
|
"id": "US-025",
|
||||||
"title": "AI checkpoint suggestions UI",
|
"title": "Home dashboard — AI daily brief and suggestion chips",
|
||||||
"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.",
|
"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": [
|
"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",
|
"Greeting rendered as '✦ Hello, {name}' in Geist Semibold 30px with -1px letter-spacing; name sourced from electron-store (defaults to 'there' if not set)",
|
||||||
"Suggested checkpoints returned by @ProjectAgent are inserted into the checkpoints table via checkpoints.create with isAiSuggested=1, isApproved=0",
|
"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",
|
||||||
"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",
|
"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",
|
||||||
"'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",
|
"Brief displayed below greeting in a shadcn/ui Card; bold key phrases rendered as <strong> (model wraps them in **markdown bold**)",
|
||||||
"'Reject' shadcn/ui Button (variant=ghost, size=sm) calls checkpoints.delete({ id }) and removes the card",
|
"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)",
|
"All UI uses shadcn/ui components (already installed)",
|
||||||
"Typecheck passes",
|
"Typecheck passes",
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 24,
|
"priority": 25,
|
||||||
"passes": false,
|
"passes": false,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
}
|
}
|
||||||
2
prd.json
2
prd.json
@@ -454,7 +454,7 @@
|
|||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 25,
|
"priority": 25,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
27
progress.txt
27
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
|
- 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)
|
- 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
|
||||||
|
---
|
||||||
|
|||||||
@@ -335,6 +335,30 @@ const tasksRouter = router({
|
|||||||
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
|
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
|
||||||
return { success: true as const };
|
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({
|
const checkpointsRouter = router({
|
||||||
@@ -512,6 +536,13 @@ const settingsRouter = router({
|
|||||||
getStore().set('sidebarCollapsed', input.collapsed);
|
getStore().set('sidebarCollapsed', input.collapsed);
|
||||||
return null;
|
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({
|
const aiRouter = router({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface AppSettings {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
aiProvider: string;
|
aiProvider: string;
|
||||||
encryptedTokens: Record<string, string>;
|
encryptedTokens: Record<string, string>;
|
||||||
|
userName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _store: Store<AppSettings> | null = null;
|
let _store: Store<AppSettings> | null = null;
|
||||||
@@ -15,6 +16,7 @@ export function getStore(): Store<AppSettings> {
|
|||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
aiProvider: 'copilot',
|
aiProvider: 'copilot',
|
||||||
encryptedTokens: {},
|
encryptedTokens: {},
|
||||||
|
userName: 'there',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
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 ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
@@ -16,12 +16,20 @@ interface ChatMessage {
|
|||||||
error?: boolean;
|
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 {
|
interface AIChatPanelProps {
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
contextType: 'global' | 'project';
|
contextType: 'global' | 'project';
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
curtainOpen: boolean;
|
curtainOpen: boolean;
|
||||||
|
isHomePage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIChatPanel({
|
export function AIChatPanel({
|
||||||
@@ -30,14 +38,25 @@ export function AIChatPanel({
|
|||||||
projectId,
|
projectId,
|
||||||
projectName,
|
projectName,
|
||||||
curtainOpen,
|
curtainOpen,
|
||||||
|
isHomePage,
|
||||||
}: AIChatPanelProps) {
|
}: AIChatPanelProps) {
|
||||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
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<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [streamingContent, setStreamingContent] = useState('');
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
|
|
||||||
|
// Daily brief state (home page only)
|
||||||
|
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
|
||||||
|
const [briefLoading, setBriefLoading] = useState(false);
|
||||||
|
const briefContentRef = useRef('');
|
||||||
|
const hasFiredBrief = useRef(false);
|
||||||
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const streamingContentRef = useRef('');
|
const streamingContentRef = useRef('');
|
||||||
@@ -62,9 +81,49 @@ export function AIChatPanel({
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, streamingContent, 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 handleSend = useCallback(() => {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed || isStreaming) return;
|
if (!trimmed || isStreaming || briefLoading) return;
|
||||||
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: crypto.randomUUID(),
|
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<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -149,7 +208,7 @@ export function AIChatPanel({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// No token configured — show settings prompt
|
// No token configured — show settings prompt
|
||||||
if (hasTokenQuery.data === false) {
|
if (hasTokenQuery.data === false && !isHomePage) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
|
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
|
||||||
<Card className="max-w-sm">
|
<Card className="max-w-sm">
|
||||||
@@ -178,22 +237,149 @@ export function AIChatPanel({
|
|||||||
? `Chatting about: ${projectName}`
|
? `Chatting about: ${projectName}`
|
||||||
: 'Global workspace';
|
: 'Global workspace';
|
||||||
|
|
||||||
|
// Derived values for home page
|
||||||
|
const dueCount = dueTodayQuery.data?.length ?? 0;
|
||||||
|
const userName = userNameQuery.data ?? 'there';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||||
{/* Context header */}
|
{/* Context header (non-home) */}
|
||||||
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
|
{!isHomePage && (
|
||||||
<Badge variant="outline">{contextLabel}</Badge>
|
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
|
||||||
</div>
|
<Badge variant="outline">{contextLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scrollable messages area */}
|
{/* Scrollable messages area */}
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
viewportRef={messagesContainerRef}
|
viewportRef={messagesContainerRef}
|
||||||
viewportClassName="[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end"
|
viewportClassName={
|
||||||
|
isHomePage && !hasMessages
|
||||||
|
? undefined
|
||||||
|
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
||||||
|
}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
{/* Messages */}
|
{/* Home page initial state: greeting + brief */}
|
||||||
{hasMessages && (
|
{isHomePage && !hasMessages && (
|
||||||
|
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-44">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Greeting + badge */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
|
||||||
|
✦ Hello, {userName}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{dueCount} Task{dueCount !== 1 ? 's' : ''} due
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily brief card */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{hasTokenQuery.data === false ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-2">
|
||||||
|
<KeyRound size={24} className="text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Configure your AI provider in Settings to enable the daily brief.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||||
|
Open Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : briefLoading && !dailyBrief ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
) : dailyBrief ? (
|
||||||
|
<ChatMarkdown content={dailyBrief} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your daily brief will appear here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Home page with messages: brief stays, then messages */}
|
||||||
|
{isHomePage && hasMessages && (
|
||||||
|
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-44">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Brief card persists */}
|
||||||
|
{dailyBrief && (
|
||||||
|
<Card className="mb-2">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<ChatMarkdown content={dailyBrief} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat messages */}
|
||||||
|
{messages.map((msg) => {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="flex justify-end">
|
||||||
|
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||||
|
<ChatMarkdown content={msg.content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.error) {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||||
|
<p className="text-sm text-destructive whitespace-pre-wrap">
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Sparkles size={16} className="text-foreground" />
|
||||||
|
<span className="text-sm font-semibold">Adiuva</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-[22px]">
|
||||||
|
<ChatMarkdown content={msg.content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Streaming AI response */}
|
||||||
|
{isStreaming && (
|
||||||
|
<div className="mr-auto max-w-[75%]">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Sparkles size={16} className="text-foreground" />
|
||||||
|
<span className="text-sm font-semibold">Adiuva</span>
|
||||||
|
</div>
|
||||||
|
{streamingContent ? (
|
||||||
|
<div className="pl-[22px]">
|
||||||
|
<ChatMarkdown content={streamingContent} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 pl-[22px]">
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Non-home messages */}
|
||||||
|
{!isHomePage && hasMessages && (
|
||||||
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-44">
|
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-44">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
@@ -257,14 +443,32 @@ export function AIChatPanel({
|
|||||||
{/* Fixed input — pinned to the bottom */}
|
{/* Fixed input — pinned to the bottom */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-4 pt-12 pointer-events-none">
|
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-4 pt-12 pointer-events-none">
|
||||||
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/80 to-background" />
|
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/80 to-background" />
|
||||||
<div className="relative pointer-events-auto mx-auto max-w-[1088px]">
|
<div className={`relative pointer-events-auto mx-auto ${isHomePage ? 'max-w-3xl' : 'max-w-[1088px]'}`}>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
input={input}
|
input={input}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming || briefLoading}
|
||||||
onInputChange={setInput}
|
onInputChange={setInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
isHomePage={isHomePage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Suggestion chips (home page only, before first message) */}
|
||||||
|
{isHomePage && !hasMessages && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{SUGGESTION_CHIPS.map((chip) => (
|
||||||
|
<Button
|
||||||
|
key={chip.label}
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2 h-auto py-2.5 px-4 text-left"
|
||||||
|
onClick={() => setInput(chip.label)}
|
||||||
|
>
|
||||||
|
<chip.icon size={16} className="shrink-0" />
|
||||||
|
<span className="text-sm">{chip.label}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,6 +483,7 @@ interface ChatInputProps {
|
|||||||
onInputChange: (value: string) => void;
|
onInputChange: (value: string) => void;
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
|
isHomePage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatInput({
|
function ChatInput({
|
||||||
@@ -287,22 +492,34 @@ function ChatInput({
|
|||||||
onInputChange,
|
onInputChange,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onSend,
|
onSend,
|
||||||
|
isHomePage,
|
||||||
}: ChatInputProps) {
|
}: ChatInputProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded-2xl bg-muted/60 backdrop-blur-xl border border-border shadow-[0_2px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.3)] overflow-hidden">
|
<div
|
||||||
|
className={
|
||||||
|
isHomePage
|
||||||
|
? 'relative rounded-2xl bg-white dark:bg-neutral-900 border border-[#d4d4d4] shadow-lg overflow-hidden'
|
||||||
|
: 'relative rounded-2xl bg-muted/60 backdrop-blur-xl border border-border shadow-[0_2px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.3)] overflow-hidden'
|
||||||
|
}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Ask me anything..."
|
placeholder="Ask me anything..."
|
||||||
rows={3}
|
rows={isHomePage ? 4 : 3}
|
||||||
|
style={isHomePage ? { minHeight: 109 } : undefined}
|
||||||
className="w-full resize-none bg-transparent px-4 pt-4 pb-12 text-sm placeholder:text-muted-foreground outline-none"
|
className="w-full resize-none bg-transparent px-4 pt-4 pb-12 text-sm placeholder:text-muted-foreground outline-none"
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-3 right-3">
|
<div className="absolute bottom-3 right-3">
|
||||||
<button
|
<button
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
disabled={!input.trim() || isStreaming}
|
disabled={!input.trim() || isStreaming}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
className={`flex h-8 w-8 items-center justify-center rounded-xl shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||||
|
isHomePage
|
||||||
|
? 'bg-foreground text-background'
|
||||||
|
: 'bg-primary text-primary-foreground'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowUp size={16} />
|
<ArrowUp size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isHomePage = currentPath === '/';
|
||||||
|
|
||||||
// Curtain is disabled on home page and on /projects without a selected project
|
// Curtain is disabled on home page and on /projects without a selected project
|
||||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
const searchObj = routerState.location.search as Record<string, unknown>;
|
||||||
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
|
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
|
||||||
@@ -220,33 +222,36 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
contextType={contextType}
|
contextType={contextType}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
projectName={projectQuery.data?.name}
|
projectName={projectQuery.data?.name}
|
||||||
curtainOpen={curtainOpen}
|
curtainOpen={isHomePage || curtainOpen}
|
||||||
|
isHomePage={isHomePage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content panel: slides down to reveal chat */}
|
{/* Content panel: slides down to reveal chat (hidden on home — AIChatPanel IS the home page) */}
|
||||||
<motion.div
|
{!isHomePage && (
|
||||||
style={{ y: springY }}
|
<motion.div
|
||||||
className="absolute inset-0 z-10 flex flex-col bg-background"
|
style={{ y: springY }}
|
||||||
>
|
className="absolute inset-0 z-10 flex flex-col bg-background"
|
||||||
{children}
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
{/* Right-edge vertical affordance (non-interactive, hidden on home) */}
|
{/* Right-edge vertical affordance (non-interactive) */}
|
||||||
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
|
<div className={`absolute right-0 top-0 flex items-end justify-center pt-8 pointer-events-none select-none${!curtainEnabled ? ' hidden' : ''}`}>
|
||||||
<div className="flex flex-col items-center gap-1.5 pr-2">
|
<div className="flex flex-col items-center gap-1.5 pr-2">
|
||||||
{curtainOpen ? (
|
{curtainOpen ? (
|
||||||
<ChevronDown size={10} />
|
<ChevronDown size={10} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronUp size={10} />
|
<ChevronUp size={10} />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="text-[9px] tracking-widest uppercase font-medium"
|
className="text-[9px] tracking-widest uppercase font-medium"
|
||||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||||
>
|
>
|
||||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
)}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { trpc } from '@/lib/trpc';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: HomePage,
|
component: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
function HomePage() {
|
|
||||||
const pingQuery = trpc.health.ping.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="text-foreground"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">
|
|
||||||
Hello, Roberto
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Adiuva is ready. Start building.
|
|
||||||
</p>
|
|
||||||
{pingQuery.data && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
tRPC IPC bridge: {pingQuery.data}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user