Compare commits

...

1 Commits

Author SHA1 Message Date
Roberto
259ab50b25 Update projects page view 2026-04-29 09:31:15 +02:00
18 changed files with 778 additions and 362 deletions

View File

@@ -42,6 +42,7 @@ interface OrchestrateFloatingInput {
requestId?: string;
sessionId?: string;
scope: WsFloatingRequest['scope'];
conversationHistory?: WsFloatingRequest['conversationHistory'];
sender?: Electron.WebContents;
}
@@ -121,14 +122,14 @@ export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateR
// ---------------------------------------------------------------------------
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
const { message, requestId, sessionId, scope, sender } = input;
const { message, requestId, sessionId, scope, conversationHistory, sender } = input;
const check = await checkConnectivity();
if (!check.ok) return { response: '', error: check.error };
try {
const client = getBackendClient();
const { requestId: activeRequestId, promise } = client.sendFloatingRequest(message, scope, requestId, sessionId, {
const { requestId: activeRequestId, promise } = client.sendFloatingRequest(message, scope, conversationHistory, requestId, sessionId, {
onStart: () => sendFrame(sender, { type: 'stream_start', requestId: activeRequestId }),
onText: (chunk) => sendFrame(sender, { type: 'stream_text', requestId: activeRequestId, chunk }),
onEnd: (mutations) => sendFrame(sender, { type: 'stream_end', requestId: activeRequestId, mutations: mutations as unknown[] | undefined }),

View File

@@ -407,6 +407,7 @@ export class BackendClient {
sendFloatingRequest(
message: string,
scope: WsFloatingRequest['scope'],
conversationHistory?: WsFloatingRequest['conversationHistory'],
requestId?: string,
sessionId?: string,
callbacks?: Partial<Omit<StreamListener, 'resolve' | 'reject'>>,
@@ -445,6 +446,7 @@ export class BackendClient {
sessionId,
message,
scope,
conversationHistory,
formatPrefs: {
timezone: rawPrefs.timezone,
dateFormat: rawPrefs.dateFormat,

View File

@@ -925,6 +925,7 @@ const aiRouter = router({
requestId: input.requestId,
sessionId: input.sessionId,
scope: input.scope,
conversationHistory: input.conversationHistory,
sender: ctx.sender,
});
}

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useRef, useCallback, useMemo, forwardRef } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo, forwardRef, memo } from 'react';
import { Link } from '@tanstack/react-router';
import { Sparkles, LogIn, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import { Sparkles, LogIn, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc';
import { useAIChat, type UIChatContext } from '@/hooks/useAIChat';
import { ChatInputBox, type ChatInputBoxHandle } from './ChatInputBox';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
@@ -221,12 +222,11 @@ export function AIChatPanel({
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend: chatHandleSend,
clearMessages,
cacheKey,
} = useAIChat(chatContext);
const hasMessages = messages.length > 0 || isStreaming;
@@ -244,6 +244,7 @@ export function AIChatPanel({
// --- Scroll-to-user-message + AI response minHeight ---
const chatInputWrapperRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<ChatInputBoxHandle>(null);
const briefWrapper = useRef<HTMLDivElement | null>(null);
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
const lastAiRef = useRef<HTMLDivElement | null>(null);
@@ -256,6 +257,7 @@ export function AIChatPanel({
if (actionsRef) {
actionsRef.current = {
clear: () => {
inputRef.current?.clear();
clearMessages();
aiMinHeightCache = null;
setAiMinHeight(null);
@@ -390,18 +392,11 @@ export function AIChatPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHomePage, authStatusQuery.data?.authenticated, cachedBriefQuery.isLoading, cachedBriefQuery.data]);
const handleSend = useCallback(() => {
const handleSend = useCallback((message: string) => {
pendingScrollRef.current = true;
chatHandleSend();
chatHandleSend(message);
}, [chatHandleSend]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Derived values for home page
const dueCount = dueTodayQuery.data?.length ?? 0;
@@ -549,13 +544,16 @@ export function AIChatPanel({
{/* Input + suggestion links */}
<motion.div variants={fadeUp} className="max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
/>
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<ChatInputBox
ref={inputRef}
cacheKey={cacheKey}
isStreaming={isStreaming}
onSend={handleSend}
placeholder={t('home.askAnything')}
autoFocus
/>
</div>
<div className="flex flex-col gap-0.5 mt-5">
{SUGGESTION_CHIPS.map((chip) => {
const label = t(chip.labelKey);
@@ -565,7 +563,7 @@ export function AIChatPanel({
type="button"
className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
onClick={() => setInput(label)}
onClick={() => inputRef.current?.setValue(label)}
>
<chip.icon
size={16}
@@ -659,13 +657,15 @@ export function AIChatPanel({
{hasMessages && (
<div ref={chatInputWrapperRef} className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
<div className="relative pointer-events-auto mx-auto max-w-3xl">
<ChatInput
input={input}
isStreaming={isStreaming}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
/>
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<ChatInputBox
ref={inputRef}
cacheKey={cacheKey}
isStreaming={isStreaming}
onSend={handleSend}
placeholder={t('home.askAnything')}
/>
</div>
</div>
</div>
)}
@@ -682,7 +682,7 @@ interface AIMessageProps {
skeleton?: boolean;
}
const AIMessage = forwardRef<HTMLDivElement, AIMessageProps>(
const AIMessage = memo(forwardRef<HTMLDivElement, AIMessageProps>(
({ content, bottomPad, minHeight, skeleton }, ref) => (
<div
ref={ref}
@@ -705,13 +705,16 @@ const AIMessage = forwardRef<HTMLDivElement, AIMessageProps>(
)}
</div>
)
);
));
AIMessage.displayName = 'AIMessage';
/* ---------- MessageContent: text with inline entity blocks ---------- */
function MessageContent({ content, fontSize }: { content: string; fontSize?: string }) {
const segments = mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content)));
const MessageContent = memo(function MessageContent({ content, fontSize }: { content: string; fontSize?: string }) {
const segments = useMemo(
() => mergeConsecutiveTaskSegments(mergeTimelineSegments(parseInlineTags(content))),
[content],
);
// Fast path: no inline tags, just render markdown
if (segments.length === 1 && segments[0]?.type === 'text') {
@@ -742,7 +745,7 @@ function MessageContent({ content, fontSize }: { content: string; fontSize?: str
})}
</div>
);
}
});
const blockAnimation = {
initial: { opacity: 0, scale: 0.95 },
@@ -750,52 +753,29 @@ const blockAnimation = {
transition: { type: 'spring' as const, stiffness: 400, damping: 30 },
};
/* ---------- 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) {
const { t } = useTranslation();
return (
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<div className="flex items-center gap-2 px-4 py-2.5">
<textarea
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder={t('home.askAnything')}
aria-label="Chat message"
rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
onClick={onSend}
disabled={!input.trim() || isStreaming}
aria-label="Send message"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
>
<ArrowUp size={16} />
</button>
</div>
</div>
);
}
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
// Stable references — defined outside the component so react-markdown never
// sees a changed prop reference and re-parses content on every render.
const REMARK_PLUGINS: Parameters<typeof ReactMarkdown>[0]['remarkPlugins'] = [remarkGfm];
const MARKDOWN_COMPONENTS: Parameters<typeof ReactMarkdown>[0]['components'] = {
pre: ({ children }) => (
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">
{children}
</pre>
),
code: ({ children, className }: { children?: React.ReactNode; className?: string }) => {
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>;
},
};
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
return (
<div
@@ -803,24 +783,8 @@ export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: stri
style={fontSize ? { fontSize } : undefined}
>
<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>;
},
}}
remarkPlugins={REMARK_PLUGINS}
components={MARKDOWN_COMPONENTS}
>
{content}
</ReactMarkdown>

View File

@@ -0,0 +1,135 @@
import { useState, useRef, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
import { ArrowUp } from 'lucide-react';
import { readInputDraft, writeInputDraft } from '@/hooks/useAIChat';
export interface ChatInputBoxHandle {
getValue: () => string;
setValue: (v: string) => void;
clear: () => void;
focus: () => void;
}
type ChatInputBoxVariant = 'panel' | 'floating';
interface ChatInputBoxProps {
cacheKey: string;
isStreaming: boolean;
onSend: (message: string) => void;
placeholder?: string;
autoFocus?: boolean;
variant?: ChatInputBoxVariant;
}
const VARIANT_STYLES = {
panel: {
container: 'flex items-center gap-2 px-4 py-2.5',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto',
button: 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100',
iconSize: 16,
},
floating: {
container: 'flex items-center gap-2 px-3 py-2.5',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto',
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
iconSize: 14,
},
} as const;
export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
({ cacheKey, isStreaming, onSend, placeholder, autoFocus, variant = 'panel' }, ref) => {
const styles = VARIANT_STYLES[variant];
const [value, setValue] = useState(() => readInputDraft(cacheKey));
const textareaRef = useRef<HTMLTextAreaElement>(null);
const valueRef = useRef(value);
valueRef.current = value;
// Re-init when the cache key changes (context switches in FloatingChat).
const prevKeyRef = useRef(cacheKey);
useEffect(() => {
if (prevKeyRef.current !== cacheKey) {
prevKeyRef.current = cacheKey;
setValue(readInputDraft(cacheKey));
}
}, [cacheKey]);
// Debounced draft persistence — fires 250 ms after the last keystroke.
useEffect(() => {
const id = setTimeout(() => writeInputDraft(cacheKey, value), 250);
return () => clearTimeout(id);
}, [cacheKey, value]);
// Flush on unmount so a fast close/reopen preserves the current draft.
useEffect(() => {
return () => writeInputDraft(cacheKey, valueRef.current);
}, [cacheKey]);
useImperativeHandle(ref, () => ({
getValue: () => valueRef.current,
setValue: (v: string) => {
setValue(v);
// Move caret to end + focus after React commits the new value.
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.setSelectionRange(v.length, v.length);
}
});
},
clear: () => setValue(''),
focus: () => textareaRef.current?.focus(),
}));
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Guard IME composition — prevents spurious submit during Italian dead-key
// input (e.g. ` + e → è) and CJK composition sequences.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (isStreaming) return;
const v = valueRef.current.trim();
if (!v) return;
onSend(v);
setValue('');
}
},
[isStreaming, onSend],
);
const handleClick = useCallback(() => {
if (isStreaming) return;
const v = valueRef.current.trim();
if (!v) return;
onSend(v);
setValue('');
}, [isStreaming, onSend]);
return (
<div className={styles.container}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label="Chat message"
rows={1}
autoFocus={autoFocus}
className={styles.textarea}
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
type="button"
onClick={handleClick}
disabled={!value.trim() || isStreaming}
aria-label="Send message"
className={styles.button}
>
<ArrowUp size={styles.iconSize} />
</button>
</div>
);
},
);
ChatInputBox.displayName = 'ChatInputBox';

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useNavigate, useRouterState } from '@tanstack/react-router';
import { X, ArrowUp } from 'lucide-react';
import { X } from 'lucide-react';
import {
useFloatingChat,
computeDualAnchor,
@@ -12,6 +12,7 @@ import {
} from '@/context/FloatingChatContext';
import { useAIChat, type UIChatContext, type FloatingDomainSignal } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { ChatInputBox, type ChatInputBoxHandle } from '@/components/ai/ChatInputBox';
import { Skeleton } from '@/components/ui/skeleton';
/** Map floating_domain signals to routes for background navigation */
@@ -155,12 +156,11 @@ function FloatingChatInner() {
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend,
clearMessages,
cacheKey,
} = useAIChat(chatContext, { onDomainSignal: handleDomainSignal });
const containerRef = useRef<HTMLDivElement>(null);
@@ -206,6 +206,8 @@ function FloatingChatInner() {
if (prevOpenRef.current && !state.isOpen) {
const resetSession = closeByNavigationRef.current;
closeByNavigationRef.current = false;
// Clear input draft first so the unmount flush writes '' to the cache.
inputRef.current?.clear();
clearMessages(resetSession);
}
prevOpenRef.current = state.isOpen;
@@ -289,7 +291,7 @@ function FloatingChatInner() {
// ---- Auto-focus input on open ----
const inputRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<ChatInputBoxHandle>(null);
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
@@ -297,15 +299,6 @@ function FloatingChatInner() {
}
}, [state.isOpen]);
// ---- Input handling ----
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const hasMessages = messages.length > 0 || isStreaming;
// Expand the messages panel upward if there's enough space above the input bar,
@@ -425,25 +418,14 @@ function FloatingChatInner() {
<X size={10} />
</button>
<div className="flex items-center gap-2 px-3 py-2.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
rows={1}
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isStreaming}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowUp size={14} />
</button>
</div>
<ChatInputBox
ref={inputRef}
variant="floating"
cacheKey={cacheKey}
isStreaming={isStreaming}
onSend={handleSend}
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
/>
</div>
</motion.div>
)}

View File

@@ -5,6 +5,7 @@ import {
Bar,
BarChart,
CartesianGrid,
Cell,
Line,
LineChart,
Pie,
@@ -26,17 +27,35 @@ import {
import type { ChartBlockData } from '../../../../../shared/api-types';
export function ChatChartBlock({ data: blockData }: { data: ChartBlockData }) {
const { chartType, title, data, config } = blockData;
const { chartType, title, data } = blockData;
// config is optional — the AI sometimes omits it and embeds color in data items instead
const config = blockData.config ?? {};
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
for (const [key, val] of Object.entries(config)) {
cfg[key] = { label: val.label, color: val.color };
const entries = Object.entries(config);
for (let i = 0; i < entries.length; i++) {
const [key, val] = entries[i];
// Normalize: guard against missing colors and the legacy hsl(var(--chart-N)) pattern
// (chart vars are oklch values, so the hsl wrapper produces invalid CSS → black fills).
const raw = val.color ?? '';
const color =
raw && !/^hsl\(var\(/.test(raw) ? raw : `var(--chart-${(i % 5) + 1})`;
cfg[key] = { label: val.label, color };
}
return cfg;
}, [config]);
const dataKeys = useMemo(() => Object.keys(config), [config]);
const dataKeys = useMemo(() => {
const keys = Object.keys(config);
if (keys.length > 0) return keys;
// Infer series keys from first data row when config is absent
const first = data[0];
if (!first) return ['value'];
return Object.entries(first)
.filter(([k, v]) => k !== 'name' && k !== 'color' && typeof v === 'number')
.map(([k]) => k);
}, [config, data]);
return (
<div className="rounded-lg border border-border bg-card p-4">
@@ -128,7 +147,11 @@ function renderChart(
nameKey="name"
innerRadius="40%"
outerRadius="70%"
/>
>
{data.map((_, i) => (
<Cell key={i} fill={`var(--chart-${(i % 5) + 1})`} />
))}
</Pie>
</PieChart>
);
case 'radar':

View File

@@ -80,7 +80,6 @@ function TaskEntityBlock({ ids }: { ids: string[] }) {
task={task}
onToggle={handleToggle}
onClick={setViewTask}
hideBreadcrumb
/>
))}
</EntityWrapper>

View File

@@ -152,7 +152,7 @@ function AppShellInner({ children }: AppShellProps) {
currentPath={currentPath}
profile={authStatusQuery.data?.profile ?? null}
/>
<SidebarInset className="min-w-0 overflow-x-hidden">
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
{showHeader && (
<header className="flex h-14 shrink-0 items-center gap-2">
<div className="flex flex-1 items-center gap-2 px-3">

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react';
import { memo, useState, useMemo, useCallback } from 'react';
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
import { trpc } from '@/lib/trpc';
import { useNotify } from '@/hooks/useNotify';
@@ -24,7 +24,7 @@ type KanbanBoardProps = {
onNewTaskOpenChange: (open: boolean) => void;
};
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
function KanbanBoardInner({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
const { t } = useTranslation();
const { state: floatingState } = useFloatingChat();
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
@@ -173,3 +173,5 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
</>
);
}
export const KanbanBoard = memo(KanbanBoardInner);

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, CalendarDays, Plus } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
@@ -14,7 +14,6 @@ import {
BreadcrumbList,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { KanbanBoard } from './KanbanBoard';
import { type TimelineEvent } from '@/components/timeline/ProjectTimeline';
import { type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
@@ -24,6 +23,8 @@ import { EditEventDialog } from '@/components/timeline/EditEventDialog';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { useTimelineHistory } from '@/hooks/useTimelineHistory';
import type { EventSnapshot } from '@/components/timeline/history-types';
import { cn } from '@/lib/utils';
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
type ProjectDetailProps = {
projectId: string;
@@ -33,14 +34,27 @@ type ProjectDetailProps = {
export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const navigate = useNavigate();
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [addEventOpen, setAddEventOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<TimelineEvent | null>(null);
const navigate = useNavigate();
const [compact, setCompact] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const heroRef = useRef<HTMLDivElement>(null);
const summaryRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const tasksRef = useRef<HTMLDivElement>(null);
const notesRef = useRef<HTMLDivElement>(null);
const sectionRefs: Record<SectionId, React.RefObject<HTMLDivElement | null>> = useMemo(() => ({
overview: summaryRef,
timeline: timelineRef,
tasks: tasksRef,
notes: notesRef,
}), []);
const didInitialScroll = useRef(false);
const { registerSection, unregisterSection } = useFloatingChat();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
@@ -58,6 +72,12 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
createMutateRef,
} = useTimelineHistory({ disabled: addEventOpen || editingEvent !== null });
// Latest-ref pattern for unstable closures so useCallback deps stay stable
const recordRef = useRef(record);
recordRef.current = record;
const clearHistoryRef = useRef(clearHistory);
clearHistoryRef.current = clearHistory;
useEffect(() => {
if (isLoading || !project) return;
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
@@ -70,6 +90,59 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
};
}, [projectId, isLoading, project, registerSection, unregisterSection]);
// Compact hero on scroll. scrollRef is the definitive scroll container
// (flex-1 min-h-0 overflow-y-auto inside a flex-col parent with min-h-0
// at every ancestor, so h-full never resolves to content-height).
// Hysteresis: compact at >80, un-compact only at 0 — layout shift from the
// hero re-growing can't trigger oscillation because it can't push scrollTop
// back to exactly 0.
// Re-run when project arrives — loading state renders a different DOM tree
// without scrollRef, so the initial commit's scrollRef.current is null on a
// cold mount (e.g. opening from the AppShell sidebar).
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const handle = () => {
const top = el.scrollTop;
setCompact((prev) => {
if (prev && top <= 0) return false;
if (!prev && top > 80) return true;
return prev;
});
};
el.addEventListener('scroll', handle, { passive: true });
return () => el.removeEventListener('scroll', handle);
}, [project]);
// Track hero height as a CSS custom property on the scroll container so the
// tab bar top offset updates without any React re-render. Re-run when project
// data arrives — hero element only mounts after isLoading=false, so the
// initial mount's heroRef.current is null on a cold route entry.
useEffect(() => {
const el = heroRef.current;
if (!el) return;
const measure = () => {
const h = el.getBoundingClientRect().height;
scrollRef.current?.style.setProperty('--hero-h', `${h}px`);
};
measure();
const obs = new ResizeObserver(measure);
obs.observe(el);
return () => obs.disconnect();
}, [project]);
// Scroll to initialTab on mount
useEffect(() => {
if (didInitialScroll.current || !initialTab || !SECTIONS.includes(initialTab as SectionId)) return;
const ref = sectionRefs[initialTab as SectionId];
const scrollEl = scrollRef.current;
if (!ref?.current || !scrollEl) return;
didInitialScroll.current = true;
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const top = ref.current.offsetTop - heroH - 41;
scrollEl.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}, [initialTab, sectionRefs]);
const utils = trpc.useUtils();
const { data: clientsList } = trpc.clients.list.useQuery();
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
@@ -126,7 +199,7 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
onError: (err) => {
if (historyOpRef.current) {
historyOpRef.current = false;
clearHistory();
clearHistoryRef.current();
notify('warning', 'toast.timeline.historyError');
} else {
notifyError('toast.timeline.deleteError', err);
@@ -143,7 +216,7 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
onError: (err) => {
if (historyOpRef.current) {
historyOpRef.current = false;
clearHistory();
clearHistoryRef.current();
notify('warning', 'toast.timeline.historyError');
} else {
notifyError('toast.timeline.updateError', err);
@@ -158,7 +231,7 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
const payload = pendingCreatePayloadRef.current;
pendingCreatePayloadRef.current = null;
if (payload) {
record({ kind: 'create', id: data.id, payload: { ...payload, id: data.id } });
recordRef.current({ kind: 'create', id: data.id, payload: { ...payload, id: data.id } });
}
}
historyOpRef.current = false;
@@ -168,7 +241,7 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
pendingCreatePayloadRef.current = null;
if (historyOpRef.current) {
historyOpRef.current = false;
clearHistory();
clearHistoryRef.current();
notify('warning', 'toast.timeline.historyError');
} else {
notifyError('toast.timeline.createError', err);
@@ -176,7 +249,6 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
},
});
// Wire latest mutation functions into refs so undo/redo can call them
updateMutateRef.current = updateEvent.mutate;
deleteMutateRef.current = deleteEvent.mutate;
createMutateRef.current = createEvent.mutate;
@@ -191,7 +263,42 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
endDate: new Date(),
}), [projectId, project?.name, project?.status, breadcrumbPath, timelineEvents]);
function handleDuplicate(ev: TimelineEvent) {
const handleToggleComplete = useCallback((id: string, current: number) => {
recordRef.current({
kind: 'update',
id,
prev: { isCompleted: current },
next: { isCompleted: current === 1 ? 0 : 1 },
});
updateEvent.mutate({ id, isCompleted: current === 1 ? 0 : 1 });
}, [updateEvent]);
const handleDeleteEvent = useCallback((id: string) => {
const ev = timelineEvents.find((e) => e.id === id);
if (ev) {
recordRef.current({
kind: 'delete',
id,
snapshot: {
id: ev.id,
projectId: ev.projectId ?? null,
title: ev.title,
date: ev.date,
endDate: ev.endDate ?? null,
type: (ev.type ?? 'milestone') as EventSnapshot['type'],
isCompleted: ev.isCompleted ?? 0,
isAiSuggested: ev.isAiSuggested ?? 0,
},
});
}
deleteEvent.mutate({ id });
}, [timelineEvents, deleteEvent]);
const handleEditEvent = useCallback((ev: TimelineEvent) => {
setEditingEvent(ev);
}, []);
const handleDuplicate = useCallback((ev: TimelineEvent) => {
pendingCreatePayloadRef.current = {
id: '',
projectId: ev.projectId ?? null,
@@ -209,7 +316,22 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
projectId: ev.projectId ?? undefined,
type: ev.type,
});
}
}, [createEvent, pendingCreatePayloadRef]);
const handleMoveEvent = useCallback((id: string, date: number, endDate: number | null) => {
const ev = timelineEvents.find((e) => e.id === id);
if (ev) {
recordRef.current({
kind: 'update',
id,
prev: { date: ev.date, endDate: ev.endDate ?? null },
next: { date, endDate },
});
}
updateEvent.mutate({ id, date, endDate: endDate ?? null });
}, [timelineEvents, updateEvent]);
const handleAddEvent = useCallback(() => setAddEventOpen(true), []);
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => { void utils.tasks.list.invalidate({ projectId }); },
@@ -237,21 +359,28 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
onError: (err) => notifyError('toast.note.createError', err),
});
// Suppress unused-variable lints for variables used indirectly via refs
void updateTask;
void deleteTask;
void suggestTasks;
if (isLoading) {
return (
<div className="mx-auto py-10 px-8 w-full max-w-6xl flex flex-col gap-6">
<div className="flex flex-col gap-2 pb-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-9 w-64" />
<Skeleton className="h-4 w-40" />
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="mx-auto py-10 px-8 w-full max-w-6xl flex flex-col gap-6">
<div className="flex flex-col gap-2 pb-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-9 w-64" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-9 w-80" />
<div className="grid grid-cols-3 gap-4 mt-6">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
<Skeleton className="h-16 rounded-lg" />
</div>
<Skeleton className="h-9 w-80" />
<div className="grid grid-cols-3 gap-4 mt-6">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-20 rounded-lg" />
</div>
<Skeleton className="h-16 rounded-lg" />
</div>
);
}
@@ -264,82 +393,123 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
);
}
const projectName = breadcrumbPath.length > 0 ? project.name : '';
const subtitle = breadcrumbPath.length > 0 ? breadcrumbPath[breadcrumbPath.length - 1] : project.name;
return (
<div className="mx-auto py-10 px-8 flex flex-col gap-6 w-full max-w-6xl">
{/* Hero — same style as settings page */}
<div className="pb-1">
{breadcrumbPath.length > 1 && (
<Breadcrumb className="mb-3">
<BreadcrumbList>
{breadcrumbPath.slice(0, -1).map((segment, i) => (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
<span className="text-muted-foreground text-xs">{segment}</span>
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Sticky shrinking hero */}
<div
ref={heroRef}
data-compact={compact}
className={cn(
'sticky top-0 z-30 bg-background border-b border-border/40',
'transition-[padding] duration-200 ease-out',
compact ? 'py-2' : 'pt-8 pb-4',
)}
<h1 className="text-3xl font-semibold tracking-tight leading-tight">
{breadcrumbPath.length > 0 ? project.name : ''}
<br />
<span className="text-muted-foreground/50">
{breadcrumbPath.length > 0 ? breadcrumbPath[breadcrumbPath.length - 1] : project.name}
</span>
</h1>
>
<div className="mx-auto max-w-6xl px-8">
{breadcrumbPath.length > 1 && (
<div
className={cn(
'overflow-hidden transition-all duration-200 ease-out',
compact ? 'max-h-0 mb-0 opacity-0' : 'max-h-6 mb-3 opacity-100',
)}
>
<Breadcrumb>
<BreadcrumbList>
{breadcrumbPath.slice(0, -1).map((segment, i) => (
<Fragment key={i}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
<span className="text-muted-foreground text-xs">{segment}</span>
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
)}
<h1
className={cn(
'font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
)}
>
{projectName}
{projectName && subtitle && (
compact
? <span className="text-muted-foreground/60 font-normal"> · </span>
: <br />
)}
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
{subtitle}
</span>
</h1>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue={initialTab ?? 'overview'}>
<TabsList variant="line">
<TabsTrigger value="overview">{t('projects.overview')}</TabsTrigger>
<TabsTrigger value="timeline">{t('projects.projectTimeline')}</TabsTrigger>
<TabsTrigger value="tasks">{t('projects.tasks')}</TabsTrigger>
<TabsTrigger value="notes">{t('projects.notes')}</TabsTrigger>
</TabsList>
{/* Sticky tab bar — owns activeSection state and scroll spy */}
<ProjectTabBar
sectionRefs={sectionRefs}
scrollRef={scrollRef}
heroRef={heroRef}
initialTab={initialTab}
/>
{/* Overview */}
<TabsContent value="overview" className="mt-6">
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-4">
<Item variant="muted">
<ItemMedia variant="icon"><FileText /></ItemMedia>
<ItemContent>
<ItemTitle>{notesCount}</ItemTitle>
<ItemDescription>{t('projects.notes')}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon"><CheckCircle2 /></ItemMedia>
<ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>{t('projects.tasksComplete')}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon"><CalendarDays /></ItemMedia>
<ItemContent>
<ItemTitle>{eventStats.completed}/{eventStats.total}</ItemTitle>
<ItemDescription>{t('projects.events')}</ItemDescription>
</ItemContent>
</Item>
</div>
<Item variant="outline">
<ItemMedia variant="icon"><Sparkles /></ItemMedia>
{/* Page body */}
<div className="mx-auto max-w-6xl px-8 py-8 flex flex-col gap-16 w-full">
{/* Overview section */}
<section
ref={summaryRef}
data-section="overview"
data-ai-section="project-summary"
className="flex flex-col gap-4"
>
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.overview')}</h1>
<div className="grid grid-cols-3 gap-4">
<Item variant="muted">
<ItemMedia variant="icon"><FileText /></ItemMedia>
<ItemContent>
<ItemTitle>{t('projects.aiProjectSummary')}</ItemTitle>
<ItemDescription>
{project.aiSummary || t('projects.aiSummaryPlaceholder')}
</ItemDescription>
<ItemTitle>{notesCount}</ItemTitle>
<ItemDescription>{t('projects.notes')}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon"><CheckCircle2 /></ItemMedia>
<ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>{t('projects.tasksComplete')}</ItemDescription>
</ItemContent>
</Item>
<Item variant="muted">
<ItemMedia variant="icon"><CalendarDays /></ItemMedia>
<ItemContent>
<ItemTitle>{eventStats.completed}/{eventStats.total}</ItemTitle>
<ItemDescription>{t('projects.events')}</ItemDescription>
</ItemContent>
</Item>
</div>
</TabsContent>
<Item variant="outline">
<ItemMedia variant="icon"><Sparkles /></ItemMedia>
<ItemContent>
<ItemTitle>{t('projects.aiProjectSummary')}</ItemTitle>
<ItemDescription>
{project.aiSummary || t('projects.aiSummaryPlaceholder')}
</ItemDescription>
</ItemContent>
</Item>
</section>
{/* Timeline */}
<TabsContent value="timeline" className="mt-6">
{/* Timeline section */}
<section
ref={timelineRef}
data-section="timeline"
className="flex flex-col gap-4"
>
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.projectTimeline')}</h1>
<TimelineGanttView
className="h-[560px]"
projectGroups={[projectGroup]}
@@ -348,50 +518,12 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
onAdd={() => setAddEventOpen(true)}
onToggleComplete={(id, current) => {
record({
kind: 'update',
id,
prev: { isCompleted: current },
next: { isCompleted: current === 1 ? 0 : 1 },
});
updateEvent.mutate({ id, isCompleted: current === 1 ? 0 : 1 });
}}
onDelete={(id) => {
const ev = timelineEvents.find((e) => e.id === id);
if (ev) {
record({
kind: 'delete',
id,
snapshot: {
id: ev.id,
projectId: ev.projectId ?? null,
title: ev.title,
date: ev.date,
endDate: ev.endDate ?? null,
type: (ev.type ?? 'milestone') as EventSnapshot['type'],
isCompleted: ev.isCompleted ?? 0,
isAiSuggested: ev.isAiSuggested ?? 0,
},
});
}
deleteEvent.mutate({ id });
}}
onEdit={(ev) => setEditingEvent(ev)}
onAdd={handleAddEvent}
onToggleComplete={handleToggleComplete}
onDelete={handleDeleteEvent}
onEdit={handleEditEvent}
onDuplicate={handleDuplicate}
onMove={(id, date, endDate) => {
const ev = timelineEvents.find((e) => e.id === id);
if (ev) {
record({
kind: 'update',
id,
prev: { date: ev.date, endDate: ev.endDate ?? null },
next: { date, endDate },
});
}
updateEvent.mutate({ id, date, endDate: endDate ?? null });
}}
onMove={handleMoveEvent}
sectionId="project-timeline"
sectionLabel="Project Timeline"
projectId={projectId}
@@ -407,67 +539,75 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
onOpenChange={(open) => { if (!open) setEditingEvent(null); }}
onRecordHistory={record}
/>
</TabsContent>
</section>
{/* Tasks */}
<TabsContent value="tasks" className="mt-6">
<div ref={tasksRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t('projects.tasks')}</h2>
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus data-icon="inline-start" />
{t('common.add')}
</Button>
</div>
<KanbanBoard
projectId={projectId}
newTaskOpen={newTaskOpen}
onNewTaskOpenChange={setNewTaskOpen}
/>
{/* Tasks section */}
<section
ref={tasksRef}
data-section="tasks"
data-ai-section="project-tasks"
className="flex flex-col gap-4"
>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.tasks')}</h1>
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus data-icon="inline-start" />
{t('common.add')}
</Button>
</div>
</TabsContent>
<KanbanBoard
projectId={projectId}
newTaskOpen={newTaskOpen}
onNewTaskOpenChange={setNewTaskOpen}
/>
</section>
{/* Notes */}
<TabsContent value="notes" className="mt-6">
<div ref={notesRef} data-ai-section="project-notes" className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t('projects.notes')}</h2>
<Button
variant="secondary"
size="sm"
disabled={createNote.isPending}
onClick={() =>
createNote.mutate({ title: t('projects.untitledNote'), content: '', projectId })
}
>
<Plus data-icon="inline-start" />
{t('common.add')}
</Button>
</div>
{notesList && notesList.length > 0 ? (
<div className="rounded-lg border divide-y divide-border overflow-hidden">
{notesList.map((note) => (
<button
key={note.id}
className="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
onClick={() =>
void navigate({ to: '/notes/$noteId', params: { noteId: note.id } })
}
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm font-medium">{note.title}</span>
<span className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
{formatDate(note.createdAt, prefs)}
</span>
</button>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('projects.noNotesYet')}</p>
)}
{/* Notes section */}
<section
ref={notesRef}
data-section="notes"
data-ai-section="project-notes"
className="flex flex-col gap-4 pb-16"
>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.notes')}</h1>
<Button
variant="secondary"
size="sm"
disabled={createNote.isPending}
onClick={() =>
createNote.mutate({ title: t('projects.untitledNote'), content: '', projectId })
}
>
<Plus data-icon="inline-start" />
{t('common.add')}
</Button>
</div>
</TabsContent>
</Tabs>
{notesList && notesList.length > 0 ? (
<div className="rounded-lg border divide-y divide-border overflow-hidden">
{notesList.map((note) => (
<button
key={note.id}
className="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
onClick={() =>
void navigate({ to: '/notes/$noteId', params: { noteId: note.id } })
}
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate text-sm font-medium">{note.title}</span>
<span className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
{formatDate(note.createdAt, prefs)}
</span>
</button>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('projects.noNotesYet')}</p>
)}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useState, useEffect, useCallback, type RefObject } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;
export type SectionId = typeof SECTIONS[number];
interface ProjectTabBarProps {
sectionRefs: Record<SectionId, RefObject<HTMLDivElement | null>>;
scrollRef: RefObject<HTMLDivElement | null>;
heroRef: RefObject<HTMLDivElement | null>;
initialTab?: string;
}
export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: ProjectTabBarProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [activeSection, setActiveSection] = useState<SectionId>(
(SECTIONS.includes(initialTab as SectionId) ? initialTab : 'overview') as SectionId,
);
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const tabBarH = 41;
const visible = new Map<SectionId, IntersectionObserverEntry>();
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
const id = e.target.getAttribute('data-section') as SectionId | null;
if (!id) continue;
if (e.isIntersecting) visible.set(id, e);
else visible.delete(id);
}
if (visible.size === 0) return;
let best: SectionId | null = null;
let bestTop = Infinity;
for (const [id, e] of visible) {
const top = e.boundingClientRect.top;
if (top >= 0 && top < bestTop) { bestTop = top; best = id; }
}
if (!best) {
bestTop = -Infinity;
for (const [id, e] of visible) {
const top = e.boundingClientRect.top;
if (top > bestTop) { bestTop = top; best = id; }
}
}
if (best) setActiveSection(best);
},
{ root, rootMargin: `-${heroH + tabBarH}px 0px -50% 0px`, threshold: 0 },
);
for (const ref of Object.values(sectionRefs)) {
if (ref.current) observer.observe(ref.current);
}
return () => observer.disconnect();
}, [sectionRefs, scrollRef, heroRef]);
const scrollToSection = useCallback((id: SectionId) => {
const el = scrollRef.current;
if (!el) return;
if (id === 'overview') {
el.scrollTo({ top: 0, behavior: 'smooth' });
} else {
const ref = sectionRefs[id];
if (!ref?.current) return;
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
const sectionTop = ref.current.getBoundingClientRect().top;
const containerTop = el.getBoundingClientRect().top;
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
void navigate({
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: id }),
replace: true,
});
}, [sectionRefs, scrollRef, heroRef, navigate]);
const TAB_LABELS: Record<SectionId, string> = {
overview: t('projects.overview'),
timeline: t('projects.projectTimeline'),
tasks: t('projects.tasks'),
notes: t('projects.notes'),
};
return (
<nav
className="sticky z-20 bg-background border-b border-border/40"
style={{ top: 'var(--hero-h)' }}
>
<div className="mx-auto max-w-6xl px-8 flex gap-0">
{SECTIONS.map((id) => (
<button
key={id}
type="button"
onClick={() => scrollToSection(id)}
className={cn(
'relative px-4 py-2.5 text-sm font-medium transition-colors',
'border-b-2 -mb-px',
activeSection === id
? 'border-foreground text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{TAB_LABELS[id]}
</button>
))}
</div>
</nav>
);
}

View File

@@ -158,9 +158,8 @@ export function ProjectTimeline({
{/* Timeline row */}
<div
className="relative"
className="relative flex-1"
style={{
width: timelineWidth,
minWidth: timelineWidth,
height: ROW_HEIGHT,
backgroundColor: isOdd ? 'color-mix(in srgb, var(--muted) 35%, transparent)' : undefined,

View File

@@ -83,6 +83,19 @@ export function TimelineAxisHeader({
const x1 = dateToX(monthStart, startDate, endDate, width, paddingX);
const x2 = dateToX(monthEnd, startDate, endDate, width, paddingX);
const labelX = (x1 + x2) / 2;
const monthPixelWidth = x2 - x1;
let label: string;
if (monthPixelWidth >= 60) {
label = month.toLocaleString(undefined, { month: 'short', year: 'numeric' });
} else if (monthPixelWidth >= 28) {
label = month.toLocaleString(undefined, { month: 'short' });
} else if (month.getMonth() === 0) {
label = String(month.getFullYear());
} else {
return null;
}
return (
<g key={month.toISOString()}>
<text
@@ -94,17 +107,26 @@ export function TimelineAxisHeader({
fontWeight={600}
letterSpacing="-0.3"
>
{month.toLocaleString(undefined, { month: 'short', year: 'numeric' })}
{label}
</text>
</g>
);
})}
{/* Secondary ticks */}
{secondaryTicks.map((tick) => {
{secondaryTicks.map((tick, tickIdx) => {
const x = dateToX(tick.getTime(), startDate, endDate, width, paddingX);
if (x < leftEdge || x > rightEdge) return null;
// On month granularity, skip ticks that would overlap (< 28px apart)
if (!showDays && !showWeeks) {
const nextTick = secondaryTicks[tickIdx + 1];
if (nextTick) {
const nextX = dateToX(nextTick.getTime(), startDate, endDate, width, paddingX);
if (nextX - x < 28) return null;
}
}
const label = showDays
? String(tick.getDate())
: showWeeks

View File

@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useRef, useState, useMemo, type ReactNode } from 'react';
import { memo, useEffect, useLayoutEffect, useRef, useState, useMemo, type ReactNode } from 'react';
import {
ChartGantt,
Sparkles,
@@ -59,7 +59,7 @@ export interface TimelineGanttViewProps {
className?: string;
}
export function TimelineGanttView({
function TimelineGanttViewInner({
projectGroups,
events,
canUndo,
@@ -264,10 +264,10 @@ export function TimelineGanttView({
<div className="relative" style={{ minWidth: GANTT_LABEL_WIDTH + timelineWidth }}>
{/* Sticky calendar header */}
<div className="flex sticky top-0 z-20 bg-card">
<div className="flex sticky top-0 z-10 bg-card">
<div
style={{ width: GANTT_LABEL_WIDTH, minWidth: GANTT_LABEL_WIDTH }}
className="sticky left-0 z-[21] bg-card shrink-0 flex items-center justify-end px-2"
className="sticky left-0 z-[11] bg-card shrink-0 flex items-center justify-end px-2"
>
<Button
size="sm"
@@ -345,7 +345,7 @@ export function TimelineGanttView({
</Badge>
)}
</div>
<div style={{ width: timelineWidth }} />
<div className="flex-1" style={{ minWidth: timelineWidth }} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -372,3 +372,5 @@ export function TimelineGanttView({
</div>
);
}
export const TimelineGanttView = memo(TimelineGanttViewInner);

View File

@@ -39,12 +39,12 @@ export interface ChatMessage {
interface UseAIChatReturn {
messages: ChatMessage[];
input: string;
setInput: (v: string) => void;
isStreaming: boolean;
streamingContent: string;
handleSend: (overrideMessage?: string, overrideContext?: UIChatContext) => void;
handleSend: (message: string, overrideContext?: UIChatContext) => void;
clearMessages: (resetSession?: boolean) => void;
/** Cache key for this chat context — pass to ChatInputBox for draft persistence. */
cacheKey: string;
}
interface UseAIChatOptions {
@@ -53,6 +53,7 @@ interface UseAIChatOptions {
interface CachedChatState {
messages: ChatMessage[];
/** Written by ChatInputBox; read on mount to restore draft. Not written by this hook. */
input: string;
sessionId: string;
}
@@ -68,6 +69,23 @@ function getContextCacheKey(ctx: UIChatContext): string {
return 'floating';
}
// ---------------------------------------------------------------------------
// Draft helpers — used by ChatInputBox to persist the textarea value
// ---------------------------------------------------------------------------
export function readInputDraft(key: string): string {
return chatSessionCache.get(key)?.input ?? '';
}
export function writeInputDraft(key: string, value: string): void {
const existing = chatSessionCache.get(key);
chatSessionCache.set(key, {
messages: existing?.messages ?? [],
sessionId: existing?.sessionId ?? crypto.randomUUID(),
input: value,
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -112,9 +130,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
const [messages, setMessages] = useState<ChatMessage[]>(
() => chatSessionCache.get(contextCacheKey)?.messages ?? [],
);
const [input, setInput] = useState(
() => chatSessionCache.get(contextCacheKey)?.input ?? '',
);
const [sessionId, setSessionId] = useState<string>(
() => chatSessionCache.get(contextCacheKey)?.sessionId ?? crypto.randomUUID(),
);
@@ -124,35 +139,51 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
// Refs for values used inside handleSend/clearMessages callbacks.
// Using refs breaks the stale-closure chain so these callbacks are not
// recreated on every render.
const chatMutationRef = useRef(chatMutation);
chatMutationRef.current = chatMutation;
const messagesRef = useRef(messages);
messagesRef.current = messages;
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const onDomainSignalRef = useRef(options?.onDomainSignal);
onDomainSignalRef.current = options?.onDomainSignal;
// Keep local state aligned when the chat context changes in-place.
useEffect(() => {
const cached = chatSessionCache.get(contextCacheKey);
setMessages(cached?.messages ?? []);
setInput(cached?.input ?? '');
setSessionId(cached?.sessionId ?? crypto.randomUUID());
setIsStreaming(false);
setStreamingContent('');
streamingContentRef.current = '';
// ChatInputBox re-inits its own draft via its cacheKey effect.
}, [contextCacheKey]);
// Persist the chat session so remounting the panel restores the conversation.
// Persist messages + sessionId so remounting the panel restores the conversation.
// input is managed separately by ChatInputBox via writeInputDraft.
useEffect(() => {
chatSessionCache.set(contextCacheKey, { messages, input, sessionId });
}, [contextCacheKey, messages, input, sessionId]);
chatSessionCache.set(contextCacheKey, {
messages,
sessionId,
input: chatSessionCache.get(contextCacheKey)?.input ?? '',
});
}, [contextCacheKey, messages, sessionId]);
const clearMessages = useCallback((resetSession = true) => {
const newSessionId = resetSession ? crypto.randomUUID() : sessionId;
const newSessionId = resetSession ? crypto.randomUUID() : sessionIdRef.current;
setMessages([]);
setStreamingContent('');
streamingContentRef.current = '';
setInput('');
if (resetSession) setSessionId(newSessionId);
chatSessionCache.set(contextCacheKey, { messages: [], input: '', sessionId: newSessionId });
}, [contextCacheKey, sessionId]);
}, [contextCacheKey]);
const handleSend = useCallback(
(overrideMessage?: string, overrideContext?: UIChatContext) => {
const trimmed = (overrideMessage ?? input).trim();
(message: string, overrideContext?: UIChatContext) => {
const trimmed = message.trim();
if (!trimmed || isStreaming) return;
const ctx = overrideContext ?? defaultContext;
@@ -165,7 +196,6 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
const requestId = crypto.randomUUID();
setMessages((prev) => [...prev, userMsg]);
if (!overrideMessage) setInput('');
setIsStreaming(true);
setStreamingContent('');
streamingContentRef.current = '';
@@ -205,25 +235,25 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
}
case 'floating_domain':
options?.onDomainSignal?.(event.domain);
onDomainSignalRef.current?.(event.domain);
break;
}
});
// Build conversation history from current messages (before this send)
const conversationHistory = messages.map((m) => ({
// Build conversation history from current messages (before this send), capped to last 20 turns
const conversationHistory = messagesRef.current.slice(-20).map((m) => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
const isFloating = ctx.type === 'floating';
chatMutation.mutate(
chatMutationRef.current.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId,
sessionId: sessionIdRef.current,
...(isFloating && ctx.scope
? { mode: 'floating' as const, scope: ctx.scope }
: {}),
@@ -279,16 +309,15 @@ export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOpti
},
);
},
[input, isStreaming, defaultContext, chatMutation, messages, options, sessionId],
[isStreaming, defaultContext],
);
return {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend,
clearMessages,
cacheKey: contextCacheKey,
};
}

View File

@@ -4,7 +4,6 @@ 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';
import { useTranslation } from 'react-i18next';
const searchSchema = z.object({
@@ -32,7 +31,7 @@ function ProjectsPage() {
selectedProjectId={projectId}
onSelectProject={handleSelectProject}
/>
<ScrollArea className="flex-1">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{projectId ? (
<ProjectDetail projectId={projectId} initialTab={tab} />
) : (
@@ -48,7 +47,7 @@ function ProjectsPage() {
</EmptyHeader>
</Empty>
)}
</ScrollArea>
</div>
</div>
);
}

View File

@@ -101,6 +101,9 @@ export const WsFloatingRequestSchema = z.object({
type: z.enum(['task', 'project', 'note', 'timeline']),
id: z.string().optional(),
}),
conversationHistory: z
.array(z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string() }))
.optional(),
});
export type WsFloatingRequest = z.infer<typeof WsFloatingRequestSchema>;