feat: implement full context-scoped AI chat UI in AIChatPanel

- Added AIChatPanel component with context header, user and AI message handling.
- Integrated streaming responses via IPC and error handling for chat mutations.
- Enhanced user experience with input handling and auto-scrolling features.
- Updated AppShell to derive AI chat context from the current route.
- Introduced ScrollArea component for better scrolling behavior in various dialogs.
- Added support for Tailwind typography and improved global styles.
- Updated project and task dialogs to utilize ScrollArea for better UX.
This commit is contained in:
Roberto Musso
2026-02-24 12:02:06 +01:00
parent 00a43e0fbc
commit 5eb19e022e
20 changed files with 962 additions and 91 deletions

View File

@@ -3,31 +3,43 @@
*
* Wraps the CopilotClient's session API so it can be used as a drop-in
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
*
* Accepts a client-getter function to avoid module duplication issues when
* this file is code-split into a separate chunk by Vite.
*/
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { AIMessageChunk } from '@langchain/core/messages';
import { ChatGenerationChunk } from '@langchain/core/outputs';
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
import { getCopilotClient } from './copilot';
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
const COPILOT_TIMEOUT = 60_000;
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
constructor() {
private getClient: () => CopilotClientType | null;
constructor(getClient: () => CopilotClientType | null) {
super({});
this.getClient = getClient;
}
_llmType(): string {
return 'copilot';
}
private requireClient(): CopilotClientType {
const client = this.getClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please check that Copilot CLI is authenticated (copilot auth login).');
}
return client;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
const client = getCopilotClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
}
const client = this.requireClient();
// Extract system message and user prompt from LangChain messages
const systemContent = messages
@@ -61,10 +73,7 @@ export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
_options: this['ParsedCallOptions'],
_runManager?: CallbackManagerForLLMRun,
): AsyncGenerator<ChatGenerationChunk> {
const client = getCopilotClient();
if (!client) {
throw new Error('CopilotClient not initialized. Please add your GitHub token in Settings.');
}
const client = this.requireClient();
const systemContent = messages
.filter((m) => m._getType() === 'system')

View File

@@ -5,14 +5,14 @@ import { registerProvider, type AIProvider } from './provider';
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
let client: CopilotClientType | null = null;
let token: string | null = null;
let isReady = false;
const copilotProvider: AIProvider = {
name: 'copilot',
displayName: 'GitHub Copilot',
usesExternalAuth: true,
async initialize(t: string): Promise<boolean> {
token = t;
async initialize(): Promise<boolean> {
try {
// Stop existing client if re-initializing
if (client) {
@@ -22,32 +22,30 @@ const copilotProvider: AIProvider = {
}
const { CopilotClient } = await import('@github/copilot-sdk');
// No githubToken — uses stored OAuth credentials from Copilot CLI
// (authenticate first with `copilot auth login`)
client = new CopilotClient({
githubToken: t,
autoStart: true,
autoRestart: true,
logLevel: 'warning',
});
await client.start();
console.log('[AI] CopilotClient started successfully');
isReady = true;
console.log('[AI] CopilotClient started (using CLI OAuth credentials)');
return true;
} catch (err) {
console.error('[AI] Failed to start CopilotClient:', err);
client = null;
isReady = false;
return false;
}
},
isReady(): boolean {
return client !== null && token !== null;
return isReady && client !== null;
},
};
/** Get the raw Copilot token (used by future chat/completion calls). */
export function getCopilotToken(): string | null {
return token;
}
/** Get the CopilotClient instance (null if not initialized). */
export function getCopilotClient(): CopilotClientType | null {
return client;

View File

@@ -5,8 +5,9 @@
* the only place that knows how to create provider-specific LLM instances.
*/
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getActiveProviderName } from './provider';
import { getActiveProviderName, getActiveProvider } from './provider';
import { getToken } from './token';
import { getCopilotClient } from './copilot';
// ---------------------------------------------------------------------------
// Provider-specific factory functions (lazy-loaded)
@@ -36,8 +37,10 @@ async function createAnthropicModel(token: string): Promise<BaseChatModel> {
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
// We wrap it in a LangChain-compatible adapter.
// Pass getCopilotClient from this chunk (same as copilot.ts) to avoid
// module duplication when chat-copilot.ts is code-split by Vite.
const { ChatCopilot } = await import('./chat-copilot');
return new ChatCopilot();
return new ChatCopilot(getCopilotClient);
}
// ---------------------------------------------------------------------------
@@ -62,14 +65,15 @@ export async function getLLM(): Promise<BaseChatModel | null> {
return null;
}
const token = await getToken(providerName);
if (!token) {
const provider = getActiveProvider();
const token = provider?.usesExternalAuth ? '' : await getToken(providerName);
if (!provider?.usesExternalAuth && !token) {
console.log(`[AI] No token available for provider "${providerName}"`);
return null;
}
try {
return await factory(token);
return await factory(token ?? '');
} catch (err) {
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
return null;

View File

@@ -6,7 +6,6 @@
*/
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
import { SystemMessage, HumanMessage, type BaseMessage } from '@langchain/core/messages';
import { z } from 'zod';
import { eq, asc } from 'drizzle-orm';
import { getDb } from '../db';
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
@@ -178,12 +177,6 @@ If the user asks about specific note contents that aren't included here, let the
// LangGraph State
// ---------------------------------------------------------------------------
const RouteSchema = z.object({
route: z.enum(['project', 'knowledge', 'general']).describe(
'Which specialist agent should handle this request',
),
});
const OrchestratorState = Annotation.Root({
/** The user's original message */
userMessage: Annotation<string>(),
@@ -207,25 +200,29 @@ type State = typeof OrchestratorState.State;
// Graph nodes
// ---------------------------------------------------------------------------
/** Node 1: Classify intent using structured output */
/** Node 1: Classify intent using plain-text extraction (works with all providers) */
async function classifyIntent(state: State): Promise<Partial<State>> {
const llm = await getLLM();
if (!llm) throw new Error('AI provider not configured. Please add your token in Settings.');
const routerLLM = llm.withStructuredOutput(RouteSchema);
const result = await routerLLM.invoke([
const response = await llm.invoke([
new SystemMessage(
`You are a routing classifier for Adiuva, a project management workspace.
Classify the user's message into one of these categories:
- "project": Question about a specific project (tasks, notes, checkpoints, progress, summaries)
- "knowledge": Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
- "general": Everything else (general help, scheduling, task overviews, workspace summaries)`,
Classify the user's message into exactly one category. Reply with ONLY the category name, nothing else.
Categories:
- project: Question about a specific project (tasks, notes, checkpoints, progress, summaries)
- knowledge: Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
- general: Everything else (general help, scheduling, task overviews, workspace summaries)`,
),
new HumanMessage(state.userMessage),
]);
return { route: result.route };
const text = (typeof response.content === 'string' ? response.content : '').trim().toLowerCase();
const validRoutes = ['project', 'knowledge', 'general'] as const;
const route = validRoutes.find((r) => text.includes(r)) ?? 'general';
return { route };
}
/** Node 2a: Project agent — answer project-scoped questions */

View File

@@ -10,6 +10,8 @@ export interface AIProvider {
initialize(token: string): Promise<boolean>;
/** Whether the provider is initialized and ready to handle requests. */
isReady(): boolean;
/** If true, this provider uses external auth (e.g. CLI OAuth) and doesn't need a stored token. */
usesExternalAuth?: boolean;
}
const providers = new Map<string, AIProvider>();
@@ -49,9 +51,12 @@ export async function saveTokenAndInit(token: string): Promise<void> {
}
}
/** Check whether the active provider has a stored token. */
/** Check whether the active provider has credentials (stored token or external auth). */
export async function hasActiveToken(): Promise<boolean> {
const name = getActiveProviderName();
const provider = providers.get(name);
// Providers with external auth (e.g. Copilot CLI OAuth) don't need a stored token
if (provider?.usesExternalAuth) return true;
const token = await getToken(name);
return token !== null && token.length > 0;
}
@@ -69,6 +74,14 @@ export async function initAI(): Promise<void> {
return;
}
// Providers with external auth (e.g. Copilot CLI OAuth) initialize without a stored token
if (provider.usesExternalAuth) {
const ready = await provider.initialize('');
activeProvider = provider;
console.log(`[AI] Provider "${provider.displayName}" initialized (external auth): ready=${ready}`);
return;
}
const token = await getToken(name);
if (token) {
const ready = await provider.initialize(token);

View File

@@ -1,15 +1,154 @@
import { Sparkles, KeyRound } from 'lucide-react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Sparkles, KeyRound, ArrowUp } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { trpc } from '@/lib/trpc';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
interface AIChatPanelProps {
onOpenSettings?: () => void;
contextType: 'global' | 'project';
projectId?: string;
projectName?: string;
curtainOpen: boolean;
}
export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
export function AIChatPanel({
onOpenSettings,
contextType,
projectId,
projectName,
curtainOpen,
}: AIChatPanelProps) {
const hasTokenQuery = trpc.ai.hasToken.useQuery();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
const scrollToBottom = useCallback(() => {
const el = messagesContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
// Reset input when curtain closes; scroll to bottom when it reopens
useEffect(() => {
if (!curtainOpen) {
setInput('');
} else {
setTimeout(scrollToBottom, 50);
}
}, [curtainOpen, scrollToBottom]);
// Auto-scroll when messages change or streaming content updates
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsStreaming(true);
setStreamingContent('');
streamingContentRef.current = '';
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
const finalContent = streamingContentRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
unsubscribe();
return;
}
streamingContentRef.current += token;
setStreamingContent(streamingContentRef.current);
});
chatMutation.mutate(
{
message: trimmed,
context: {
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
},
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
}
},
onError: (err) => {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
},
},
);
}, [input, isStreaming, contextType, projectId, chatMutation]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Smart wheel handler: only stop propagation when there's content to scroll through
const handleWheel = useCallback((e: React.WheelEvent) => {
const el = messagesContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 2;
const atTop = el.scrollTop < 2;
// Let event propagate to AppShell when at boundaries
if ((e.deltaY > 0 && atBottom) || (e.deltaY < 0 && atTop)) return;
e.stopPropagation();
}, []);
// No token configured — show settings prompt
if (hasTokenQuery.data === false) {
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
@@ -19,7 +158,8 @@ export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
<div className="text-center space-y-1">
<p className="text-sm font-medium">AI provider not configured</p>
<p className="text-xs text-muted-foreground">
Connect your GitHub Copilot token to enable AI-powered features like chat, summaries, and suggestions.
Connect your GitHub Copilot token to enable AI-powered features
like chat, summaries, and suggestions.
</p>
</div>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
@@ -31,12 +171,173 @@ export function AIChatPanel({ onOpenSettings }: AIChatPanelProps) {
);
}
const hasMessages = messages.length > 0 || isStreaming;
const contextLabel =
contextType === 'project' && projectName
? `Chatting about: ${projectName}`
: 'Global workspace';
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
<Sparkles size={32} className="text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground/60 tracking-wide">
AI Chat coming soon
</p>
<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>
{/* 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"
onWheel={handleWheel}
>
{/* Messages */}
{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) => {
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>
)}
</ScrollArea>
{/* 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]">
<ChatInput
input={input}
isStreaming={isStreaming}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
/>
</div>
</div>
</div>
);
}
/* ---------- ChatInput: Floating glass card ---------- */
interface ChatInputProps {
input: string;
isStreaming: boolean;
onInputChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
}
function ChatInput({
input,
isStreaming,
onInputChange,
onKeyDown,
onSend,
}: 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">
<textarea
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask me anything..."
rows={3}
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"
>
<ArrowUp size={16} />
</button>
</div>
</div>
);
}
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
function ChatMarkdown({ content }: { content: string }) {
return (
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => (
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">
{children}
</pre>
),
code: ({ children, className }) => {
if (!className) {
return (
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
}}
>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -119,12 +119,21 @@ export function AppShell({ children }: AppShellProps) {
// 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;
const curtainEnabled =
currentPath !== '/' &&
!(currentPath === '/projects' && !searchObj['projectId']);
!(currentPath === '/projects' && !projectId);
const curtainEnabledRef = useRef(curtainEnabled);
curtainEnabledRef.current = curtainEnabled;
// Derive AI chat context from current route
const isProjectView = currentPath === '/projects' && !!projectId;
const contextType = isProjectView ? 'project' as const : 'global' as const;
const projectQuery = trpc.projects.get.useQuery(
{ id: projectId ?? '' },
{ enabled: !!projectId },
);
// --- Curtain animation state ---
const [curtainOpen, setCurtainOpen] = useState(false);
const curtainOpenRef = useRef(false);
@@ -149,6 +158,17 @@ export function AppShell({ children }: AppShellProps) {
else openCurtain();
}, [openCurtain, closeCurtain]);
// Keep curtain position in sync with window height on resize
useEffect(() => {
const handleResize = () => {
if (curtainOpenRef.current) {
y.set(window.innerHeight);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [y]);
// Keyboard shortcut: Cmd/Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -191,10 +211,17 @@ export function AppShell({ children }: AppShellProps) {
<AppSidebar
currentPath={currentPath}
setTokenDialogOpen={setTokenDialogOpen}
onNavClick={closeCurtain}
/>
<SidebarInset className="overflow-hidden">
{/* AI Chat layer: always mounted behind the content panel */}
<AIChatPanel onOpenSettings={() => setTokenDialogOpen(true)} />
<AIChatPanel
onOpenSettings={() => setTokenDialogOpen(true)}
contextType={contextType}
projectId={projectId}
projectName={projectQuery.data?.name}
curtainOpen={curtainOpen}
/>
{/* Content panel: slides down to reveal chat */}
<motion.div
@@ -273,9 +300,10 @@ export function AppShell({ children }: AppShellProps) {
interface AppSidebarProps {
currentPath: string;
setTokenDialogOpen: (open: boolean) => void;
onNavClick: () => void;
}
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
function AppSidebar({ currentPath, setTokenDialogOpen, onNavClick }: AppSidebarProps) {
const { toggleSidebar } = useSidebar();
const { theme, setTheme } = useTheme();
@@ -328,7 +356,7 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
isActive={isActive}
tooltip={label}
>
<Link to={to}>
<Link to={to} onClick={onNavClick}>
<Icon />
<span>{label}</span>
</Link>

View File

@@ -54,6 +54,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Empty,
EmptyContent,
@@ -416,7 +417,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
</div>
{/* Project tree */}
<div className="flex-1 overflow-y-auto py-1 px-1">
<ScrollArea className="flex-1 py-1 px-1">
{totalProjects === 0 ? (
<Empty>
<EmptyHeader>
@@ -824,7 +825,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
))}
</>
)}
</div>
</ScrollArea>
{/* Rename project dialog */}
<Dialog

View File

@@ -23,6 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { TaskItem } from './TaskRow';
@@ -275,7 +276,8 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
{knownAssignees.length > 0 && (
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
<ScrollArea className="max-h-36 mb-2">
<div className="flex flex-col gap-0.5">
{knownAssignees.map((name) => (
<Button
key={name}
@@ -293,7 +295,8 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
<span className="truncate">{name}</span>
</Button>
))}
</div>
</div>
</ScrollArea>
)}
{knownAssignees.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>

View File

@@ -23,6 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Calendar } from '@/components/ui/calendar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
const NO_CLIENT = '__no_client__';
@@ -512,7 +513,8 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
<PopoverContent className="w-64 p-2" align="start">
{/* Known assignees list */}
{knownAssignees.length > 0 && (
<div className="max-h-36 overflow-y-auto flex flex-col gap-0.5 mb-2">
<ScrollArea className="max-h-36 mb-2">
<div className="flex flex-col gap-0.5">
{knownAssignees.map((name) => (
<Button
key={name}
@@ -530,7 +532,8 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
<span className="truncate">{name}</span>
</Button>
))}
</div>
</div>
</ScrollArea>
)}
{knownAssignees.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>

View File

@@ -21,6 +21,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { trpc } from '@/lib/trpc';
import { PriorityBadge } from './PriorityBadge';
import { parseAssignees, type TaskItem } from './TaskRow';
@@ -194,7 +195,8 @@ export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }:
<TabsContent value="comment" className="px-6 py-4 min-h-[120px] flex flex-col gap-4">
{/* Comment list */}
<div className="flex flex-col gap-4 max-h-[260px] overflow-y-auto">
<ScrollArea className="max-h-[260px]">
<div className="flex flex-col gap-4">
{(!comments || comments.length === 0) ? (
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
) : (
@@ -223,6 +225,7 @@ export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }:
))
)}
</div>
</ScrollArea>
{/* Add comment input */}
<form

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Calendar } from '@/components/ui/calendar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
interface AddCheckpointDialogProps {
@@ -85,15 +86,17 @@ export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: Ad
{/* Just-added list */}
{added.length > 0 && (
<div className="flex flex-col gap-1.5 max-h-32 overflow-y-auto">
{added.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate">{entry.title}</span>
<span className="ml-auto text-xs shrink-0">{format(entry.date, 'MMM d')}</span>
</div>
))}
</div>
<ScrollArea className="max-h-32">
<div className="flex flex-col gap-1.5">
{added.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate">{entry.title}</span>
<span className="ml-auto text-xs shrink-0">{format(entry.date, 'MMM d')}</span>
</div>
))}
</div>
</ScrollArea>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">

View File

@@ -0,0 +1,65 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
viewportRef,
viewportClassName,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
viewportRef?: React.Ref<HTMLDivElement>;
viewportClassName?: string;
}) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className={cn(
"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1",
viewportClassName
)}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -5,6 +5,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -128,7 +129,7 @@
}
body {
font-family: 'Geist', 'Inter', system-ui, sans-serif;
font-family: 'Geist', 'Inter', system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji';
-webkit-font-smoothing: antialiased;
margin: 0;
overflow: hidden; /* Electron: no OS scrollbars */

View File

@@ -4,6 +4,7 @@ import { FolderKanban } from 'lucide-react';
import { ProjectSidebar } from '@/components/projects/ProjectSidebar';
import { ProjectDetail } from '@/components/projects/ProjectDetail';
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
import { ScrollArea } from '@/components/ui/scroll-area';
const searchSchema = z.object({
projectId: z.string().optional(),
@@ -28,7 +29,7 @@ function ProjectsPage() {
selectedProjectId={projectId}
onSelectProject={handleSelectProject}
/>
<div className="flex-1 overflow-y-auto">
<ScrollArea className="flex-1">
{projectId ? (
<ProjectDetail projectId={projectId} />
) : (
@@ -44,7 +45,7 @@ function ProjectsPage() {
</EmptyHeader>
</Empty>
)}
</div>
</ScrollArea>
</div>
);
}