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
+ }
/>
))
)}