Compare commits
1 Commits
a04c2434b6
...
259ab50b25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
259ab50b25 |
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -925,6 +925,7 @@ const aiRouter = router({
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
scope: input.scope,
|
||||
conversationHistory: input.conversationHistory,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
135
src/renderer/components/ai/ChatInputBox.tsx
Normal file
135
src/renderer/components/ai/ChatInputBox.tsx
Normal 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';
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -80,7 +80,6 @@ function TaskEntityBlock({ ids }: { ids: string[] }) {
|
||||
task={task}
|
||||
onToggle={handleToggle}
|
||||
onClick={setViewTask}
|
||||
hideBreadcrumb
|
||||
/>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
113
src/renderer/components/projects/ProjectTabBar.tsx
Normal file
113
src/renderer/components/projects/ProjectTabBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user