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:
Roberto Musso
2026-02-28 13:27:23 +01:00
parent d12681b79f
commit 60b76c6d97
6 changed files with 56 additions and 10 deletions

View File

@@ -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 | [ ] |

View File

@@ -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(() => {

View File

@@ -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 (
<>
<LayoutGroup>
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
<AppSidebar
currentPath={currentPath}
@@ -314,7 +314,7 @@ function AppShellInner({ children }: AppShellProps) {
</DialogFooter>
</DialogContent>
</Dialog>
</>
</LayoutGroup>
);
}

View File

@@ -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
}
/>
</div>
)}

View File

@@ -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 (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
<Wrapper
{...wrapperProps}
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'
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
@@ -146,7 +153,7 @@ export function TaskRow({
)}
</div>
)}
</div>
</Wrapper>
</ContextMenuTrigger>
<ContextMenuContent>

View File

@@ -43,7 +43,7 @@ const ORDER_LABELS: Record<OrderBy, string> = {
function TasksPage() {
// Temporary test: register section for floating AI chat
const testRef = useRef<HTMLDivElement>(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
}
/>
))
)}