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:
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user