Skip to content

feat(kanban): add view for kanban case - #731

Open
TheShinriel wants to merge 12 commits into
nextfrom
feat/kanban-web-view
Open

feat(kanban): add view for kanban case#731
TheShinriel wants to merge 12 commits into
nextfrom
feat/kanban-web-view

Conversation

@TheShinriel

Copy link
Copy Markdown
Contributor

🎯 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, and cli/ reaches the feature through a single entrypoint.

🛠️ How it works

  • Composition root. kanban/src/composition/kanban-runtime.ts is the only file that may import infrastructure/; it wires the repository, use case, watcher factory and web-server factory and hands presentation a KanbanRuntime. Enforced by kanban/tests/architecture/import-boundary.test.ts, not by convention. The adapter formerly new'd inside a React useEffect is gone.
  • One board semantics. Columns are the five fixed ProgressStatus buckets for CLI table, ink and web; unknown renders only when non-empty. Replaces three divergent groupings (status-grouping.ts deleted).
  • HTTP transport is infrastructure. kanban/src/infrastructure/http/kanban-web-server.ts (Node native http), SSE manager and asset reads moved out of presentation/web/. Frontend stays vanilla JS as bundled strings.
  • BoardDto is the contract for HTTP responses and list --json (board-dto.ts); domain entities stop leaking. Repository returns project-relative paths. list --json shape changed; command is hidden.
  • Path selection. web <path> pins the path and hides the picker (POST /api/project → 409); bare web seeds cwd and the picker retargets the watcher. New port methods: TaskDocumentWatcher.retarget, TaskDocumentRepository.projectExists.
  • Live refresh. web is always-live; ink is fetch-once unless interactive --live, which subscribes the same watcher via a hook sharing the fetch-once loader.
  • CLI boundary. cli/ imports only kanban/src/index.ts; cli/scripts/check-kanban-boundary.mjs fails pnpm --dir cli lint on deeper imports. Deps assembled in deps.ts. No published package (explicit non-goal).

🧪 How to verify

  • pnpm --dir kanban test (118 green), pnpm --dir kanban typecheck, pnpm --dir kanban lint
  • pnpm --dir cli lint (boundary gate), pnpm --dir cli build (under budget)
  • Manual scenarios (k list --json, k interactive --live, k web / k web <path>, endpoints, BLOCKED fixture): see cli-kanban-tests.md .

⚠️ Heads-up

  • aidd kanban stays hidden / experimental; no user-facing docs here.
  • list --json output shape changed to BoardDto; the only consumer (web frontend) is updated in this PR.
  • tests/golden/framework-build-golden.e2e.test.ts can time out under the full pnpm --dir cli test (CPU starvation); passes isolated. Pre-existing, unrelated.

🔗 Linked issue

✅ I certify

  • I DO CERTIFY I READ EACH LINE OF THE PULL REQUEST BECAUSE I AM A SOFTWARE ENGINEER, NOT A AI PUPPY.

templth and others added 12 commits August 21, 2026 20:10
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
@TheShinriel TheShinriel self-assigned this Aug 30, 2026
@TheShinriel
TheShinriel requested a review from a team as a code owner August 30, 2026 15:14

@blafourcade blafourcade left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 EADDRINUSE et 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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peut être trop décrit ici

for (const line of violations) {
console.error(` ${line}`);
}
process.exit(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 = () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 blafourcade changed the title Feat/kanban web view feat(kanban): add view for kanban case Aug 31, 2026

@blafourcade blafourcade left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: TaskGroups ou collection équivalente pour regrouper, filtrer et produire un Board; Board garantit ses colonnes et leur ordre; TaskGroup expose sa progression. Aucun libellé UI.
  • Application: GetBoardUseCase orchestre le repository et le domaine; une BoardSession porte 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants