diff --git a/docs/floating-ai-integration-guide.md b/docs/floating-ai-integration-guide.md index 8bda8e0..1463354 100644 --- a/docs/floating-ai-integration-guide.md +++ b/docs/floating-ai-integration-guide.md @@ -35,9 +35,9 @@ Steps MUST be implemented in order. Each step lists its prerequisites. | 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 | | 3 | Create double-click hook | [x] 2026-02-27 | | 4 | Build `FloatingChat` component | [x] 2026-02-27 | -| 5 | Add `ai:action` IPC side-channel | [ ] | -| 6 | Pass `uiContext` through to the AI | [ ] | -| 7 | Implement morph animation (FLIP) | [ ] | +| 5 | Add `ai:action` IPC side-channel | [x] 2026-02-28 | +| 6 | Pass `uiContext` through to the AI | [x] 2026-02-28 | +| 7 | Implement morph animation (FLIP) | [x] 2026-02-28 | | 8a | Page interactions — Project Detail | [ ] | | 8b | Page interactions — Tasks page | [ ] | | 8c | Page interactions — Timeline page | [ ] | diff --git a/src/renderer/components/ai/FloatingChat.tsx b/src/renderer/components/ai/FloatingChat.tsx index 7e1bb3d..c360cdf 100644 --- a/src/renderer/components/ai/FloatingChat.tsx +++ b/src/renderer/components/ai/FloatingChat.tsx @@ -12,9 +12,11 @@ import { import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; import { ChatMarkdown } from '@/components/ai/AIChatPanel'; import { Skeleton } from '@/components/ui/skeleton'; +import { trpc } from '@/lib/trpc'; function FloatingChatInner() { - const { state, sections, close } = useFloatingChat(); + const { state, sections, close, setMorphTarget } = useFloatingChat(); + const utils = trpc.useUtils(); const routerState = useRouterState(); const prevPathRef = useRef(routerState.location.pathname); @@ -77,6 +79,31 @@ function FloatingChatInner() { prevOpenRef.current = state.isOpen; }, [state.isOpen, clearMessages]); + // ---- AI action: morph into newly-created task ---- + + useEffect(() => { + if (!state.isOpen) return; + + const unsubscribe = window.electronAI.onAction((action) => { + if (action.type === 'task_created' && action.taskId) { + // Invalidate task queries so the new TaskRow renders + void utils.tasks.list.invalidate(); + + // Set the morph target layoutId + setMorphTarget(`task-morph-${action.taskId}`); + + // Wait for the TaskRow to render, then close (triggering FLIP) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + close(); + }); + }); + } + }); + + return unsubscribe; + }, [state.isOpen, utils, setMorphTarget, close]); + // ---- Window resize: keep within bounds ---- useEffect(() => { diff --git a/src/renderer/components/layout/AppShell.tsx b/src/renderer/components/layout/AppShell.tsx index 1641772..f0714dc 100644 --- a/src/renderer/components/layout/AppShell.tsx +++ b/src/renderer/components/layout/AppShell.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useRouterState } from '@tanstack/react-router'; -import { motion, useMotionValue, useSpring } from 'framer-motion'; +import { LayoutGroup, motion, useMotionValue, useSpring } from 'framer-motion'; import { House, ChartGantt, @@ -221,7 +221,7 @@ function AppShellInner({ children }: AppShellProps) { }, [openCurtain, closeCurtain]); return ( - <> + - + ); } diff --git a/src/renderer/components/projects/KanbanBoard.tsx b/src/renderer/components/projects/KanbanBoard.tsx index 4e3c22f..0aad3bb 100644 --- a/src/renderer/components/projects/KanbanBoard.tsx +++ b/src/renderer/components/projects/KanbanBoard.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useCallback } from 'react'; import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd'; import { trpc } from '@/lib/trpc'; +import { useFloatingChat } from '@/context/FloatingChatContext'; import { Badge } from '@/components/ui/badge'; import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow'; import { NewTaskDialog } from '@/components/tasks/NewTaskDialog'; @@ -22,6 +23,7 @@ type KanbanBoardProps = { }; export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) { + const { state: floatingState } = useFloatingChat(); const { data: tasksList } = trpc.tasks.list.useQuery({ projectId }); const utils = trpc.useUtils(); @@ -125,6 +127,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan onDelete={(id) => deleteTask.mutate({ id })} onClick={setViewTask} hideBreadcrumb + layoutId={ + floatingState.morphTargetId === `task-morph-${task.id}` + ? floatingState.morphTargetId + : undefined + } /> )} diff --git a/src/renderer/components/tasks/TaskRow.tsx b/src/renderer/components/tasks/TaskRow.tsx index 8089873..f852c61 100644 --- a/src/renderer/components/tasks/TaskRow.tsx +++ b/src/renderer/components/tasks/TaskRow.tsx @@ -1,3 +1,4 @@ +import { motion } from 'framer-motion'; import { Calendar, User, Pencil, Trash2 } from 'lucide-react'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; @@ -57,6 +58,7 @@ export function TaskRow({ onDelete, onClick, hideBreadcrumb, + layoutId, }: { task: TaskItem; onToggle: (id: string, status: string | null) => void; @@ -64,6 +66,7 @@ export function TaskRow({ onDelete?: (id: string) => void; onClick?: (task: TaskItem) => void; hideBreadcrumb?: boolean; + layoutId?: string; }) { const isDone = task.status === 'done'; @@ -84,10 +87,14 @@ export function TaskRow({ breadcrumb.length > 0 || task.assignee; + const Wrapper = layoutId ? motion.div : 'div'; + const wrapperProps = layoutId ? { layoutId, layout: true as const } : {}; + return ( -
)} -
+
diff --git a/src/renderer/routes/tasks.tsx b/src/renderer/routes/tasks.tsx index a20353f..8e7fe69 100644 --- a/src/renderer/routes/tasks.tsx +++ b/src/renderer/routes/tasks.tsx @@ -43,7 +43,7 @@ const ORDER_LABELS: Record = { function TasksPage() { // Temporary test: register section for floating AI chat const testRef = useRef(null); - const { registerSection, unregisterSection } = useFloatingChat(); + const { state: floatingState, registerSection, unregisterSection } = useFloatingChat(); useEffect(() => { registerSection({ id: 'test', label: 'Tasks', ref: testRef }); return () => unregisterSection('test'); @@ -225,6 +225,11 @@ function TasksPage() { onEdit={setEditTask} onDelete={(id) => deleteTask.mutate({ id })} onClick={setViewTask} + layoutId={ + floatingState.morphTargetId === `task-morph-${task.id}` + ? floatingState.morphTargetId + : undefined + } /> )) )}