diff --git a/changelogs/v2.2.8.md b/changelogs/v2.2.8.md new file mode 100644 index 00000000..eaf29a74 --- /dev/null +++ b/changelogs/v2.2.8.md @@ -0,0 +1,13 @@ +## What's new + +### Fixes + +- **Folder Preservation on Note Edits**: Fixed a bug where `patch_note` and `append_to_note` would move note files out of their current folder and into the project root. Both tools now pass the note's existing `folder` field to `writeNoteFile`, matching the behaviour of `ensure_note`, `rename_note`, and `bulk_move_notes`. +- **Kanban Drag Cancel Cleanup**: Added an `onDragCancel` handler to the board's `DndContext`. Pressing Escape during a card or column drag now properly clears all drag state (`activeCard`, `activeColumn`, `overId`, `hoverZone`) and dismisses the action zone overlay, instead of leaving stale highlights behind. + +### Changes + +- **Redesigned Archive/Delete Drop Zones**: The action zone overlay that appears when dragging a card has been redesigned as two compact, button-style targets centered at the bottom of the viewport. The previous full-width banner at the top of the board caused content to shift on drag start; the new design is a fixed portal overlay that doesn't affect layout. Buttons match the app's `lg` button sizing (36px tall, rounded outer corners, solid colour at 85% opacity resting and full colour on hover). +- **Kanban Card Height Fix**: Collapsed card descriptions now use `max-h-10 overflow-hidden` instead of `line-clamp-2`, which didn't reliably clip block-level markdown content. Expanded view increased to `max-h-80`. Paragraph margins from `prose-cairn` are zeroed inside cards to prevent excess vertical space. +- **Kanban Drop Target Expansion**: Columns now stretch to the board's full height via `self-stretch` and `h-full`. During an active drag-over, the card drop zone minimum height bumps from 48px to 120px, making empty or near-empty columns easy drop targets without scrolling. +- **Diff Viewer Performance**: The Agent View's Diff Viewer no longer blocks the UI thread when polling `git diff` every 5 seconds. `parseDiff` now runs on a `useDeferredValue`, so React keeps interactions responsive while the parse runs at lower priority. All diff sub-components (`FileDiff`, `UnifiedFile`, `SplitFile`, `HL`) are wrapped in `React.memo` so unchanged files skip re-rendering entirely. Split-view row building and addition/deletion counts are memoised per file. diff --git a/electron/mcp/tools/notes.ts b/electron/mcp/tools/notes.ts index 2e05580d..ff79eafc 100644 --- a/electron/mcp/tools/notes.ts +++ b/electron/mcp/tools/notes.ts @@ -152,6 +152,7 @@ export function append_to_note(db: Database.Database, snap: Snapshot, workspaceP createdAt: note.createdAt as string, updatedAt: updated?.updatedAt ?? new Date().toISOString(), archivedAt: note.archivedAt as string | undefined, projectName: proj?.name ?? note.projectId as string, + folder: (note.folder as string) ?? "", }); return { id: noteId, title: note.title, updatedAt: updated?.updatedAt, newLength: newContent.length }; } finally { @@ -195,6 +196,7 @@ export function patch_note(db: Database.Database, snap: Snapshot, workspacePath: createdAt: note.createdAt as string, updatedAt: updated?.updatedAt ?? new Date().toISOString(), archivedAt: note.archivedAt as string | undefined, projectName: proj?.name ?? note.projectId as string, + folder: (note.folder as string) ?? "", }); return { id: noteId, title: note.title, updatedAt: updated?.updatedAt, replacements: all ? count : 1 }; } finally { diff --git a/src/app/globals.css b/src/app/globals.css index b0f43c78..705f82a3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -538,4 +538,27 @@ input[type="url"] { } +/* ── Kanban drag drop zones ─────────────────────────────────────────────────── */ +.zone-archive { + color: var(--warning); + background: color-mix(in srgb, var(--warning) 12%, transparent); +} +.zone-archive:hover { + background: color-mix(in srgb, var(--warning) 22%, transparent); +} +.zone-delete { + color: var(--danger); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} +.zone-delete:hover { + background: color-mix(in srgb, var(--danger) 22%, transparent); +} +.zone-archive.zone-active { + background: var(--warning); + color: #1a1a1a; +} +.zone-delete.zone-active { + background: var(--danger); + color: #fff; +} diff --git a/src/components/agent/DiffFile.tsx b/src/components/agent/DiffFile.tsx index 79c28892..3167611a 100644 --- a/src/components/agent/DiffFile.tsx +++ b/src/components/agent/DiffFile.tsx @@ -68,7 +68,7 @@ function langFrom(filename: string): string | null { // ── Highlighted line ────────────────────────────────────────────────────────── -function HL({ content, lang, palette }: { content: string; lang: string | null; palette: Palette }) { +const HL = React.memo(function HL({ content, lang, palette }: { content: string; lang: string | null; palette: Palette }) { const tokens = useMemo(() => { if (!lang || !content) return null; try { @@ -81,7 +81,7 @@ function HL({ content, lang, palette }: { content: string; lang: string | null; {tokens ? tokens.map((n, i) => renderHast(n, palette, String(i))) : content} ); -} +}); // ── View mode type ──────────────────────────────────────────────────────────── @@ -103,7 +103,7 @@ function ln(change: Change, side: "old" | "new"): number | "" { // ── Unified file view ───────────────────────────────────────────────────────── -export function UnifiedFile({ file, palette, changesOnly, hunkTop }: { file: File; palette: Palette; changesOnly: boolean; hunkTop: number }) { +export const UnifiedFile = React.memo(function UnifiedFile({ file, palette, changesOnly, hunkTop }: { file: File; palette: Palette; changesOnly: boolean; hunkTop: number }) { const filename = file.to ?? file.from ?? "unknown"; const lang = langFrom(filename); return ( @@ -162,7 +162,7 @@ export function UnifiedFile({ file, palette, changesOnly, hunkTop }: { file: Fil })} ); -} +}); // ── Split file view ─────────────────────────────────────────────────────────── @@ -190,11 +190,12 @@ function buildSplitRows(changes: Change[]): SplitRow[] { return rows; } -function SplitFile({ file, palette, hunkTop }: { file: File; palette: Palette; hunkTop: number }) { +export const SplitFile = React.memo(function SplitFile({ file, palette, hunkTop }: { file: File; palette: Palette; hunkTop: number }) { const filename = file.to ?? file.from ?? "unknown"; const lang = langFrom(filename); - const addBg = "color-mix(in srgb, var(--success, #22c55e) 10%, transparent)"; + const addBg = "color-mix(in srgb, var(--success) 10%, transparent)"; const delBg = "color-mix(in srgb, var(--danger) 10%, transparent)"; + const splitRows = useMemo(() => file.chunks.map((chunk) => buildSplitRows(chunk.changes)), [file]); return ( <> {file.chunks.map((chunk, ci) => ( @@ -205,7 +206,7 @@ function SplitFile({ file, palette, hunkTop }: { file: File; palette: Palette; h > {chunk.content} - {buildSplitRows(chunk.changes).map((row, ri) => { + {splitRows[ci].map((row, ri) => { const oldContent = row.old ? row.old.content.slice(1) : ""; const newContent = row.new ? row.new.content.slice(1) : ""; const oldBg = row.old?.type === "del" ? delBg : undefined; @@ -243,14 +244,15 @@ function SplitFile({ file, palette, hunkTop }: { file: File; palette: Palette; h ))} ); -} +}); // ── FileDiff — file header + collapsible content ────────────────────────────── -export function FileDiff({ file, collapsed, onToggle, mode, palette }: { +export const FileDiff = React.memo(function FileDiff({ file, fileKey, collapsed, onToggle, mode, palette }: { file: File; + fileKey: string; collapsed: boolean; - onToggle: () => void; + onToggle: (key: string) => void; mode: ViewMode; palette: Palette; }) { @@ -259,8 +261,8 @@ export function FileDiff({ file, collapsed, onToggle, mode, palette }: { ? `${file.from} → ${filename}` : filename; - const additions = file.chunks.reduce((s, c) => s + c.changes.filter((ch) => ch.type === "add").length, 0); - const deletions = file.chunks.reduce((s, c) => s + c.changes.filter((ch) => ch.type === "del").length, 0); + const additions = useMemo(() => file.chunks.reduce((s, c) => s + c.changes.filter((ch) => ch.type === "add").length, 0), [file]); + const deletions = useMemo(() => file.chunks.reduce((s, c) => s + c.changes.filter((ch) => ch.type === "del").length, 0), [file]); const headerRef = useRef(null); const [hunkTop, setHunkTop] = useState(32); @@ -277,7 +279,7 @@ export function FileDiff({ file, collapsed, onToggle, mode, palette }: {
); -} +}); diff --git a/src/components/agent/DiffViewer.tsx b/src/components/agent/DiffViewer.tsx index d5e134ee..6306b92e 100644 --- a/src/components/agent/DiffViewer.tsx +++ b/src/components/agent/DiffViewer.tsx @@ -11,7 +11,7 @@ * changes — unified but context lines hidden (adds/deletes only) */ -import { useMemo, useState, useEffect, useCallback, useRef } from "react"; +import { useMemo, useState, useEffect, useCallback, useRef, useDeferredValue } from "react"; import parseDiff from "parse-diff"; import { Copy, Check, RefreshCw, FolderGit2, ChevronRight, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -58,6 +58,10 @@ export function DiffViewer({ cwd }: DiffViewerProps) { const [collapsed, setCollapsed] = useState>(new Set()); const isMounted = useRef(true); + // Defers the diff text so that parseDiff + re-render runs at lower priority, + // keeping the UI responsive while typing, scrolling, or interacting. + const deferredDiffText = useDeferredValue(diffText); + const fetchDiff = useCallback(async () => { if (!window.electron) return; setLoading(true); @@ -83,9 +87,9 @@ export function DiffViewer({ cwd }: DiffViewerProps) { }, [fetchDiff]); const files = useMemo(() => { - if (!diffText) return []; - try { return parseDiff(diffText); } catch { return []; } - }, [diffText]); + if (!deferredDiffText) return []; + try { return parseDiff(deferredDiffText); } catch { return []; } + }, [deferredDiffText]); const toggleCollapse = useCallback((key: string) => { setCollapsed((prev) => { @@ -224,8 +228,9 @@ export function DiffViewer({ cwd }: DiffViewerProps) { toggleCollapse(fileKey)} + onToggle={toggleCollapse} mode={mode} palette={palette} /> diff --git a/src/components/kanban/board.tsx b/src/components/kanban/board.tsx index 4d0623ec..246f540e 100644 --- a/src/components/kanban/board.tsx +++ b/src/components/kanban/board.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useState, useEffect, useRef, useCallback } from "react"; -import { createPortal } from "react-dom"; import { DndContext, DragOverlay, @@ -33,23 +32,12 @@ import { KanbanCard } from "./card"; import { CardDetailModal } from "./card-detail"; import type { TaskCard, BoardColumn } from "@/types"; -const ZONE_H = 56; - -// ── Zone helpers — no component state deps, safe at module scope ────────────── - -function computeZoneRect(el: HTMLDivElement | null): { top: number; left: number; width: number } | null { - const r = el?.getBoundingClientRect(); - if (!r) return null; - return { top: r.top, left: r.left, width: r.width }; -} - -function getZoneHit(clientX: number, clientY: number, rect: { top: number; left: number; width: number }): "archive" | "delete" | null { - const { top, left, width } = rect; - const bottom = top + ZONE_H; - const mid = left + width / 2; - if (clientY < top || clientY > bottom) return null; - if (clientX < left || clientX > left + width) return null; - return clientX < mid ? "archive" : "delete"; +function getZoneHit(clientX: number, clientY: number, barRect: DOMRect | null, archiveRect: DOMRect | null, deleteRect: DOMRect | null): "archive" | "delete" | null { + if (!barRect) return null; + if (clientY < barRect.top || clientY > barRect.bottom) return null; + if (archiveRect && clientX >= archiveRect.left && clientX <= archiveRect.right) return "archive"; + if (deleteRect && clientX >= deleteRect.left && clientX <= deleteRect.right) return "delete"; + return null; } export function KanbanBoard() { @@ -96,7 +84,6 @@ export function KanbanBoard() { const [detailCardId, setDetailCardId] = useState(null); const [overId, setOverId] = useState(null); const [deleteFlashing, setDeleteFlashing] = useState(false); - const [hoverZone, setHoverZone] = useState<"archive" | "delete" | null>(null); // Board filter — ⌘F / Ctrl+F toggles and focuses const [boardFilter, setBoardFilter] = useState(""); @@ -125,10 +112,6 @@ export function KanbanBoard() { return () => window.removeEventListener("keydown", handleKeyDown); }, []); - // Portal zone rects — computed from the board container when drag starts - const boardRef = useRef(null); - const [zoneRect, setZoneRect] = useState<{ top: number; left: number; width: number } | null>(null); - const columns = activeProjectId ? getProjectColumns(activeProjectId) : []; const sensors = useSensors( @@ -141,18 +124,38 @@ export function KanbanBoard() { function handleDragStart(event: DragStartEvent) { const col = event.active.data.current?.column as BoardColumn | undefined; const card = event.active.data.current?.card as TaskCard | undefined; - if (col) { setActiveColumn(col); setZoneRect(null); } - else if (card) { setActiveCard(card); setZoneRect(computeZoneRect(boardScrollRef.current)); } + if (col) { setActiveColumn(col); } + else if (card) { setActiveCard(card); } } - function handleDragMove(event: DragMoveEvent) { - if (!activeCard || !zoneRect) { setHoverZone(null); return; } - const init = event.activatorEvent as PointerEvent; - const x = init.clientX + event.delta.x; - const y = init.clientY + event.delta.y; - const zone = getZoneHit(x, y, zoneRect); - setHoverZone(zone); - // Clear column/card drop highlight while hovering an action zone + const hoverZoneRef = useRef<"archive" | "delete" | null>(null); + const dragOverlayRef = useRef(null); + + function applyZoneHighlight(zone: "archive" | "delete" | null) { + const a = archiveBtnRef.current; + const d = deleteBtnRef.current; + if (a) a.classList.toggle("zone-active", zone === "archive"); + if (d) d.classList.toggle("zone-active", zone === "delete"); + const ov = dragOverlayRef.current; + if (ov) { + ov.classList.toggle("ring-2", !!zone); + ov.classList.toggle("ring-[var(--warning)]", zone === "archive"); + ov.classList.toggle("ring-[var(--danger)]", zone === "delete"); + } + } + + function handleDragMove(_event: DragMoveEvent) { + if (!activeCard) { return; } + const p = livePointer.current; + if (!p) return; + const barRect = titleBarRef.current?.getBoundingClientRect() ?? null; + const archive = archiveBtnRef.current?.getBoundingClientRect() ?? null; + const del = deleteBtnRef.current?.getBoundingClientRect() ?? null; + const zone = getZoneHit(p.x, p.y, barRect, archive, del); + if (zone !== hoverZoneRef.current) { + hoverZoneRef.current = zone; + applyZoneHighlight(zone); + } if (zone) setOverId(null); } @@ -160,39 +163,65 @@ export function KanbanBoard() { setOverId(event.over?.id as string ?? null); } - function handleDragEnd(event: DragEndEvent) { - const { active, over } = event; - const draggedCard = active.data.current?.card as TaskCard | undefined; - - // Compute final pointer position before clearing state - const init = event.activatorEvent as PointerEvent; - const dropX = init.clientX + event.delta.x; - const dropY = init.clientY + event.delta.y; - const rect = zoneRect; - + function handleDragCancel() { setActiveCard(null); setActiveColumn(null); setOverId(null); - setHoverZone(null); - setZoneRect(null); + hoverZoneRef.current = null; + applyZoneHighlight(null); + livePointer.current = null; + } + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + const draggedCard = active.data.current?.card as TaskCard | undefined; - // ── Action zones ────────────────────────────────────────────────────── - if (draggedCard && rect) { - const zone = getZoneHit(dropX, dropY, rect); + // Use the last known live pointer position (tracked via window listener). + const drop = livePointer.current; + const dropX = drop?.x ?? 0; + const dropY = drop?.y ?? 0; + const barRect = titleBarRef.current?.getBoundingClientRect() ?? null; + const archive = archiveBtnRef.current?.getBoundingClientRect() ?? null; + const del = deleteBtnRef.current?.getBoundingClientRect() ?? null; + + // Check action zones before clearing drag state — the delete flash + // needs activeCard to stay truthy so the zone buttons remain mounted. + if (draggedCard) { + const zone = getZoneHit(dropX, dropY, barRect, archive, del); if (zone === "archive") { + setActiveCard(null); + setActiveColumn(null); + setOverId(null); + hoverZoneRef.current = null; + applyZoneHighlight(null); + livePointer.current = null; archiveCard(draggedCard.id); return; } if (zone === "delete") { setDeleteFlashing(true); + applyZoneHighlight("delete"); setTimeout(() => { setDeleteFlashing(false); + setActiveCard(null); + setActiveColumn(null); + setOverId(null); + hoverZoneRef.current = null; + applyZoneHighlight(null); + livePointer.current = null; deleteCard(draggedCard.id); }, 300); return; } } + setActiveCard(null); + setActiveColumn(null); + setOverId(null); + hoverZoneRef.current = null; + applyZoneHighlight(null); + livePointer.current = null; + if (!over || active.id === over.id) return; // ── Column reorder ──────────────────────────────────────────────────── @@ -245,6 +274,30 @@ export function KanbanBoard() { // Deep-link: scroll to column const columnRefs = useRef>({}); const boardScrollRef = useRef(null); + const titleBarRef = useRef(null); + const archiveBtnRef = useRef(null); + const deleteBtnRef = useRef(null); + const livePointer = useRef<{ x: number; y: number } | null>(null); + + // Track real pointer position via window listener during drag — dnd-kit's + // event.delta doesn't account for auto-scroll, so we can't rely on + // activatorEvent + delta for screen coordinates. + useEffect(() => { + if (!activeCard) { livePointer.current = null; return; } + function onPointerMove(e: PointerEvent) { + livePointer.current = { x: e.clientX, y: e.clientY }; + } + function onTouchMove(e: TouchEvent) { + const t = e.touches[0]; + if (t) livePointer.current = { x: t.clientX, y: t.clientY }; + } + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("touchmove", onTouchMove); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("touchmove", onTouchMove); + }; + }, [activeCard]); const [highlightedColumnId, setHighlightedColumnId] = useState(null); const scrollToColumn = useCallback((columnId: string) => { @@ -277,68 +330,7 @@ export function KanbanBoard() { ); } - // Portal action zones — rendered into document.body, positioned over the board top edge - const actionZonesPortal = activeCard && zoneRect && typeof document !== "undefined" - ? createPortal( -
- {/* Archive half */} -
- - Archive -
- {/* Delete half */} -
- - Delete -
-
, - document.body - ) - : null; + // No more portal — action zones are rendered inline in the title bar. return ( <> @@ -349,10 +341,11 @@ export function KanbanBoard() { onDragMove={handleDragMove} onDragOver={handleDragOver} onDragEnd={handleDragEnd} + onDragCancel={handleDragCancel} > -
+
{/* Board / Archive toggle bar */} -
+
+ + {/* Drop zones — shown alongside toggle buttons during card drag */} + {activeCard && ( +
+ + +
+ )}
{/* Filter bar — shown when ⌘F is pressed */} @@ -547,8 +565,7 @@ export function KanbanBoard() { {!archiveViewOpen && c.id)} strategy={horizontalListSortingStrategy}>
{columns.map((column) => { const allCards = getColumnCards(column.id); @@ -559,7 +576,7 @@ export function KanbanBoard() { }) : allCards; return ( -
{ columnRefs.current[column.id] = el; }} className="flex-shrink-0"> +
{ columnRefs.current[column.id] = el; }} className="flex-shrink-0 self-stretch"> }
- {actionZonesPortal} - {activeCard && ( -
+
{}} />
)} diff --git a/src/components/kanban/card.tsx b/src/components/kanban/card.tsx index 8c2b9f0a..5515eef0 100644 --- a/src/components/kanban/card.tsx +++ b/src/components/kanban/card.tsx @@ -109,7 +109,8 @@ export const KanbanCard = React.memo(function KanbanCard({ card, onClick, isDrag ref={descRef} className={cn( "text-[0.786rem] text-[var(--text-tertiary)] leading-relaxed", - expanded ? "max-h-48 overflow-y-auto pr-1" : "line-clamp-2" + "[&_.prose-cairn]:!py-0 [&_.prose-cairn_p]:!my-0", + expanded ? "max-h-80 overflow-y-auto pr-1" : "max-h-10 overflow-hidden" )} > diff --git a/src/components/kanban/column.tsx b/src/components/kanban/column.tsx index 9ac966ee..80650b84 100644 --- a/src/components/kanban/column.tsx +++ b/src/components/kanban/column.tsx @@ -204,7 +204,7 @@ export function KanbanColumn({ ref={setSortableRef} style={style} className={cn( - "flex flex-col w-56 rounded-xl border flex-shrink-0 transition-colors duration-150", + "flex flex-col w-56 rounded-xl border flex-shrink-0 h-full transition-colors duration-150", isDragOver ? "border-[var(--accent)]/50 bg-[var(--accent-dim)]" : isHighlighted @@ -323,7 +323,7 @@ export function KanbanColumn({ {/* Cards */} c.id)} strategy={verticalListSortingStrategy}> -
+
{cards.map((card) => ( onCardClick(card.id)} /> ))}