Compare commits
1 Commits
c5e78311e6
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15051cfa7a |
@@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat();
|
|||||||
|
|
||||||
## Step 8a: Page Interactions — Project Detail
|
## Step 8a: Page Interactions — Project Detail
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
|
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
|
||||||
|
|
||||||
@@ -1022,7 +1022,7 @@ Repeat for all 4 sections.
|
|||||||
|
|
||||||
## Step 8b: Page Interactions — Tasks Page
|
## Step 8b: Page Interactions — Tasks Page
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/routes/tasks.tsx`
|
**Modifies**: `src/renderer/routes/tasks.tsx`
|
||||||
|
|
||||||
@@ -1055,7 +1055,7 @@ Same pattern as 8a — create refs, add `data-ai-section` attributes, register i
|
|||||||
|
|
||||||
## Step 8c: Page Interactions — Timeline Page
|
## Step 8c: Page Interactions — Timeline Page
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/routes/timeline.tsx`
|
**Modifies**: `src/renderer/routes/timeline.tsx`
|
||||||
|
|
||||||
@@ -1082,7 +1082,7 @@ Register 1 section:
|
|||||||
|
|
||||||
## Step 8d: Page Interactions — Notes Page (Milkdown)
|
## Step 8d: Page Interactions — Notes Page (Milkdown)
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] (2026-02-28)
|
||||||
**Prerequisites**: Steps 1-4 completed
|
**Prerequisites**: Steps 1-4 completed
|
||||||
**Modifies**: `src/renderer/routes/notes.$noteId.tsx`
|
**Modifies**: `src/renderer/routes/notes.$noteId.tsx`
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { useRouterState } from '@tanstack/react-router';
|
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||||
import { X, ArrowUp } from 'lucide-react';
|
import { X, ArrowUp } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFloatingChat,
|
useFloatingChat,
|
||||||
|
computeDualAnchor,
|
||||||
CHAT_WIDTH,
|
CHAT_WIDTH,
|
||||||
CHAT_HEIGHT,
|
CHAT_HEIGHT,
|
||||||
PADDING,
|
PADDING,
|
||||||
@@ -14,9 +15,22 @@ import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
|
/** Map section IDs to their routes for cross-page navigation */
|
||||||
|
const SECTION_ROUTES: Record<string, string> = {
|
||||||
|
'project-summary': 'project',
|
||||||
|
'project-timeline': 'project',
|
||||||
|
'project-tasks': 'project',
|
||||||
|
'project-notes': 'project',
|
||||||
|
'tasks-overview': '/tasks',
|
||||||
|
'tasks-list': '/tasks',
|
||||||
|
'timeline-chart': '/timeline',
|
||||||
|
'note-editor': 'note',
|
||||||
|
};
|
||||||
|
|
||||||
function FloatingChatInner() {
|
function FloatingChatInner() {
|
||||||
const { state, sections, close, setMorphTarget } = useFloatingChat();
|
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
const navigate = useNavigate();
|
||||||
const routerState = useRouterState();
|
const routerState = useRouterState();
|
||||||
const prevPathRef = useRef(routerState.location.pathname);
|
const prevPathRef = useRef(routerState.location.pathname);
|
||||||
|
|
||||||
@@ -33,6 +47,32 @@ function FloatingChatInner() {
|
|||||||
[activeSection?.projectId, activeSection?.label],
|
[activeSection?.projectId, activeSection?.label],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle [SECTION:xxx] tags from AI responses
|
||||||
|
const handleSectionTag = useCallback((sectionId: string) => {
|
||||||
|
// Same-page: section is already registered
|
||||||
|
const targetSection = sections.get(sectionId);
|
||||||
|
if (targetSection) {
|
||||||
|
moveToSection(sectionId);
|
||||||
|
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-page: section not registered, navigate to its route
|
||||||
|
const route = SECTION_ROUTES[sectionId];
|
||||||
|
if (!route) return;
|
||||||
|
|
||||||
|
setPendingSection({ sectionId });
|
||||||
|
|
||||||
|
if (route === 'project' && state.projectId) {
|
||||||
|
// Navigate to the project page (stay on same project)
|
||||||
|
// Project sections re-register on mount and pendingSection will auto-open
|
||||||
|
void navigate({ to: '/projects/$projectId', params: { projectId: state.projectId } });
|
||||||
|
} else if (route.startsWith('/')) {
|
||||||
|
void navigate({ to: route });
|
||||||
|
}
|
||||||
|
// 'note' type requires noteId — skip cross-page for now
|
||||||
|
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
input,
|
input,
|
||||||
@@ -41,7 +81,7 @@ function FloatingChatInner() {
|
|||||||
streamingContent,
|
streamingContent,
|
||||||
handleSend,
|
handleSend,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
} = useAIChat(chatContext);
|
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -59,15 +99,15 @@ function FloatingChatInner() {
|
|||||||
return () => document.removeEventListener('keydown', handler);
|
return () => document.removeEventListener('keydown', handler);
|
||||||
}, [state.isOpen, close]);
|
}, [state.isOpen, close]);
|
||||||
|
|
||||||
// ---- Close on route change ----
|
// ---- Close on route change (unless cross-page navigation pending) ----
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPath = routerState.location.pathname;
|
const currentPath = routerState.location.pathname;
|
||||||
if (prevPathRef.current !== currentPath && state.isOpen) {
|
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
prevPathRef.current = currentPath;
|
prevPathRef.current = currentPath;
|
||||||
}, [routerState.location.pathname, state.isOpen, close]);
|
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||||
|
|
||||||
// ---- Clear messages on close ----
|
// ---- Clear messages on close ----
|
||||||
|
|
||||||
@@ -123,6 +163,51 @@ function FloatingChatInner() {
|
|||||||
return () => window.removeEventListener('resize', handler);
|
return () => window.removeEventListener('resize', handler);
|
||||||
}, [state.isOpen, state.position.x, state.position.y]);
|
}, [state.isOpen, state.position.x, state.position.y]);
|
||||||
|
|
||||||
|
// ---- Scroll tracking: dual-anchor repositioning ----
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.isOpen || !state.activeSectionId) return;
|
||||||
|
const section = sections.get(state.activeSectionId);
|
||||||
|
if (!section || section.anchorMode === 'right-margin') return;
|
||||||
|
|
||||||
|
const el = section.ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// Find scrollable ancestor
|
||||||
|
let scrollParent: HTMLElement | null = el.parentElement;
|
||||||
|
while (scrollParent) {
|
||||||
|
const style = getComputedStyle(scrollParent);
|
||||||
|
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
|
||||||
|
style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Also check for Radix ScrollArea viewport
|
||||||
|
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
|
||||||
|
scrollParent = scrollParent.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scrollParent) return;
|
||||||
|
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = null;
|
||||||
|
const newPos = computeDualAnchor(section);
|
||||||
|
if (newPos) {
|
||||||
|
updatePosition(newPos);
|
||||||
|
}
|
||||||
|
// null = fully off-screen → freeze (do nothing)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
scrollParent.removeEventListener('scroll', handleScroll);
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
|
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
|
||||||
|
|
||||||
// ---- Auto-scroll messages ----
|
// ---- Auto-scroll messages ----
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useMemo, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
|
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
@@ -16,6 +16,7 @@ import { KanbanBoard } from './KanbanBoard';
|
|||||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
|
|
||||||
type ProjectDetailProps = {
|
type ProjectDetailProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -26,6 +27,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
||||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// AI section refs
|
||||||
|
const summaryRef = useRef<HTMLDivElement>(null);
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tasksRef = useRef<HTMLDivElement>(null);
|
||||||
|
const notesRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { registerSection, unregisterSection } = useFloatingChat();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
|
||||||
|
registerSection({ id: 'project-timeline', label: 'Project Timeline', ref: timelineRef, projectId });
|
||||||
|
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
|
||||||
|
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
|
||||||
|
return () => {
|
||||||
|
unregisterSection('project-summary');
|
||||||
|
unregisterSection('project-timeline');
|
||||||
|
unregisterSection('project-tasks');
|
||||||
|
unregisterSection('project-notes');
|
||||||
|
};
|
||||||
|
}, [projectId, registerSection, unregisterSection]);
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||||
const { data: clientsList } = trpc.clients.list.useQuery();
|
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||||
@@ -181,6 +202,8 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project Summary Section */}
|
||||||
|
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
|
||||||
{/* Stat Cards */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
@@ -226,9 +249,10 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Project Timeline */}
|
{/* Project Timeline */}
|
||||||
<div className="flex flex-col gap-3">
|
<div ref={timelineRef} data-ai-section="project-timeline" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -306,7 +330,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks Kanban */}
|
{/* Tasks Kanban */}
|
||||||
<div className="flex flex-col gap-3">
|
<div ref={tasksRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -372,7 +396,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-3">
|
<div ref={notesRef} data-ai-section="project-notes" className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Notes</h2>
|
<h2 className="text-lg font-semibold">Notes</h2>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface AISection {
|
|||||||
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
||||||
ref: RefObject<HTMLElement | null>;
|
ref: RefObject<HTMLElement | null>;
|
||||||
projectId?: string; // If section is project-scoped
|
projectId?: string; // If section is project-scoped
|
||||||
|
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionOpenOpts {
|
||||||
|
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FloatingChatState {
|
interface FloatingChatState {
|
||||||
@@ -24,6 +29,7 @@ interface FloatingChatState {
|
|||||||
position: { x: number; y: number; width: number };
|
position: { x: number; y: number; width: number };
|
||||||
morphTargetId: string | null;
|
morphTargetId: string | null;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FloatingChatContextValue {
|
interface FloatingChatContextValue {
|
||||||
@@ -36,10 +42,12 @@ interface FloatingChatContextValue {
|
|||||||
unregisterSection: (id: string) => void;
|
unregisterSection: (id: string) => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
openAtSection: (sectionId: string) => void;
|
openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||||
moveToSection: (sectionId: string) => void;
|
moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
setMorphTarget: (id: string | null) => void;
|
setMorphTarget: (id: string | null) => void;
|
||||||
|
updatePosition: (pos: { x: number; y: number; width: number }) => void;
|
||||||
|
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Constants ----------
|
// ---------- Constants ----------
|
||||||
@@ -50,25 +58,79 @@ export const PADDING = 16;
|
|||||||
|
|
||||||
// ---------- Position computation ----------
|
// ---------- Position computation ----------
|
||||||
|
|
||||||
|
function clampPosition(x: number, y: number): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)),
|
||||||
|
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function computeAnchorPosition(
|
function computeAnchorPosition(
|
||||||
sectionRef: RefObject<HTMLElement | null>,
|
section: AISection,
|
||||||
|
opts?: SectionOpenOpts,
|
||||||
): { x: number; y: number; width: number } {
|
): { x: number; y: number; width: number } {
|
||||||
const el = sectionRef.current;
|
const el = section.ref.current;
|
||||||
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
|
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
const mode = section.anchorMode ?? 'top-right';
|
||||||
|
|
||||||
// Anchor to top-right of section, offset inward
|
if (mode === 'right-margin') {
|
||||||
let x = rect.right - CHAT_WIDTH - PADDING;
|
// Position to the right of the section at the click Y-coordinate
|
||||||
let y = rect.top + PADDING;
|
const rawX = rect.right + PADDING;
|
||||||
|
const rawY = opts?.clickY ?? rect.top + PADDING;
|
||||||
|
const { x, y } = clampPosition(rawX, rawY);
|
||||||
|
return { x, y, width: CHAT_WIDTH };
|
||||||
|
}
|
||||||
|
|
||||||
// Edge-collision clamping
|
// Default: top-right of section
|
||||||
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING));
|
const rawX = rect.right - CHAT_WIDTH - PADDING;
|
||||||
y = Math.max(
|
const rawY = rect.top + PADDING;
|
||||||
PADDING,
|
const { x, y } = clampPosition(rawX, rawY);
|
||||||
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING),
|
return { x, y, width: CHAT_WIDTH };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dual-anchor recomputation for scroll tracking.
|
||||||
|
* Returns null when the section is fully off-screen (freeze at last position).
|
||||||
|
*/
|
||||||
|
export function computeDualAnchor(
|
||||||
|
section: AISection,
|
||||||
|
): { x: number; y: number; width: number } | null {
|
||||||
|
const el = section.ref.current;
|
||||||
|
if (!el) return null;
|
||||||
|
|
||||||
|
// Skip scroll tracking for right-margin mode (stays at fixed clickY)
|
||||||
|
if (section.anchorMode === 'right-margin') return null;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Fully off-screen — freeze
|
||||||
|
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
|
||||||
|
|
||||||
|
// Primary anchor: top-right (when section top is visible)
|
||||||
|
if (rect.top >= PADDING) {
|
||||||
|
const { x, y } = clampPosition(
|
||||||
|
rect.right - CHAT_WIDTH - PADDING,
|
||||||
|
rect.top + PADDING,
|
||||||
);
|
);
|
||||||
|
return { x, y, width: CHAT_WIDTH };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback anchor: bottom-right (when section top scrolled off)
|
||||||
|
if (rect.bottom > CHAT_HEIGHT) {
|
||||||
|
const { x, y } = clampPosition(
|
||||||
|
rect.right - CHAT_WIDTH - PADDING,
|
||||||
|
rect.bottom - CHAT_HEIGHT - PADDING,
|
||||||
|
);
|
||||||
|
return { x, y, width: CHAT_WIDTH };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section visible but too small for fallback — clamp to top
|
||||||
|
const { x, y } = clampPosition(
|
||||||
|
rect.right - CHAT_WIDTH - PADDING,
|
||||||
|
PADDING,
|
||||||
|
);
|
||||||
return { x, y, width: CHAT_WIDTH };
|
return { x, y, width: CHAT_WIDTH };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +170,23 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
|||||||
const registerSection = useCallback((section: AISection) => {
|
const registerSection = useCallback((section: AISection) => {
|
||||||
sectionsRef.current.set(section.id, section);
|
sectionsRef.current.set(section.id, section);
|
||||||
setSections(new Map(sectionsRef.current));
|
setSections(new Map(sectionsRef.current));
|
||||||
|
|
||||||
|
// Check if there's a pending section to open after cross-page navigation
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.pendingSection && prev.pendingSection.sectionId === section.id) {
|
||||||
|
const position = computeAnchorPosition(section, { clickY: prev.pendingSection.clickY });
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
isOpen: true,
|
||||||
|
activeSectionId: section.id,
|
||||||
|
position,
|
||||||
|
morphTargetId: null,
|
||||||
|
projectId: section.projectId,
|
||||||
|
pendingSection: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unregisterSection = useCallback((id: string) => {
|
const unregisterSection = useCallback((id: string) => {
|
||||||
@@ -115,11 +194,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
|||||||
setSections(new Map(sectionsRef.current));
|
setSections(new Map(sectionsRef.current));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openAtSection = useCallback((sectionId: string) => {
|
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||||
const section = sectionsRef.current.get(sectionId);
|
const section = sectionsRef.current.get(sectionId);
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
const position = computeAnchorPosition(section.ref);
|
const position = computeAnchorPosition(section, opts);
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -130,11 +209,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const moveToSection = useCallback((sectionId: string) => {
|
const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||||
const section = sectionsRef.current.get(sectionId);
|
const section = sectionsRef.current.get(sectionId);
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
|
|
||||||
const position = computeAnchorPosition(section.ref);
|
const position = computeAnchorPosition(section, opts);
|
||||||
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -157,6 +236,14 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
|||||||
setState((prev) => ({ ...prev, morphTargetId: id }));
|
setState((prev) => ({ ...prev, morphTargetId: id }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const updatePosition = useCallback((pos: { x: number; y: number; width: number }) => {
|
||||||
|
setState((prev) => ({ ...prev, position: pos }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPendingSection = useCallback((pending: { sectionId: string; clickY?: number } | undefined) => {
|
||||||
|
setState((prev) => ({ ...prev, pendingSection: pending }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FloatingChatCtx.Provider
|
<FloatingChatCtx.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -168,6 +255,8 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
|||||||
moveToSection,
|
moveToSection,
|
||||||
close,
|
close,
|
||||||
setMorphTarget,
|
setMorphTarget,
|
||||||
|
updatePosition,
|
||||||
|
setPendingSection,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
|
|||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
|
export interface UseAIChatOptions {
|
||||||
|
onSectionTag?: (sectionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOptions): UseAIChatReturn {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
|
|||||||
|
|
||||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
||||||
if (done) {
|
if (done) {
|
||||||
const finalContent = streamingContentRef.current;
|
let finalContent = streamingContentRef.current;
|
||||||
|
|
||||||
|
// Parse and strip [SECTION:xxx] tag from AI response
|
||||||
|
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
|
||||||
|
if (sectionMatch) {
|
||||||
|
finalContent = finalContent.slice(sectionMatch[0].length);
|
||||||
|
options?.onSectionTag?.(sectionMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
|
|||||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||||
|
|
||||||
export function useDoubleClickAI(): void {
|
export function useDoubleClickAI(): void {
|
||||||
const { openAtSection, state } = useFloatingChat();
|
const { openAtSection, moveToSection, sections, state } = useFloatingChat();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -35,10 +35,20 @@ export function useDoubleClickAI(): void {
|
|||||||
// If popup is already open at THIS section, do nothing
|
// If popup is already open at THIS section, do nothing
|
||||||
if (state.isOpen && state.activeSectionId === sectionId) return;
|
if (state.isOpen && state.activeSectionId === sectionId) return;
|
||||||
|
|
||||||
openAtSection(sectionId);
|
// Build opts for right-margin sections
|
||||||
|
const section = sections.get(sectionId);
|
||||||
|
const opts = section?.anchorMode === 'right-margin' ? { clickY: e.clientY } : undefined;
|
||||||
|
|
||||||
|
// If chat is already open at a different section, move (keep conversation)
|
||||||
|
if (state.isOpen) {
|
||||||
|
moveToSection(sectionId, opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openAtSection(sectionId, opts);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('dblclick', handler);
|
document.addEventListener('dblclick', handler);
|
||||||
return () => document.removeEventListener('dblclick', handler);
|
return () => document.removeEventListener('dblclick', handler);
|
||||||
}, [openAtSection, state.isOpen, state.activeSectionId]);
|
}, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
|
|
||||||
export const Route = createFileRoute('/notes/$noteId')({
|
export const Route = createFileRoute('/notes/$noteId')({
|
||||||
component: NoteDetailPage,
|
component: NoteDetailPage,
|
||||||
@@ -29,6 +30,21 @@ function NoteDetailPage() {
|
|||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
|
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
|
||||||
|
|
||||||
|
// AI section — register with right-margin anchor mode
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { registerSection, unregisterSection } = useFloatingChat();
|
||||||
|
const noteProjectId = note?.projectId ?? undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
registerSection({
|
||||||
|
id: 'note-editor',
|
||||||
|
label: 'Note Editor',
|
||||||
|
ref: editorRef,
|
||||||
|
projectId: noteProjectId,
|
||||||
|
anchorMode: 'right-margin',
|
||||||
|
});
|
||||||
|
return () => unregisterSection('note-editor');
|
||||||
|
}, [noteId, noteProjectId, registerSection, unregisterSection]);
|
||||||
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -188,7 +204,7 @@ function NoteDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
<MilkdownEditor
|
<MilkdownEditor
|
||||||
key={noteId}
|
key={noteId}
|
||||||
|
|||||||
@@ -41,12 +41,17 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function TasksPage() {
|
function TasksPage() {
|
||||||
// Temporary test: register section for floating AI chat
|
// AI section refs
|
||||||
const testRef = useRef<HTMLDivElement>(null);
|
const overviewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
|
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
|
||||||
return () => unregisterSection('test');
|
registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
|
||||||
|
return () => {
|
||||||
|
unregisterSection('tasks-overview');
|
||||||
|
unregisterSection('tasks-list');
|
||||||
|
};
|
||||||
}, [registerSection, unregisterSection]);
|
}, [registerSection, unregisterSection]);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -121,7 +126,7 @@ function TasksPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6 w-full">
|
<div className="flex flex-col gap-6 p-6 w-full">
|
||||||
{/* Stat Cards */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<ClipboardCheck />
|
<ClipboardCheck />
|
||||||
@@ -160,6 +165,8 @@ function TasksPage() {
|
|||||||
</Item>
|
</Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Task List Section */}
|
||||||
|
<div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
|
||||||
{/* Search + Order By */}
|
{/* Search + Order By */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<InputGroup className="flex-1">
|
<InputGroup className="flex-1">
|
||||||
@@ -187,7 +194,7 @@ function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter Tabs + New Task Button */}
|
{/* Status Filter Tabs + New Task Button */}
|
||||||
<div ref={testRef} data-ai-section="test" className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="all">All</TabsTrigger>
|
<TabsTrigger value="all">All</TabsTrigger>
|
||||||
@@ -234,6 +241,7 @@ function TasksPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
<EditTaskDialog
|
<EditTaskDialog
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { useState, useMemo } from 'react';
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||||
@@ -15,6 +16,14 @@ function TimelinePage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||||
|
|
||||||
|
// AI section
|
||||||
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { registerSection, unregisterSection } = useFloatingChat();
|
||||||
|
useEffect(() => {
|
||||||
|
registerSection({ id: 'timeline-chart', label: 'Timeline', ref: timelineRef });
|
||||||
|
return () => unregisterSection('timeline-chart');
|
||||||
|
}, [registerSection, unregisterSection]);
|
||||||
|
|
||||||
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
|
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
|
||||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -70,7 +79,7 @@ function TimelinePage() {
|
|||||||
}, [ganttCheckpoints]);
|
}, [ganttCheckpoints]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6 w-full">
|
<div ref={timelineRef} data-ai-section="timeline-chart" className="flex flex-col gap-6 p-6 w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold">Timeline</h1>
|
<h1 className="text-xl font-semibold">Timeline</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user