feat(floating-ai): step 7 — implement morph animation (FLIP)
Add FLIP animation so the floating chat visually morphs into a newly-created TaskRow when the AI creates a task. Uses Framer Motion's shared layoutId across FloatingChat and TaskRow, with LayoutGroup wrapping the app shell. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,9 +35,9 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
|
|||||||
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
||||||
| 3 | Create double-click hook | [x] 2026-02-27 |
|
| 3 | Create double-click hook | [x] 2026-02-27 |
|
||||||
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
||||||
| 5 | Add `ai:action` IPC side-channel | [ ] |
|
| 5 | Add `ai:action` IPC side-channel | [x] 2026-02-28 |
|
||||||
| 6 | Pass `uiContext` through to the AI | [ ] |
|
| 6 | Pass `uiContext` through to the AI | [x] 2026-02-28 |
|
||||||
| 7 | Implement morph animation (FLIP) | [ ] |
|
| 7 | Implement morph animation (FLIP) | [x] 2026-02-28 |
|
||||||
| 8a | Page interactions — Project Detail | [ ] |
|
| 8a | Page interactions — Project Detail | [ ] |
|
||||||
| 8b | Page interactions — Tasks page | [ ] |
|
| 8b | Page interactions — Tasks page | [ ] |
|
||||||
| 8c | Page interactions — Timeline page | [ ] |
|
| 8c | Page interactions — Timeline page | [ ] |
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import {
|
|||||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
function FloatingChatInner() {
|
function FloatingChatInner() {
|
||||||
const { state, sections, close } = useFloatingChat();
|
const { state, sections, close, setMorphTarget } = useFloatingChat();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
const routerState = useRouterState();
|
const routerState = useRouterState();
|
||||||
const prevPathRef = useRef(routerState.location.pathname);
|
const prevPathRef = useRef(routerState.location.pathname);
|
||||||
|
|
||||||
@@ -77,6 +79,31 @@ function FloatingChatInner() {
|
|||||||
prevOpenRef.current = state.isOpen;
|
prevOpenRef.current = state.isOpen;
|
||||||
}, [state.isOpen, clearMessages]);
|
}, [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 ----
|
// ---- Window resize: keep within bounds ----
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Link, useRouterState } from '@tanstack/react-router';
|
import { Link, useRouterState } from '@tanstack/react-router';
|
||||||
import { motion, useMotionValue, useSpring } from 'framer-motion';
|
import { LayoutGroup, motion, useMotionValue, useSpring } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
House,
|
House,
|
||||||
ChartGantt,
|
ChartGantt,
|
||||||
@@ -221,7 +221,7 @@ function AppShellInner({ children }: AppShellProps) {
|
|||||||
}, [openCurtain, closeCurtain]);
|
}, [openCurtain, closeCurtain]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<LayoutGroup>
|
||||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
@@ -314,7 +314,7 @@ function AppShellInner({ children }: AppShellProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</LayoutGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||||
@@ -22,6 +23,7 @@ type KanbanBoardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
||||||
|
const { state: floatingState } = useFloatingChat();
|
||||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -125,6 +127,11 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
|
|||||||
onDelete={(id) => deleteTask.mutate({ id })}
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
onClick={setViewTask}
|
onClick={setViewTask}
|
||||||
hideBreadcrumb
|
hideBreadcrumb
|
||||||
|
layoutId={
|
||||||
|
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||||
|
? floatingState.morphTargetId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -57,6 +58,7 @@ export function TaskRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onClick,
|
onClick,
|
||||||
hideBreadcrumb,
|
hideBreadcrumb,
|
||||||
|
layoutId,
|
||||||
}: {
|
}: {
|
||||||
task: TaskItem;
|
task: TaskItem;
|
||||||
onToggle: (id: string, status: string | null) => void;
|
onToggle: (id: string, status: string | null) => void;
|
||||||
@@ -64,6 +66,7 @@ export function TaskRow({
|
|||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
onClick?: (task: TaskItem) => void;
|
onClick?: (task: TaskItem) => void;
|
||||||
hideBreadcrumb?: boolean;
|
hideBreadcrumb?: boolean;
|
||||||
|
layoutId?: string;
|
||||||
}) {
|
}) {
|
||||||
const isDone = task.status === 'done';
|
const isDone = task.status === 'done';
|
||||||
|
|
||||||
@@ -84,10 +87,14 @@ export function TaskRow({
|
|||||||
breadcrumb.length > 0 ||
|
breadcrumb.length > 0 ||
|
||||||
task.assignee;
|
task.assignee;
|
||||||
|
|
||||||
|
const Wrapper = layoutId ? motion.div : 'div';
|
||||||
|
const wrapperProps = layoutId ? { layoutId, layout: true as const } : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<Wrapper
|
||||||
|
{...wrapperProps}
|
||||||
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
|
||||||
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
|
||||||
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
|
||||||
@@ -146,7 +153,7 @@ export function TaskRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Wrapper>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
|||||||
function TasksPage() {
|
function TasksPage() {
|
||||||
// Temporary test: register section for floating AI chat
|
// Temporary test: register section for floating AI chat
|
||||||
const testRef = useRef<HTMLDivElement>(null);
|
const testRef = useRef<HTMLDivElement>(null);
|
||||||
const { registerSection, unregisterSection } = useFloatingChat();
|
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
||||||
return () => unregisterSection('test');
|
return () => unregisterSection('test');
|
||||||
@@ -225,6 +225,11 @@ function TasksPage() {
|
|||||||
onEdit={setEditTask}
|
onEdit={setEditTask}
|
||||||
onDelete={(id) => deleteTask.mutate({ id })}
|
onDelete={(id) => deleteTask.mutate({ id })}
|
||||||
onClick={setViewTask}
|
onClick={setViewTask}
|
||||||
|
layoutId={
|
||||||
|
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||||
|
? floatingState.morphTargetId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user