Load/render workspace alignment (#182)

* docs: plan load render workspace alignment

* fix: normalize workspace status hydration

* fix: avoid duplicate backlog hydration load

* refactor: use sprint store active story

* refactor: migrate solo to workspace store

* chore: stabilize verification ignores
This commit is contained in:
Janpeter Visser 2026-05-10 07:34:58 +02:00 committed by GitHub
parent 98ee05d458
commit 3b5cee823c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1845 additions and 577 deletions

View file

@ -8,9 +8,11 @@ import { debugProps } from '@/lib/debug'
// De voorganger (stores/product-store.ts) wordt in Story 8 (T-876) verwijderd.
export function SetCurrentProduct({ id, name }: { id: string; name: string }) {
useEffect(() => {
useProductWorkspaceStore.getState().setActiveProduct({ id, name })
useProductWorkspaceStore
.getState()
.setActiveProduct({ id, name }, { load: false, preserveSelection: true })
return () => {
useProductWorkspaceStore.getState().setActiveProduct(null)
useProductWorkspaceStore.getState().setActiveProduct(null, { load: false })
}
}, [id, name])

View file

@ -1,12 +1,19 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
PointerSensor, useSensor, useSensors, closestCorners,
} from '@dnd-kit/core'
import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store'
import {
selectSoloTaskById,
selectSoloTasksForColumn,
selectSoloUnassignedStories,
} from '@/stores/solo-workspace/selectors'
import type { SoloTask, SoloUnassignedStory, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types'
import { taskStatusToApi } from '@/lib/task-status'
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog'
@ -17,34 +24,17 @@ import { SplitPane } from '@/components/split-pane/split-pane'
import { SoloColumn, type ColumnStatus } from './solo-column'
import { SoloTaskCardOverlay } from './solo-task-card'
import { TaskDetailDialog } from './task-detail-dialog'
import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet'
import { UnassignedStoriesSheet } from './unassigned-stories-sheet'
export interface SoloTask {
id: string
title: string
description: string | null
implementation_plan: string | null
priority: number
sort_order: number
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
verify_only: boolean
verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY'
story_id: string
story_code: string | null
story_title: string
task_code: string | null
pbi_code: string | null
pbi_title: string | null
pbi_description: string | null
}
export type { SoloTask } from '@/stores/solo-workspace/types'
export interface SoloBoardProps {
productId: string
sprintGoal: string
tasks: SoloTask[]
unassignedStories: UnassignedStory[]
tasks?: SoloTask[]
unassignedStories?: SoloUnassignedStory[]
isDemo: boolean
currentUserId: string
currentUserId?: string
repoUrl?: string | null
}
@ -56,14 +46,22 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus {
}
export function SoloBoard({
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl,
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, currentUserId,
}: SoloBoardProps) {
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
const {
tasks,
hydrateSnapshot,
optimisticMove,
rollback,
markPending,
clearPending,
removeUnassignedStory,
} = useSoloStore()
const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const selectedTask = useSoloStore(selectSoloTaskById(selectedTaskId))
const [sheetOpen, setSheetOpen] = useState(false)
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition()
const [batchPending, startBatchTransition] = useTransition()
const [confirmPending, startConfirmTransition] = useTransition()
@ -76,21 +74,27 @@ export function SoloBoard({
}
const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null)
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => {
initTasks(initialTasks)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskKey])
if (!initialTasks || !initialUnassigned || !currentUserId) return
const snapshot: SoloWorkspaceSnapshot = {
product: { id: productId, name: '' },
sprint: { id: `compat:${productId}`, sprint_goal: sprintGoal },
activeUserId: currentUserId,
tasks: initialTasks,
unassignedStories: initialUnassigned,
}
hydrateSnapshot(snapshot)
}, [currentUserId, hydrateSnapshot, initialTasks, initialUnassigned, productId, sprintGoal])
const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
const sensors = useSensors(...(isDemo ? [] : [pointerSensor]))
const taskList = Object.values(tasks)
const columnTasks: Record<ColumnStatus, SoloTask[]> = {
TO_DO: taskList.filter(t => getColumnStatus(t.status) === 'TO_DO'),
IN_PROGRESS: taskList.filter(t => getColumnStatus(t.status) === 'IN_PROGRESS'),
DONE: taskList.filter(t => getColumnStatus(t.status) === 'DONE'),
TO_DO: useSoloStore(useShallow(selectSoloTasksForColumn('TO_DO'))),
IN_PROGRESS: useSoloStore(useShallow(selectSoloTasksForColumn('IN_PROGRESS'))),
DONE: useSoloStore(useShallow(selectSoloTasksForColumn('DONE'))),
}
const unassignedStories = useSoloStore(useShallow(selectSoloUnassignedStories))
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
@ -243,21 +247,21 @@ export function SoloBoard({
status="TO_DO"
tasks={columnTasks.TO_DO}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
<SoloColumn
key="IN_PROGRESS"
status="IN_PROGRESS"
tasks={columnTasks.IN_PROGRESS}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
<SoloColumn
key="DONE"
status="DONE"
tasks={columnTasks.DONE}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
]}
/>
@ -272,7 +276,7 @@ export function SoloBoard({
productId={productId}
isDemo={isDemo}
repoUrl={repoUrl}
onClose={() => setSelectedTask(null)}
onClose={() => setSelectedTaskId(null)}
/>
<UnassignedStoriesSheet
@ -281,7 +285,7 @@ export function SoloBoard({
isDemo={isDemo}
open={sheetOpen}
onOpenChange={setSheetOpen}
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
onClaim={removeUnassignedStory}
/>
{blockerDialog && (

View file

@ -0,0 +1,55 @@
'use client'
import { useEffect, useRef } from 'react'
import { useSoloStore } from '@/stores/solo-store'
import type { SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types'
interface SoloHydrationWrapperProps {
initialData: SoloWorkspaceSnapshot
children: React.ReactNode
}
function fingerprint(data: SoloWorkspaceSnapshot): string {
const taskPart = data.tasks
.map((task) => [
task.id,
task.status,
task.sort_order,
task.title,
task.implementation_plan ?? '',
task.verify_only ? '1' : '0',
task.verify_required,
task.story_id,
task.story_title,
task.story_code ?? '',
].join(':'))
.join(',')
const unassignedPart = data.unassignedStories
.map((story) => [
story.id,
story.title,
story.code ?? '',
story.tasks.map((task) => `${task.id}:${task.status}:${task.title}`).join('|'),
].join(':'))
.join(',')
return [
data.product.id,
data.sprint.id,
data.activeUserId,
taskPart,
unassignedPart,
].join('||')
}
export function SoloHydrationWrapper({ initialData, children }: SoloHydrationWrapperProps) {
const lastFingerprint = useRef<string>('')
useEffect(() => {
const fp = fingerprint(initialData)
if (fp === lastFingerprint.current) return
lastFingerprint.current = fp
useSoloStore.getState().hydrateSnapshot(initialData)
}, [initialData])
return <>{children}</>
}

View file

@ -62,8 +62,8 @@ export function SprintBoardClient({
const sprintStories = useSprintWorkspaceStore(
useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]),
)
const selectedStoryId = useSprintWorkspaceStore((s) => s.context.activeStoryId)
const sprintStoryIds = new Set(sprintStories.map(s => s.id))
const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null)
const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null)
const [, startTransition] = useTransition()
@ -157,7 +157,9 @@ export function SprintBoardClient({
if (story) story.sprint_id = null
})
if (selectedStoryId === storyId) setSelectedStoryId(null)
if (selectedStoryId === storyId) {
useSprintWorkspaceStore.getState().setActiveStory(null)
}
startTransition(async () => {
const result = await removeStoryFromSprintAction(storyId)
@ -240,7 +242,7 @@ export function SprintBoardClient({
sprintId={sprintId}
isDemo={isDemo}
onRemove={handleRemove}
onSelect={setSelectedStoryId}
onSelect={(storyId) => useSprintWorkspaceStore.getState().setActiveStory(storyId)}
selectedStoryId={selectedStoryId}
currentUserId={currentUserId}
productId={productId}
@ -250,7 +252,6 @@ export function SprintBoardClient({
selectedStoryId ? (
<TaskList
key="tasks"
storyId={selectedStoryId}
sprintId={sprintId}
productId={productId}
isDemo={isDemo}

View file

@ -20,7 +20,7 @@ import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import { selectTasksForStory } from '@/stores/sprint-workspace/selectors'
import { selectTasksForActiveStory } from '@/stores/sprint-workspace/selectors'
import type {
SprintWorkspaceTask,
SprintWorkspaceTaskDetail,
@ -70,7 +70,6 @@ export interface Task {
type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail
interface TaskListProps {
storyId: string
sprintId: string
productId: string
isDemo: boolean
@ -158,9 +157,10 @@ function SortableTaskRow({
)
}
export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) {
export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) {
const storyId = useSprintWorkspaceStore((s) => s.context.activeStoryId)
const orderedTasks = useSprintWorkspaceStore(
useShallow((s) => selectTasksForStory(s, storyId)),
useShallow(selectTasksForActiveStory),
)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
@ -179,6 +179,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId,
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!storyId) return
if (!over || active.id === over.id) return
const store = useSprintWorkspaceStore.getState()
const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])]
@ -217,6 +218,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId,
}
function openCreateDialog() {
if (!storyId) return
router.push(`${pathname}?newTask=1&storyId=${storyId}`)
}