/** * Board view: the multi-column kanban that replaces the middle column while * active. Cards open the task detail (never execute directly); the header * offers filter, new-task, and a back-to-chat escape. */ import { memo, useCallback, useEffect, useState } from 'react' import { selectedTaskOf, type BoardController } from '../../core/controller.ts' import { COLUMNS, canMoveManually, collectKnownTags, tagTone, type TaskRecord } from '../../core/tasks.ts' import { t } from '../locales.ts' import css from '../board.module.css' import { NewTaskModal } from './NewTaskModal.tsx' import { STATUS_KEY } from './status-key.ts' import { TaskCard } from './TaskCard.tsx' import { TaskDetail } from './TaskDetail.tsx' /** Sentinel option value of the project row's "register a new project" entry. */ export const NEW_PROJECT_VALUE = '__dsh_new_project__' /** Case-insensitive title/description/tag/freeze-snapshot match. */ export function matchesFilter(task: TaskRecord, filter: string): boolean { if (filter.trim() === '') return true const needle = filter.trim().toLowerCase() const haystacks = [task.title, task.description, ...(task.tags ?? []).map(tag => tag.name)] if (task.freeze !== undefined) haystacks.push(task.freeze.goal, task.freeze.progress, task.freeze.next) return haystacks.some(text => text.toLowerCase().includes(needle)) } /** * Whether a task carries every selected label (issue #1521). Multi-select is * conjunctive: adding a label narrows the board instead of widening it, which * is the only reading that keeps "工作" selected from dragging unrelated cards * back in when a second label is added. */ export function matchesTagFilter(task: TaskRecord, selected: readonly string[]): boolean { if (selected.length === 0) return true const names = new Set((task.tags ?? []).map(tag => tag.name)) return selected.every(name => names.has(name)) } /** * Memoized per-card adapter: with a stable `onOpen` from the board and an * immutable task record (only the changed card gets a new object ref), a card * re-renders only when its own task changes — not when a sibling card status, * the filter, or the selection moves. */ const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen }: { task: TaskRecord; pending: boolean; timeZone?: string; onOpen: (id: string) => void }) { const onClick = useCallback(() => { onOpen(task.id) }, [task.id, onOpen]) return }) /** Board component; subscribes to the controller snapshot. */ export function TaskBoard({ controller }: { controller: BoardController }) { const [snapshot, setSnapshot] = useState(controller.getSnapshot()) useEffect( () => controller.subscribe(() => setSnapshot(controller.getSnapshot())), [controller], ) const [filter, setFilter] = useState('') const [tagFilter, setTagFilter] = useState([]) const [showNew, setShowNew] = useState(false) // Project partition (#1536): '' means "all projects". A selected project // narrows the board and becomes the new-task form's default workspace. const [projectId, setProjectId] = useState('') const [showNewProject, setShowNewProject] = useState(false) const [newProjectPath, setNewProjectPath] = useState('') const [newProjectError, setNewProjectError] = useState(undefined) const [newProjectPending, setNewProjectPending] = useState(false) const selected = selectedTaskOf(snapshot) const archiveView = snapshot.archiveView // Every label in use across the ledger (board and archive alike), so the // filter never loses an option just because its task was archived. const knownTags = collectKnownTags(snapshot.tasks) // Archived tasks leave the columns; the archive view shows them instead. const visible = snapshot.tasks.filter(task => (archiveView ? task.archivedAt !== undefined : task.archivedAt === undefined) && (projectId === '' || task.workspaceId === projectId) && matchesFilter(task, filter) && matchesTagFilter(task, tagFilter), ) const projects = snapshot.executionOptions.workspaces const canCreateProject = snapshot.canCreateWorkspace === true const submitNewProject = async (): Promise => { const path = newProjectPath.trim() if (path === '') return setNewProjectPending(true) setNewProjectError(undefined) try { const created = await controller.createWorkspace(path) setProjectId(created.workspaceId) setShowNewProject(false) setNewProjectPath('') } catch (error) { setNewProjectError(error instanceof Error ? error.message : String(error)) } finally { setNewProjectPending(false) } } const toggleTag = useCallback((name: string): void => { setTagFilter(current => current.includes(name) ? current.filter(entry => entry !== name) : [...current, name]) }, []) const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller]) return ( {/* Shared hook: dsh-web-all offsets center-view back controls beside the collapsed mobile sidebar. */} { controller.closeBoard() }} > ‹ {t('board.close')} {t('board.title')} {snapshot.host !== undefined && ( {t('board.hostMeta', { revision: String(snapshot.host.revision), timeZone: snapshot.host.scheduler.timeZone, })} )} {(projects.length > 0 || canCreateProject) && ( {t('board.project')} { const value = event.target.value if (value === NEW_PROJECT_VALUE) { setNewProjectError(undefined) setShowNewProject(true) return } setProjectId(value) }} > {t('board.projectAll')} {projects.map(project => ( {project.title} ))} {canCreateProject && {t('board.projectNew')}} )} { setFilter(event.target.value) }} aria-label={t('board.search')} /> { controller.toggleArchiveView() }} > {archiveView ? t('board.backToBoard') : t('board.archiveView', { count: String(snapshot.tasks.filter(task => task.archivedAt !== undefined).length) })} { setShowNew(true) }} > + {t('board.new')} {showNewProject && ( {t('board.projectNewPath')} { setNewProjectPath(event.target.value); setNewProjectError(undefined) }} /> {newProjectError !== undefined && {t('board.projectCreateFailed', { error: newProjectError })}} { setShowNewProject(false); setNewProjectError(undefined) }} > {t('new.cancel')} { void submitNewProject() }} > {t('board.projectCreate')} )} {!archiveView && knownTags.length > 0 && ( {t('board.tagFilter')} {knownTags.map(tag => { const active = tagFilter.includes(tag.name) return ( { toggleTag(tag.name) }} > {tag.name} ) })} {tagFilter.length > 0 && ( { setTagFilter([]) }}> {t('board.tagFilterClear')} )} )} {snapshot.transportError !== undefined && ( {t('board.hostError', { error: snapshot.transportError })}{' '} { void controller.retryHostSync() }}> {t('board.retryHost')} )} {archiveView ? ( {t('board.archive')} {visible.length} {visible.map(task => ( ))} {visible.length === 0 && ( {tagFilter.length > 0 ? t('board.tagEmpty') : t('archive.empty')} )} ) : ( COLUMNS.map(column => { const tasks = visible.filter(task => task.status === column.status) const isManualDropTarget = column.status === 'backlog' || column.status === 'todo' return ( { event.preventDefault() event.dataTransfer.dropEffect = 'move' } : undefined} onDrop={isManualDropTarget ? (event) => { event.preventDefault() const taskId = event.dataTransfer.getData('text/plain') if (!taskId) return const dropped = snapshot.tasks.find(t => t.id === taskId) if (dropped && canMoveManually(dropped.status, column.status) && dropped.status !== column.status) { controller.moveTask(taskId, column.status) } } : undefined} > {t(STATUS_KEY[column.status])} {tasks.length} {tasks.map(task => ( ))} {tasks.length === 0 && ( {tagFilter.length > 0 ? t('board.tagEmpty') : t('board.empty')} )} ) }) )} {selected !== undefined && ( )} {showNew && ( { setShowNew(false) }} /> )} ) }
{t('board.projectCreateFailed', { error: newProjectError })}