US/025
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -4,6 +4,7 @@ interface AppSettings {
|
||||
sidebarCollapsed: boolean;
|
||||
aiProvider: string;
|
||||
encryptedTokens: Record<string, string>;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
@@ -15,6 +16,7 @@ export function getStore(): Store<AppSettings> {
|
||||
sidebarCollapsed: false,
|
||||
aiProvider: 'copilot',
|
||||
encryptedTokens: {},
|
||||
userName: 'there',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
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 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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
|
||||
<Card className="max-w-sm">
|
||||
@@ -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 (
|
||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||
{/* Context header */}
|
||||
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
|
||||
<Badge variant="outline">{contextLabel}</Badge>
|
||||
</div>
|
||||
{/* Context header (non-home) */}
|
||||
{!isHomePage && (
|
||||
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
|
||||
<Badge variant="outline">{contextLabel}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable messages area */}
|
||||
<ScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
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}
|
||||
>
|
||||
{/* Messages */}
|
||||
{hasMessages && (
|
||||
{/* Home page initial state: greeting + brief */}
|
||||
{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="flex flex-col gap-4">
|
||||
{messages.map((msg) => {
|
||||
@@ -257,14 +443,32 @@ export function AIChatPanel({
|
||||
{/* 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 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
|
||||
input={input}
|
||||
isStreaming={isStreaming}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
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>
|
||||
@@ -279,6 +483,7 @@ interface ChatInputProps {
|
||||
onInputChange: (value: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
isHomePage?: boolean;
|
||||
}
|
||||
|
||||
function ChatInput({
|
||||
@@ -287,22 +492,34 @@ function ChatInput({
|
||||
onInputChange,
|
||||
onKeyDown,
|
||||
onSend,
|
||||
isHomePage,
|
||||
}: ChatInputProps) {
|
||||
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
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
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"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<button
|
||||
onClick={onSend}
|
||||
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} />
|
||||
</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
|
||||
const searchObj = routerState.location.search as Record<string, unknown>;
|
||||
const projectId = typeof searchObj['projectId'] === 'string' ? searchObj['projectId'] : undefined;
|
||||
@@ -220,33 +222,36 @@ export function AppShell({ children }: AppShellProps) {
|
||||
contextType={contextType}
|
||||
projectId={projectId}
|
||||
projectName={projectQuery.data?.name}
|
||||
curtainOpen={curtainOpen}
|
||||
curtainOpen={isHomePage || curtainOpen}
|
||||
isHomePage={isHomePage}
|
||||
/>
|
||||
|
||||
{/* Content panel: slides down to reveal chat */}
|
||||
<motion.div
|
||||
style={{ y: springY }}
|
||||
className="absolute inset-0 z-10 flex flex-col bg-background"
|
||||
>
|
||||
{children}
|
||||
{/* Content panel: slides down to reveal chat (hidden on home — AIChatPanel IS the home page) */}
|
||||
{!isHomePage && (
|
||||
<motion.div
|
||||
style={{ y: springY }}
|
||||
className="absolute inset-0 z-10 flex flex-col bg-background"
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Right-edge vertical affordance (non-interactive, hidden on home) */}
|
||||
<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">
|
||||
{curtainOpen ? (
|
||||
<ChevronDown size={10} />
|
||||
) : (
|
||||
<ChevronUp size={10} />
|
||||
)}
|
||||
<span
|
||||
className="text-[9px] tracking-widest uppercase font-medium"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
||||
</span>
|
||||
{/* 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="flex flex-col items-center gap-1.5 pr-2">
|
||||
{curtainOpen ? (
|
||||
<ChevronDown size={10} />
|
||||
) : (
|
||||
<ChevronUp size={10} />
|
||||
)}
|
||||
<span
|
||||
className="text-[9px] tracking-widest uppercase font-medium"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
{curtainOpen ? 'back to app' : 'scrolling up for Adiuva'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
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