diff --git a/web/src/lib/components/BacklinksPanel.svelte b/web/src/lib/components/BacklinksPanel.svelte index 6854eea0..6c2e735c 100644 --- a/web/src/lib/components/BacklinksPanel.svelte +++ b/web/src/lib/components/BacklinksPanel.svelte @@ -38,39 +38,51 @@ }); async function loadFirstPage() { + // Capture the request identity BEFORE the await. ItemDetail reuses this + // panel across a no-{#key} item switch (props just change), so a slower + // A load must NOT overwrite B's backlinks or push A's count into the + // parent's `backlinksCount` badge (PLAN-2105 / TASK-2112). + const reqSlug = itemSlug; + const reqWs = wsSlug; loading = true; error = ''; try { - const rows = await api.items.backlinks(wsSlug, itemSlug, { limit: PAGE_LIMIT }); + const rows = await api.items.backlinks(reqWs, reqSlug, { limit: PAGE_LIMIT }); + if (reqSlug !== itemSlug || reqWs !== wsSlug) return; backlinks = rows; hasMore = rows.length === PAGE_LIMIT; onCountChange?.(rows.length); } catch (err) { + if (reqSlug !== itemSlug || reqWs !== wsSlug) return; error = err instanceof Error ? err.message : 'Failed to load backlinks'; backlinks = []; hasMore = false; // Empty contract: signal zero so the badge stays hidden on error. onCountChange?.(0); } finally { - loading = false; + if (reqSlug === itemSlug && reqWs === wsSlug) loading = false; } } async function loadMore() { if (loadingMore || !hasMore) return; + const reqSlug = itemSlug; + const reqWs = wsSlug; loadingMore = true; try { - const rows = await api.items.backlinks(wsSlug, itemSlug, { + const rows = await api.items.backlinks(reqWs, reqSlug, { limit: PAGE_LIMIT, offset: backlinks.length }); + if (reqSlug !== itemSlug || reqWs !== wsSlug) return; backlinks = [...backlinks, ...rows]; hasMore = rows.length === PAGE_LIMIT; onCountChange?.(backlinks.length); } catch (err) { + if (reqSlug !== itemSlug || reqWs !== wsSlug) return; error = err instanceof Error ? err.message : 'Failed to load more backlinks'; } finally { - loadingMore = false; + if (reqSlug === itemSlug && reqWs === wsSlug) loadingMore = false; } } diff --git a/web/src/lib/components/ChildItems.svelte b/web/src/lib/components/ChildItems.svelte index a3e37e13..4d0103bf 100644 --- a/web/src/lib/components/ChildItems.svelte +++ b/web/src/lib/components/ChildItems.svelte @@ -160,16 +160,26 @@ // ── Data loading ───────────────────────────────────────────────────────── async function loadChildren() { + // Capture the request identity (item + workspace) BEFORE the await. + // ItemDetail reuses this panel across a no-{#key} item switch (its + // `itemSlug` prop just changes), so a slower A load must NOT overwrite + // B's children — nor fire onChildrenChange with A's data into the + // parent's childItemIds / progress overrides (PLAN-2105 / TASK-2112). + const reqSlug = itemSlug; + const reqWs = wsSlug; loading = true; error = ''; try { - children = await api.items.children(wsSlug, itemSlug); + const loaded = await api.items.children(reqWs, reqSlug); + if (reqSlug !== itemSlug || reqWs !== wsSlug) return; + children = loaded; onChildrenChange?.(children); } catch (err) { + if (reqSlug !== itemSlug || reqWs !== wsSlug) return; error = err instanceof Error ? err.message : 'Failed to load children'; onChildrenChange?.([]); } finally { - loading = false; + if (reqSlug === itemSlug && reqWs === wsSlug) loading = false; } } diff --git a/web/src/lib/components/CommentEditor.svelte b/web/src/lib/components/CommentEditor.svelte index 1fdb29ab..2f5182af 100644 --- a/web/src/lib/components/CommentEditor.svelte +++ b/web/src/lib/components/CommentEditor.svelte @@ -85,12 +85,22 @@ if (busy || !editor) return; const md = currentMarkdown(); if (md === '') return; + // Capture the composer's item identity BEFORE the await. This composer + // is REUSED across a no-{#key} item switch in the timeline (its `itemId` + // prop just changes), so if the user switches A→B while A's submit is in + // flight, clearing on completion would ERASE B's freshly-typed draft + // (PLAN-2105 / TASK-2112; Codex). Only clear when the composer is still + // on the same item and its editor is still alive. + const reqWs = wsSlug; + const reqItem = itemId; saving = true; try { await onSubmit(md); // Composer behaviour: clear on success. In edit/reply mode the host // unmounts this component, so the clear is harmless there. - editor.commands.clearContent(); + if (editor && !editor.isDestroyed && reqWs === wsSlug && reqItem === itemId) { + editor.commands.clearContent(); + } } catch { // Keep the draft so the user can retry. } finally { diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index 3bc57bed..b2cf4a79 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -78,9 +78,15 @@ * lane would be meaningless (the comparator would re-sort it). */ sortMode?: SortMode; + /** + * Opt-in split-pane open (PLAN-2105 / TASK-2111). Threaded straight + * through to each ItemCard; omitted everywhere except the collection + * page, so other surfaces keep full-page anchor navigation. + */ + onItemOpen?: (item: Item) => void; } - let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, onCreateInColumn, onMoveColumn, onTagColumn, onUntagColumn, onSetPriorityColumn, onAssignColumn, members = [], tagSuggestions = [], filtered = false, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false, sortMode = 'manual', draftText = $bindable({}), draftOpen = $bindable({}) }: Props = $props(); + let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, onCreateInColumn, onMoveColumn, onTagColumn, onUntagColumn, onSetPriorityColumn, onAssignColumn, members = [], tagSuggestions = [], filtered = false, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false, sortMode = 'manual', draftText = $bindable({}), draftOpen = $bindable({}), onItemOpen }: Props = $props(); // Local — disables the draft card while its Enter-create is in flight. let savingDraft = $state(false); @@ -572,6 +578,7 @@ onMoveItem={canReorderLane(colValue) ? (it, dir) => moveItem(colValue, it, dir) : undefined} horizontal={canReorderLane(colValue)} reorderDisabledDirs={canReorderLane(colValue) ? moveDisabledDirs(colValue, i, colItems.length) : undefined} + {onItemOpen} /> {/each} diff --git a/web/src/lib/components/collections/ItemCard.svelte b/web/src/lib/components/collections/ItemCard.svelte index 340f115d..5feb743b 100644 --- a/web/src/lib/components/collections/ItemCard.svelte +++ b/web/src/lib/components/collections/ItemCard.svelte @@ -37,9 +37,20 @@ onMoveItem?: (item: Item, dir: 'left' | 'right') => void; /** Render the Move left / Move right menu entries (BoardView only). */ horizontal?: boolean; + /** + * Opt-in split-pane open (PLAN-2105 / TASK-2111). When set, a plain + * left-click on the card opens the item in the collection page's + * detail pane instead of navigating; modifier/middle clicks still + * fall through to the `href` so cmd/middle-click opens the full page + * in a new tab (the "popout" state) and right-click-copy / SSR still + * target the full page. Omitted everywhere except the collection page, + * so all other surfaces (starred / tags / roles) keep full-page + * anchor navigation. + */ + onItemOpen?: (item: Item) => void; } - let { item, collection, compact = false, focused = false, showCollection = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', onReorderItem, reorderDisabledDirs, onMoveItem, horizontal = false }: Props = $props(); + let { item, collection, compact = false, focused = false, showCollection = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', onReorderItem, reorderDisabledDirs, onMoveItem, horizontal = false, onItemOpen }: Props = $props(); let wsSlug = $derived(page.params.workspace ?? ''); let username = $derived(page.params.username ?? ''); @@ -148,9 +159,23 @@ setTimeout(() => { copied = false; }, 1500); } } + + // Split-pane row-click interception (PLAN-2105 / TASK-2111). Only a plain + // left-click opens the pane; modifier/middle clicks fall through to the + // native (cmd/middle-click = full-page popout in a new tab, + // right-click-copy / SSR target the full page). Sub-controls (star / PR / + // status / tags / reorder) already stopPropagation, so their clicks never + // reach this handler; `defaultPrevented` is a defensive backstop. + function handleCardClick(e: MouseEvent) { + if (!onItemOpen) return; + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + if (e.defaultPrevented) return; + e.preventDefault(); + onItemOpen(item); + } - + {#if pullRequest} {#if collection && (quickActions.length > 0 || isOwner)} - { - editCollectionSection = 'actions'; - editCollectionOpen = true; - }} - oncollectionupdated={(updated) => { - collection = updated; - }} - /> + + {#key itemSlug} + + {@const keyedSlug = itemSlug} + { + editCollectionSection = 'actions'; + editCollectionOpen = true; + }} + oncollectionupdated={(updated) => { + if (keyedSlug !== itemSlug) return; + collection = updated; + }} + /> + {/key} {/if}