feat(kanban): add view for kanban case - #731
Conversation
Launch a browser-based kanban board via `aidd kanban web [path]`. The server uses the shared ListTaskDocumentsUseCase (same as list/interactive), streams changes over SSE via a simplified filesystem watcher, and serves a vanilla JS frontend with expandable cards and a slide-in detail panel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2HmUD7H9ygUE4xxCtZpqn
Phase 1 of the hexagonal refactor. Wire KanbanCommandDeps + projectPath into a KanbanRuntime through a single composition root, and expose one registerKanban entrypoint. list and web now read the runtime and import no infrastructure; interactive keeps its own cwd default until phase 2. The cli consumer reaches the feature through kanban/src/index.js. Adds an import-boundary test pinning infrastructure imports to the composition root and the ink view (the latter cleared in phase 2). Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
The interactive view no longer builds a FilesystemTaskDocumentRepository inside a React effect. StatusColumnsView receives ListTaskDocumentsUseCase as a prop; interactive-command reads it from the runtime and drops its own process.cwd() default, so projectPath is now resolved once in register-kanban for all three subcommands. The import-boundary check covers presentation/components/ with no exceptions left. Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
Add a domain Board model with five fixed lifecycle columns (todo, in-progress, done, blocked, unknown) derived by deriveBoard. ListTaskDocumentsUseCase now returns a Board; the list table and the ink view consume board.columns directly, dropping status-grouping.ts and the horizontal-scroll / hidden-column machinery. The filesystem repository returns project-relative filePath values. Raw statuses proposed, open and reported map to todo; unmapped stay unknown. Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
Relocate the web server, SSE manager and frontend assets under infrastructure/http/, and introduce presentation/dto/board-dto.ts as the serialization contract. The server now receives a boardProvider returning a BoardDto instead of a use case; the composition root wires toBoardDto over the list use case. web-command parses and validates --port through the error handler and drives runtime.createWebServer. list --json emits the BoardDto. The web command no longer accepts a [path] positional; it always targets runtime.projectPath. Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
Split the old phase 5 into a backend transport phase (project-path endpoints, watcher retarget, repository projectExists) and a frontend phase (render from BoardDto + free-form path picker). Renumber the CLI boundary phase to 7 and the deferred interactive --live phase to 8. Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
Backend for the runtime project switch: retarget(path) on the watcher port, projectExists(path) on the repository port, and GET/POST /api/project on the web server. A positional path pins the server; a bare command seeds cwd and leaves the picker open. Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
… picker The frontend now draws BoardDto.columns straight from the server, dropping its own progress order, label map and grouping. A free-form project-path field wired to GET/POST /api/project lets an unpinned server be re-pointed from the browser; a pinned server shows the path as static text. Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
Move the kanban command's dependency construction out of the command file into a createKanbanCommandDeps factory in the composition root, so cli/src reaches kanban only through kanban/src/index.js. Add scripts/check-kanban-boundary.mjs, wired into `pnpm lint`, to fail on any deeper import, and document the gate. Claude-Session: https://claude.ai/code/session_01NSF2YCNs4poPape7pqwC9G
blafourcade
left a comment
There was a problem hiding this comment.
Merci pour ce travail. La centralisation des colonnes, le BoardDto, les chemins relatifs et le point d’entrée public du Kanban vont dans la bonne direction. Les tests sont également solides sur le backend: 118 tests Kanban passent, ainsi que typecheck, lint et le build CLI sous le budget.
Je demande néanmoins des changements avant merge.
Bloquant
- Le serveur annoncé comme local écoute sur toutes les interfaces et la combinaison CORS SSE wildcard + mutation de chemin sans contrôle d’origine permet une fuite de données locales. Ce point doit être corrigé avant merge.
Architecture et fiabilité
- Les dépendances ne forment pas encore le hexagone annoncé: presentation dépend de composition et infrastructure dépend du DTO de presentation.
- Le démarrage ne traite pas proprement
EADDRINUSEet lance le watcher avant le bind. - La reconnexion SSE peut afficher un état connecté avec des données obsolètes.
- La lecture des assets depuis les sources et la validation du dossier projet ont deux cas concrets non couverts.
Qualité fonctionnelle
- Les cartes et le panneau ne sont pas utilisables correctement au clavier.
- À traiter également, sans bloquer seuls le merge: validation stricte du port, README devenu obsolète et décision explicite sur le statut brut
todo.
Les commentaires inline détaillent impact, scénario de panne et correction attendue.
| Hooks run this repo's own checks directly (no parent-monorepo delegation): | ||
|
|
||
| - `pre-commit`: `pnpm lint` (biome) + `pnpm typecheck` | ||
| - `pre-commit`: `pnpm lint` (biome + `scripts/check-kanban-boundary.mjs`, which fails on any `cli/src` import of `kanban/src/` deeper than `index`) + `pnpm typecheck` |
There was a problem hiding this comment.
Peut être trop décrit ici
| for (const line of violations) { | ||
| console.error(` ${line}`); | ||
| } | ||
| process.exit(1); |
There was a problem hiding this comment.
Code 2 ? Les hooks utilisent ce code comme bloquant comme ça on peut l'utiliser s'i lfaut en hook proprement
| async execute(projectPath: string, filters: ListTaskDocumentsFilters): Promise<TaskGroup[]> { | ||
| async execute(projectPath: string, filters: ListTaskDocumentsFilters): Promise<Board> { | ||
| const taskDocuments = await this.taskDocumentRepository.findAll(projectPath); | ||
| const taskGroups = groupTaskDocumentsByDirectory(taskDocuments); |
There was a problem hiding this comment.
Il faudrait à terme qu'on ait des objets du domain riches qui exposent les méthodes comme ici TaskDocument.groupBy(directory)
.filter(filtre)
.toBoard()
| export interface BoardColumn { | ||
| progressStatus: ProgressStatus; | ||
| label: string; | ||
| taskGroups: TaskGroup[]; |
There was a problem hiding this comment.
Ca pourrait être interessant comme dit dans mon commentaire précédent, si on avait des Wrapper de list dans le domain avec les methodes qui gerent le lifecycle : TasksGroup qui contient l'array et expose les méthodes, invariants init à vide par défaut etc
| return taskGroups.filter((taskGroup) => taskGroup.parent.progressStatus === progressStatus); | ||
| } | ||
|
|
||
| export function deriveBoard(taskGroups: TaskGroup[]): Board { |
There was a problem hiding this comment.
Je challenge le fait d'avoir des méthodes avec un objet typescript ici mais c'est un choix de vision
| void this.handleRequest(req, res); | ||
| }); | ||
|
|
||
| this.deps.watcher.start(this.activeProjectPath); |
There was a problem hiding this comment.
[Fiabilité] Le watcher démarre avant que le port soit effectivement bindé. En cas de EADDRINUSE, le serveur émet error, mais la Promise créée plus bas ne possède aucun chemin de rejet ni cleanup. Cette erreur contourne donc le try/catch de la commande et laisse le watcher ouvert jusqu’à la fin forcée du processus. Démarrer le watcher après listening, rejeter sur error, puis fermer serveur et watcher sur tout démarrage partiel.
| function connectSSE() { | ||
| const source = new EventSource("/events"); | ||
|
|
||
| source.onopen = () => { |
There was a problem hiding this comment.
[Fonctionnel] Après une coupure SSE, onopen remet uniquement le voyant au vert. Les changements survenus pendant la déconnexion restent absents du board jusqu’au changement suivant. Appeler loadInitialData() à chaque reconnexion, puis tester le scénario déconnexion -> modification -> reconnexion. Côté serveur, les erreurs de fetchAndBroadcast() devraient également être remontées au lieu d’être silencieusement ignorées.
| const FRONTEND_DIRECTORY_NAME = "kanban-frontend"; | ||
|
|
||
| function resolveFrontendDirectory(): string { | ||
| return join(dirname(fileURLToPath(import.meta.url)), FRONTEND_DIRECTORY_NAME); |
There was a problem hiding this comment.
[Fonctionnel] En exécution depuis les sources, import.meta.url pointe vers .../infrastructure/http/frontend-assets.ts; ajouter kanban-frontend produit donc .../http/kanban-frontend, alors que le dossier réel est .../http/frontend. Le test des assets vérifie seulement que le module se charge et le test de composition mocke cette fonction. Il faut résoudre explicitement les emplacements source/build, ou injecter un asset provider, puis appeler réellement readFrontendAssets() dans un test.
| constructor(private readonly docsDirectoryName: string) {} | ||
|
|
||
| async projectExists(projectPath: string): Promise<boolean> { | ||
| return existsSync(join(projectPath, this.docsDirectoryName)); |
There was a problem hiding this comment.
[Robustesse] existsSync retourne aussi true si <project>/aidd_docs est un fichier ordinaire. Le projet est alors accepté, le chemin actif est muté, puis le scan ou le watcher échoue. Utiliser stat().isDirectory(), traduire ENOENT en false, et ajouter un test avec un fichier portant le nom du dossier attendu.
| } | ||
|
|
||
| function createCard(card) { | ||
| const cardEl = document.createElement("div"); |
There was a problem hiding this comment.
[Accessibilité] Cette carte interactive est un div non focusable: un utilisateur clavier ne peut ni la parcourir avec Tab ni ouvrir le panneau. Préférer un button correctement stylé, ou ajouter au minimum tabindex, rôle et gestion Enter/Espace. Le panneau devrait aussi exposer une sémantique de dialogue modal, déplacer le focus à son ouverture et le restituer à la fermeture.
blafourcade
left a comment
There was a problem hiding this comment.
Seconde passe centrée sur la répartition des responsabilités et le modèle de domaine.
Je rejoins la direction proposée dans les premiers commentaires, avec une nuance: rendre le domaine riche ne signifie pas transformer chaque interface en classe mutable. Les invariants et comportements doivent être portés par des value objects immuables au bon niveau, notamment les collections lorsque le comportement porte sur plusieurs documents.
Découpage cible
- Domaine:
TaskGroupsou collection équivalente pour regrouper, filtrer et produire un Board;Boardgarantit ses colonnes et leur ordre;TaskGroupexpose sa progression. Aucun libellé UI. - Application:
GetBoardUseCaseorchestre le repository et le domaine; uneBoardSessionporte le projet actif, le changement de projet et les subscriptions. - Présentation: un view state discriminé
loading | ready | error; un controller/hook gère les effets; la page compose des composants purs. - Infrastructure: filesystem, HTTP et SSE restent des adapters sans état de workflow applicatif.
Le point le plus concret est dans StatusColumnsView: conserver le Board dans React est normal, mais le faux Board vide et les deux states indépendants créent déjà un état impossible et empêchent la récupération après erreur. Les commentaires inline proposent un découpage progressif sans introduire un framework ou une hiérarchie de classes inutile.
| filters: ListTaskDocumentsFilters, | ||
| createWatcher: (() => TaskDocumentWatcher) | undefined | ||
| ): FetchedBoard { | ||
| const [board, setBoard] = useState<Board>(EMPTY_BOARD); |
There was a problem hiding this comment.
[Responsabilités / état UI] Conserver un snapshot Board dans React est normal. Ici, le problème est le modèle d’état: EMPTY_BOARD fabrique un Board sans les quatre colonnes garanties par le domaine, puis board et fetchError évoluent séparément. Après une erreur, un chargement réussi appelle setBoard sans effacer fetchError, donc la vue peut rester définitivement en erreur avec des données valides. Je modéliserais un état discriminé loading | ready(board) | error(message) dans un hook useBoardSession; StatusColumnsView ne ferait ensuite que composer des composants purs.
| constructor(private readonly taskDocumentRepository: TaskDocumentRepository) {} | ||
|
|
||
| async execute(projectPath: string, filters: ListTaskDocumentsFilters): Promise<TaskGroup[]> { | ||
| async execute(projectPath: string, filters: ListTaskDocumentsFilters): Promise<Board> { |
There was a problem hiding this comment.
[Responsabilité du use case] ListTaskDocumentsUseCase ne liste plus des documents: il groupe, filtre et retourne un Board. Le nom et la responsabilité publique ne correspondent donc plus. Deux directions cohérentes: soit ListTaskDocuments retourne réellement les documents, soit ce use case devient GetBoardUseCase et orchestre un value object de collection immuable, par exemple TaskGroups.fromDocuments(documents).filter(criteria).toBoard(). Ces comportements de regroupement et filtrage appartiennent à la collection, pas à chaque TaskDocument.
| private server: Server | undefined; | ||
| private readonly sseManager = new SseManager(); | ||
| private readonly deps: KanbanWebServerDeps; | ||
| private activeProjectPath: string; |
There was a problem hiding this comment.
[Responsabilité applicative] activeProjectPath, la validation du nouveau projet, le retarget du watcher et le refresh du board forment un état de session applicatif. Les conserver dans un adapter HTTP oblige le transport à porter le workflow métier. Extraire par exemple un BoardSession ou SelectProjectUseCase avec currentProject, selectProject, getBoard et subscribe; le serveur HTTP devrait seulement mapper routes, payloads et codes de statut.
| if (e.key === "Escape" && panelOverlay.classList.contains("panel-visible")) closePanel(); | ||
| }); | ||
|
|
||
| function renderBoard(columns) { |
There was a problem hiding this comment.
[Composition UI] Cette fonction construit à la fois la page, chaque colonne et ses cartes, alors que la même IIFE gère aussi API, SSE, project picker et panneau de détail. Même en gardant du vanilla JS, je séparerais KanbanPageController pour les effets et l’état, puis des composants DOM ciblés BoardView, BoardColumn, Card, DetailPanel et ProjectPicker. Le controller fournit un DTO ou un view state; chaque composant ne connaît que son rendu et ses événements locaux.
| description: taskGroup.parent.description, | ||
| path: taskGroup.parent.filePath, | ||
| subDocuments, | ||
| doneSubCount: countDoneSubCards(subDocuments), |
There was a problem hiding this comment.
[Responsabilité du domaine] Le nombre de sous-documents terminés exprime une sémantique métier de progression, pas une règle de sérialisation. Le mapper DTO devrait traduire une information déjà portée par TaskGroup ou par un value object Completion { done, total, ratio }, plutôt que recalculer le sens de done ici. Cela évite que chaque nouvelle présentation réimplémente cette règle.
| export function deriveBoard(taskGroups: TaskGroup[]): Board { | ||
| const columns = PROGRESS_STATUSES_IN_COLUMN_ORDER.map((progressStatus) => ({ | ||
| progressStatus, | ||
| label: PROGRESS_STATUS_LABELS[progressStatus], |
There was a problem hiding this comment.
[Frontière domaine / présentation] Le domaine doit décider quelles colonnes existent, leur ordre et quels groupes elles contiennent. En revanche, TODO ou IN PROGRESS sont des libellés de présentation, susceptibles de changer selon CLI, web ou localisation. Je laisserais BoardColumn porter uniquement progressStatus et les groupes; le presenter ou le mapper DTO fournirait le label.
🎯 What & why
Adds
aidd kanban web— a browser board that streams the project's task documents over SSE, with a project-path picker so a browser-only user can re-point it without restarting. On the way,kanban/moves onto a real hexagon: presentation depends only on injected ports, HTTP transport lives in infrastructure, one rule decides board columns for every surface, andcli/reaches the feature through a single entrypoint.🛠️ How it works
kanban/src/composition/kanban-runtime.tsis the only file that may importinfrastructure/; it wires the repository, use case, watcher factory and web-server factory and hands presentation aKanbanRuntime. Enforced bykanban/tests/architecture/import-boundary.test.ts, not by convention. The adapter formerlynew'd inside a ReactuseEffectis gone.ProgressStatusbuckets for CLI table, ink and web;unknownrenders only when non-empty. Replaces three divergent groupings (status-grouping.tsdeleted).kanban/src/infrastructure/http/kanban-web-server.ts(Node nativehttp), SSE manager and asset reads moved out ofpresentation/web/. Frontend stays vanilla JS as bundled strings.BoardDtois the contract for HTTP responses andlist --json(board-dto.ts); domain entities stop leaking. Repository returns project-relative paths.list --jsonshape changed; command ishidden.web <path>pins the path and hides the picker (POST /api/project→ 409); barewebseedscwdand the pickerretargets the watcher. New port methods:TaskDocumentWatcher.retarget,TaskDocumentRepository.projectExists.webis always-live; ink is fetch-once unlessinteractive --live, which subscribes the same watcher via a hook sharing the fetch-once loader.cli/imports onlykanban/src/index.ts;cli/scripts/check-kanban-boundary.mjsfailspnpm --dir cli linton deeper imports. Deps assembled indeps.ts. No published package (explicit non-goal).🧪 How to verify
pnpm --dir kanban test(118 green),pnpm --dir kanban typecheck,pnpm --dir kanban lintpnpm --dir cli lint(boundary gate),pnpm --dir cli build(under budget)k list --json,k interactive --live,k web/k web <path>, endpoints, BLOCKED fixture): see cli-kanban-tests.md .aidd kanbanstayshidden/ experimental; no user-facing docs here.list --jsonoutput shape changed toBoardDto; the only consumer (web frontend) is updated in this PR.tests/golden/framework-build-golden.e2e.test.tscan time out under the fullpnpm --dir cli test(CPU starvation); passes isolated. Pre-existing, unrelated.🔗 Linked issue
✅ I certify