SLOPSHOPPER

asana-board

Tâches Asana du projet en panneau (/asana, ou /asana-board:asana dès le premier message) et en bandeau au-dessus du prompt : les tâches non terminées de la…

panebandrowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · asana-board
│ ┃ Asana ✕ › fix the failing auth test and add an audit log call │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ │ ⏺ Read(src/auth.ts) │ ┃ │ ◆ Tâches Asana du projet │ ⎿ Read 6 lines │ ┃ │ Le panneau lit la section Asana │ ⏺ Update(src/auth.ts) │ ┃ │ nommée dans le CLAUDE.md du dossier │ ⎿ Added 2 lines, removed 1 line │ ┃ │ (ou docs/agents/issue-tracker.md), │ ⏺ Bash(bun test) │ ┃ │ puis ses tâches ouvertes et leurs │ ⎿ 3 pass, 1 fail │ ┃ │ sous-tâches. │ │ ┃ │ Dossier : /work/app │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭────────────────────────────────────────╮ › /asana │ ┃ │ ⚠ Lecture impossible │ ⎿ asana-board: Panneau Asana : Pas de section Asana dans /work/app │ ┃ │ Pas de section Asana dans /work/app │ │ ┃ │ (CLAUDE.md, │ │ ┃ │ docs/agents/issue-tracker.md). │ │ ┃ │ Attendu dans le CLAUDE.md ou │ │ ┃ │ docs/agents/issue-tracker.md : section │ │ ┃ │ « Nom » (GID `123…`), ou une ligne de │ │ ┃ │ tableau | Section | « Nom », GID │ │ ┃ │ `123…` | ; le projet de la même façon │ │ ┃ │ pour créer des tâches. │ │ ┃ │ │ │ ┃ │ [ ↻ Réessayer ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Asana
╭────────────────────────────────────────────────────────╮ │ │ │ ◆ Tâches Asana du projet │ │ Le panneau lit la section Asana nommée dans le │ │ CLAUDE.md du dossier (ou │ │ docs/agents/issue-tracker.md), puis ses tâches │ │ ouvertes et leurs sous-tâches. │ │ Dossier : /work/app │ │ │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ ⚠ Lecture impossible │ │ Pas de section Asana dans /work/app (CLAUDE.md, │ │ docs/agents/issue-tracker.md). │ │ Attendu dans le CLAUDE.md ou │ │ docs/agents/issue-tracker.md : section « Nom » (GID │ │ `123…`), ou une ligne de tableau | Section | « Nom », │ │ GID `123…` | ; le projet de la même façon pour créer │ │ des tâches. │ │ │ │ [ ↻ Réessayer ] │ ╰────────────────────────────────────────────────────────╯
README

asana-board

Les tâches Asana du projet dans Claude Code, en panneau et en bandeau au-dessus du prompt. Le mod lit la section Asana nommée dans le CLAUDE.md du dossier (ou dans docs/agents/issue-tracker.md), puis ses tâches non terminées et leurs sous-tâches, rangées par la ligne « Statut : » du skill asana-board:suivi-asana.

  • Il se remplit dès le début. Au démarrage, si le CLAUDE.md nomme une section Asana, une lecture de fond remplit le bandeau, sans rien bloquer.
  • Il permet d'agir. Créer, cocher, changer le statut, assigner, commenter, modifier, abandonner ou supprimer une tâche, sans quitter Claude Code.
  • Il suit Claude. Quand Claude écrit dans Asana pendant la discussion, le panneau se relit, et la ligne de l'appel dit l'écriture avec le nom des tâches (« Asana · coche « Corriger le bandeau » ») au lieu des GID et du JSON.

Version actuelle : 0.7.2. Historique des versions : CHANGELOG.md.


Sommaire

  1. Installation
  2. Utilisation
  3. Le panneau
  4. Le bandeau
  5. Écritures de Claude dans la conversation
  6. Commandes
  7. Lectures, erreurs et silence
  8. Données et stockage
  9. Architecture
  10. Développement
  11. Limites connues

Installation

Le mod est distribué par la marketplace mathis-plugins (dépôt GitHub matwi5/mathis-plugins, source dans plugins/asana-board/). Installation d'un poste et protocole de développement : README à la racine du dépôt.

claude plugin marketplace update mathis-plugins
claude plugin install asana-board@mathis-plugins

Pour une nouvelle version : modifier la source dans le clone de travail (plugins/asana-board), monter version dans .claude-plugin/plugin.json et dans l'entrée du mod de .claude-plugin/marketplace.json à la racine du dépôt, commiter et pousser, puis :

claude plugin marketplace update mathis-plugins
claude plugin update asana-board@mathis-plugins

Une mise à jour s'applique aux discussions démarrées après elle, ou après /reload-plugins.

Prérequis

  • Claude Code avec les function hooks : mods de la version 2.1.286 ou plus récente.
  • Le connecteur Asana dans la session. Le mod le trouve par ses outils …__get_tasks et …__update_tasks, quel que soit son nom.
  • Une section Asana dans le CLAUDE.md du dossier ou dans docs/agents/issue-tracker.md (lus dans cet ordre), sous l'une de ces formes :
  projet « Suivi des tâches » (GID `1200000000000001`), section « Mon projet » (GID `1200000000000002`)
  | Projet | « Suivi des tâches », GID `1200000000000001` |
  | Section | « Mon projet », GID `1200000000000002` |

Le projet est facultatif ; il sert à créer des tâches dans la section. Il est pris dans le fichier de la section, sinon dans l'autre.

  • Mode automatique : chaque outil Asana appelé par le mod (get_me, get_tasks, get_task, update_tasks, create_tasks, add_comment, delete_task) doit être autorisé dans permissions.allow. Le classifieur ne juge que les demandes du modèle, pas celles d'un mod.

Utilisation

  1. Ouvrir une discussion dans un dossier dont le CLAUDE.md nomme une section Asana. Le bandeau apparaît après la première lecture réussie.
  2. Ouvrir le panneau : ▸ /asana dans le bandeau, /asana, ou /asana-board:asana en tout premier message.
  3. Agir sur une tâche : ▸ la déplie. « ▶ Travailler dessus » met la demande dans le prompt, sans l'envoyer : Entrée la lance.

Le panneau

De haut en bas :

ZoneContenu
En-têtePastille « ◆ ASANA », état (lecture, erreur, « À jour » en vert pendant 10 min, puis « Lue » en gris) ; anneau de répartition par statut (SVG) ou total et barre en blocs (terminal) ; nom de la section ; tâches ouvertes, filtre, « pour toi », retards, âge de la lecture ; légende des groupes ; « + Nouvelle tâche » (« ▾ Replier le formulaire » quand il est ouvert : le brouillon reste) et « ↻ Relire » (« Lecture… » pendant une lecture, qu'il attend au lieu d'en lancer une seconde). Avant la première lecture réussie, une carte d'accueil remplace l'en-tête : aucun compte n'est affiché tant que rien n'est lu.
Onglets« Tous · N », « Pour toi · N », « Pour Claude · N ». Les comptes de l'en-tête suivent le filtre, et le disent.
ÉtatÀ la même place : lecture en cours (spinner et progression), carte d'erreur avec « ↻ Réessayer », ou avertissement.
FormulaireTitre (focus d'entrée), statut, pour qui, emplacement, contexte, critère de fin, échéance. « Annuler » vide le brouillon.
Groupes⚖ À trancher, ◉ En cours, ⏸ En attente d'autres (replié d'office), ○ À faire, · Sans statut, puis ✓ Terminées (cochées depuis la dernière lecture, pour pouvoir décocher). Dans un groupe, l'échéance la plus proche d'abord, puis le titre. Douze tâches au plus, puis « ▸ tout voir (N) ».
TâcheUne ligne : case, titre tronqué, personne (sous l'onglet « Tous » seulement), échéance colorée (rouge si dépassée ou sous 2 jours, ambre sous 7). Dépliée, une carte bordée de la couleur du groupe : statut, description, actions, commentaire.
  • Le retour d'une action passe par un toast, puisque la tâche peut changer de groupe. Une erreur garde un toast court ; son détail complet reste sous la tâche, dépliée pour qu'il se voie.
  • Pendant une écriture, la case de la tâche devient un spinner : un second appui ne relance rien, et un toast dit qu'une écriture est en cours.
  • Si le prompt ne peut pas être rempli, « ▶ Travailler dessus » donne la demande à taper dans un toast.
  • Cocher passe aussi la ligne « Statut : » à « fait », dans la même écriture ; décocher la remet à « à faire ». Une description mise en forme dans Asana n'est pas réécrite : la tâche est cochée quand même, et le toast dit que le statut n'a pas changé.
  • « Oui, supprimer » n'est pas l'action principale : « Abandonner » garde une trace (commentaire, puis coche avec « Statut : abandonnée »).
  • Sur le mobile, il n'y a ni champ ni liste de choix. Le panneau le dit en une ligne atténuée : « Mobile : pas de saisie (création, statut, commentaire). »

Le bandeau

Une ligne, deux au plus, sous les bandeaux des autres mods (flow-board, git-board…) :

  • Ligne 1 : barre « ▍ » et nom « Asana » en rose (même colonne que Git et Flow), nom de la section (à partir de 90 colonnes), petite barre de répartition (64), comptes par groupe (à trancher, en cours, à faire, sans statut, puis en attente, atténué), « ⚠ N en retard », « pour toi : N » (72). À droite : ▸ /asana ouvre le panneau, x masque le bandeau.
  • Ligne 2 (à partir de 56 colonnes, seulement s'il y a quelque chose) : la lecture en cours, l'erreur d'une relecture demandée, rien d'autre : ni échéance ni retard détaillés, les retards sont comptés en ligne 1. En tête, une barre « ▍ » et le nom du mod, de sa couleur, alignés avec ceux des autres bandeaux.
  • La ligne 1 tient toujours sur sa largeur. Les paliers disent le plus riche ; si ça ne tient pas, le nom de section part, puis les comptes passent en glyphes (⚖1 ◉1 ○2 ⏸1), puis s'en vont, dans cet ordre, « pour toi », la barre, le compte « en attente » ; le retard s'abrège en ⚠1, et le nom part en dernier recours, la barre reste. Sur 80 colonnes, les comptes sont en glyphes.

Les comptes du bandeau ne suivent pas le filtre du panneau. Le bandeau n'apparaît qu'après une lecture réussie ; pendant une relecture, il garde les dernières données.

Masquer : x ou /asana hide. La ligne d'état sous le prompt prend alors le relais. /asana show le réaffiche. Ce choix vaut pour la discussion.


Écritures de Claude dans la conversation

Depuis la 0.6.0, quand Claude appelle un outil d'écriture du connecteur Asana (…__update_tasks, …__create_tasks, …__add_comment, …__delete_task), la ligne de l'appel dans la conversation devient une phrase courte, sur toutes les surfaces :

AppelLigne
update_tasks avec completed: true (ou false)Asana · coche (décoche) « X »
update_tasks avec une ligne « Statut : » dans notesAsana · statut « en cours » sur « X »
update_tasks avec name, assignee, due_onAsana · renomme « X » en « Y » ; assigne « X » à toi ; confie à Claude « X » ; échéance 2026-10-20 sur « X »
update_tasks aux modifications de sortes différentesAsana · modifie « X », « Y »
add_commentAsana · commentaire sur « X »
create_tasksAsana · crée « Y » (le nom vient de l'appel)
delete_taskAsana · supprime « X »
  • Les noms viennent des tâches déjà lues par le panneau, sans relecture : un GID inconnu s'affiche tel quel (« tâche 1200… »).
  • Plusieurs tâches : trois noms au plus, sinon les deux premiers et « +n ».
  • Pendant l'appel, la ligne est atténuée et finit par « … ».
  • La ligne du moteur reste pour un appel en erreur ou interrompu, et pour une entrée qui n'a pas la forme attendue. Le résultat de l'appel, sous la ligne, n'est pas touché.

Commandes

CommandeEffet
/asanaOuvre le panneau et relit la section (dossier suivi, sinon celui de la session).
/asana <dossier>Suit un autre dossier, dont le CLAUDE.md nomme la section.
/asana tous, /asana toi, /asana claudeOuvre le panneau sur cet onglet.
/asana hideMasque le bandeau ; une ligne d'état prend le relais.
/asana showRéaffiche le bandeau et ouvre le panneau.

Skill suivi-asana (/asana-board:suivi-asana, depuis la 0.3.0) : la règle d'écriture des tâches (nom [code] …, ligne « Statut : », assignation, un seul niveau de sous-tâches) que le panneau suppose. Claude le déclenche de lui-même dès que du travail est fait, découvert ou modifié dans un projet suivi, mais n'écrit dans Asana qu'après l'accord de Mathis sur la liste des changements (depuis la 0.4.0).

Dès le premier message : /asana-board:asana. /asana est déclarée par le mod au démarrage de la session ; sur le desktop, ce démarrage n'a lieu qu'à l'envoi du premier message. /asana-board:asana, déclarée par commands/asana.md, est disponible tout de suite et accepte les mêmes arguments. Le mod y répond lui-même, sans passer par le modèle. Une fois le mod démarré, /asana sort du menu, mais reste utilisable tapée en entier.


Lectures, erreurs et silence

LectureLancée parEn cas d'échec
Demandée/asana, ↻ Relire, ↻ RéessayerL'erreur s'affiche dans le panneau (carte rouge) et, en ligne 2, dans le bandeau. /asana répond « Panneau Asana : <erreur> ».
De fondLe démarrage de la session ; une écriture de Claude dans AsanaRien : ni toast, ni bandeau d'erreur, ni ligne d'état.
  • Au démarrage, sans section Asana dans le CLAUDE.md : aucun appel. Connecteur pas encore là : nouvel essai après 5 s, 15 s puis 45 s, puis abandon sans un mot. Un /asana fait entre-temps (qui dit « Connecteur Asana introuvable ») n'arrête pas ces essais.
  • Jamais deux lectures en même temps. Un /asana ou un ↻ pendant une lecture l'attend. Une lecture demandée ne repart ensuite que si l'autre n'a pas fait son travail : autre dossier, ou lecture de fond échouée (il faut alors montrer l'erreur).
  • /asana attend la fin de la lecture pour répondre. Si la lecture dépasse le temps alloué au mod (plus de 10 s de pauses entre essais), la commande répond elle-même (« Lecture Asana trop longue pour attendre ici… ») au lieu de laisser le modèle dire que le mod n'est pas chargé ; si la lecture ne s'achève pas, ↻ Relire la relance.
  • Une erreur ne vide pas la liste : la dernière lecture réussie reste affichée, et la carte d'erreur dit de quand elle date.
  • Asana répond parfois 500 sous la charge : la lecture d'une sous-tâche est retentée une fois après 1,5 s, puis la tâche s'affiche sans ses détails, avec un avertissement.

Données et stockage

DonnéeEmplacementPortée
Tâches lues, dossier, filtre, groupes repliés, brouillon, bandeau masquéÉtat de session ($.state)Une discussion

Le mod ne garde rien d'une discussion à l'autre. Il écrit dans Asana seulement à la demande de la personne (boutons et champs du panneau).


Architecture

asana-board/
├── .claude-plugin/plugin.json   manifeste (nom, version, contrat de types)
├── commands/asana.md            /asana-board:asana, connue avant le démarrage du mod
├── hooks/
│   ├── hooks.json               déclare le module register.tsx
│   ├── register.tsx             tout ce qui passe par $ : état, hooks, lectures et écritures Asana
│   ├── model.ts                 données pures : rangement par statut, comptes, échéances, saisie, lignes des écritures de Claude
│   ├── view.tsx                 dessin du panneau et du bandeau, toutes surfaces
│   └── svg.ts                   anneau de répartition, barres, indicateurs (copiés de flow-board)
├── skills/suivi-asana/          skill de tenue du projet de suivi (SKILL.md, references/)
├── types/index.d.ts             contrat de l'état (`PluginState`)
└── tests/                       `claude plugin test`

view.tsx ne touche jamais $ : il reçoit l'état lu et les actions en fermetures. register.tsx lui dit aussi ce que la surface dessine (SVG, champs de saisie), d'après le contrat des surfaces du moteur.


Développement

claude plugin validate plugins/asana-board
claude plugin test plugins/asana-board
  • Les tests montent le panneau sur terminal, desktop, vscode et mobile, le bandeau sur terminal et desktop, sous un autre bandeau, et la ligne des écritures de Claude (ToolUse) sur terminal et desktop. Ils simulent Asana, le CLAUDE.md, l'horloge et le moteur.
  • Dans le kit de test, un hook qui remplace une opération du moteur (fs.read, ui.open, mcp.call…) doit rendre { value }.
  • $.mcp.call rend le texte que voit le modèle : une réponse trop longue (55 Ko le 14/10/2026) y devient un encart <persisted-output> qui donne le chemin du fichier où le moteur l'a rangée, ou (vu le 16/10/2026, 74 Ko) un message « Error: result (… characters) exceeds maximum allowed tokens. Output has been saved to <chemin>. ». asana() relit ce fichier dans les deux cas (persistedPath), avant de traiter la réponse comme une erreur.
  • La table de $.ui.resolve(e) porte tous les noms d'éléments, même ceux que la surface ne dessine pas (un fragment vide). On ne peut donc pas tester typeof table.Svg : la surface décide.
  • Les versions précédentes sont dans l'historique git du dépôt ; celles d'avant le passage à git restent dans ~/.claude/mods-archives/asana-board-<version> sur le PC Windows.

Limites connues

  • Plus de 100 tâches dans la section : la liste est tronquée (pas de pagination).
  • Plan gratuit (depuis la 0.5.0) : ni priorité, ni champ personnalisé, ni dépendance ; Asana les refuse (payment_required). Détail dans skills/suivi-asana/references/conventions.md.
  • Titulaire : « Toi » est le nom affiché codé en dur (ME dans hooks/model.ts).
  • Relecture après une écriture de Claude : déclenchée par le nom de l'outil (…__update_tasks, …__create_tasks, …__add_comment, …__delete_task), quel que soit le serveur.
  • Ligne d'une écriture de Claude : repérée par le même nom d'outil ; une tâche hors de la section suivie (ou avant la première lecture) s'affiche par son GID. Le moteur ne dit pas si la conversation est en vue détaillée : la ligne courte s'y affiche aussi, le résultat de l'appel restant celui du moteur.
  • Animations : les dessins sont des images ; sur le desktop, le spinner SVG est probablement figé.
  • Largeurs : celle de la ligne 1 du bandeau est estimée (les glyphes d'état comptent pour deux cellules, par prudence) ; sur le desktop, la police est proportionnelle.
  • Rechargement à chaud en pleine écriture ou lecture : le spinner figé est libéré au session.start suivant du mod.
Source 5 files
hooks/register.tsx 755 lines
1// Panneau Asana (/asana, /asana-board:asana) et son bandeau au-dessus du prompt : les tâches
2// non terminées de la section Asana du projet, rangées par la ligne « Statut : » (format
3// du skill asana-board:suivi-asana), avec de quoi les créer, modifier, cocher, commenter ou supprimer.
4// C'est le mod qui appelle Asana : en mode automatique, chaque outil appelé doit être
5// autorisé dans permissions.allow (le classifieur ne juge que les demandes du modèle).
6//
7// Ce fichier tient tout ce qui passe par `$` : l'état, les hooks, les lectures et écritures
8// Asana. Le dessin est dans view.tsx, les règles de rangement dans model.ts.
9
10import { atom, read, update } from 'claude-code'
11import type { Register } from 'claude-code'
12
13import type { AsanaTask, Draft, PanelMeta } from '../types'
14import { ASANA_FILES, FIELDS, WRITE_TOOLS, countsOf, describeWrite, findAsana, isoDate, persistedPath, setMe, shortName, statusLine, toTask, withStatut, withWholeStatut } from './model.ts'
15import { drawBand, drawPane, drawWrite, kitOf, type Abilities, type Actions, type BandView, type View } from './view.tsx'
16
17const PANE = 'asana'
18const PANE_TITLE = 'Asana'
19/** La commande déclarée par `commands/asana.md`, préfixée du nom du plugin. */
20const STATIC_ASANA = 'asana-board:asana'
21/** Arguments de /asana qui ne sont pas un dossier : les onglets du filtre. */
22const TABS = ['tous', 'toi', 'claude']
23/** Délais des nouveaux essais quand le connecteur Asana n'est pas encore là au démarrage. */
24const RETRIES = [5_000, 15_000, 45_000]
25// Le contrat des surfaces (Elements du moteur) : Svg sur les surfaces distantes, Input et
26// Select partout sauf le mobile. La table de `$.ui.resolve` porte tous les noms (un absent
27// y dessine un fragment vide) : c'est donc la surface qui dit ce qui se dessine vraiment.
28const SVG_SURFACES = new Set(['desktop', 'vscode', 'mobile'])
29const FIELD_SURFACES = new Set(['terminal', 'desktop', 'vscode'])
30const abilitiesOf = (surface: string): Abilities => ({ isRich: SVG_SURFACES.has(surface), hasFields: FIELD_SURFACES.has(surface) })
31
32const EMPTY_META: PanelMeta = {
33  folder: null,
34  section: null,
35  sectionGid: null,
36  projectGid: null,
37  server: null,
38  loading: null,
39  error: null,
40  warning: null,
41  loadedAt: null,
42}
43const EMPTY_DRAFT: Draft = { name: '', statut: 'à faire', who: 'claude', where: 'section', contexte: '', fini: '', due: '' }
44
45// Gardés par l'hôte pour la session : ils survivent au rechargement à chaud du module,
46// pas à une nouvelle discussion.
47const tasks = atom({ plugin: 'asana-board', key: 'tasks' } as const, [])
48const meta = atom({ plugin: 'asana-board', key: 'meta' } as const, EMPTY_META)
49const expanded = atom({ plugin: 'asana-board', key: 'expanded' } as const, [])
50const showDone = atom({ plugin: 'asana-board', key: 'showDone' } as const, false)
51// Groupes repliés ; « En attente d'autres » l'est d'abord : rien à y faire soi-même.
52const collapsed = atom({ plugin: 'asana-board', key: 'collapsed' } as const, ['attente'])
53const who = atom({ plugin: 'asana-board', key: 'who' } as const, 'tous')
54const notice = atom({ plugin: 'asana-board', key: 'notice' } as const, null)
55const editing = atom({ plugin: 'asana-board', key: 'editing' } as const, null as string | null)
56const confirmDelete = atom({ plugin: 'asana-board', key: 'confirmDelete' } as const, null as string | null)
57const creating = atom({ plugin: 'asana-board', key: 'creating' } as const, false)
58const draft = atom({ plugin: 'asana-board', key: 'draft' } as const, EMPTY_DRAFT)
59// Bandeau masqué par « x » ou /asana hide : le temps de la session, comme flow-board.
60const isBandHidden = atom({ plugin: 'asana-board', key: 'isBandHidden' } as const, false)
61// Tâches (gid, ou « new » pour le formulaire) dont une écriture est en cours : un second appui attend.
62const busy = atom({ plugin: 'asana-board', key: 'busy' } as const, [] as string[])
63// Groupes dépliés en entier (« ▸ tout voir »).
64const showAll = atom({ plugin: 'asana-board', key: 'showAll' } as const, [] as string[])
65
66// Écritures en cours depuis le panneau : elles ne doivent pas relancer une lecture
67// complète comme celles de Claude.
68let ownWrites = 0
69// Secondes écoulées, pour le rythme des redessins.
70let ticks = 0
71// La lecture en cours, s'il y en a une : jamais deux lectures en même temps. Une variable
72// de module, et non un atom : un rechargement à chaud annule les attentes de l'ancien code,
73// elle repart donc à null avec lui au lieu de rester bloquée.
74let inflight: { dir: string; isQuiet: boolean; done: Promise<boolean> } | null = null
75
76/** Un effet d'interface raté (pas d'écran, surface sans panneau) ne doit jamais gêner le reste. */
77const quietly = async (effect: () => unknown) => {
78  try {
79    await effect()
80  } catch {
81    // Rien à afficher ici : l'état, lui, est déjà écrit.
82  }
83}
84
85const toast = ($: any, text: string) => quietly(() => $.ui.toast(text))
86
87const named = (t: AsanaTask) => {
88  const name = shortName(t.name)
89  return `« ${name.length > 40 ? `${name.slice(0, 39)}…` : name} »`
90}
91
92// ─── Lecture ────────────────────────────────────────────────────────────────
93
94const dirOf = async ($: any, folder: string | null): Promise<string> => String(folder ?? (await $.session.cwd())).replace(/[\\/]+$/, '')
95
96/**
97 * Lit la section et ses tâches ; vrai si la lecture a réussi. `isQuiet` : une lecture lancée
98 * sans action de la personne (démarrage, relecture après un outil de Claude). Son échec ne
99 * laisse aucune trace : ni erreur dans le panneau, ni toast, ni ligne d'état ; seule une
100 * lecture demandée (/asana, ↻) montre son erreur.
101 *
102 * Une lecture déjà en cours est attendue plutôt que doublée. Une lecture demandée ne repart
103 * ensuite que si celle-là n'a pas fait son travail : autre dossier, ou lecture de fond
104 * échouée en silence (il faut alors montrer l'erreur).
105 */
106async function load($: any, folder: string | null, isQuiet = false): Promise<boolean> {
107  const dir = await dirOf($, folder)
108  while (inflight) {
109    const running = inflight
110    const isOk = await running.done
111    if (isQuiet) return isOk
112    if (running.dir === dir && (isOk || !running.isQuiet)) return isOk
113  }
114  const done = readSection($, dir, isQuiet).catch(() => false)
115  inflight = { dir, isQuiet, done }
116  try {
117    return await done
118  } finally {
119    if (inflight?.done === done) inflight = null
120  }
121}
122
123let isMeKnown = false
124
125async function readSection($: any, dir: string, isQuiet: boolean): Promise<boolean> {
126  const say = (loading: string | null, extra: Partial<PanelMeta> = {}) => update($, meta, m => ({ ...m, ...extra, folder: dir, loading }))
127  let missed = 0
128  // Asana répond parfois 500 sous la charge : une pause, un second essai, puis on
129  // garde la tâche sans ses détails plutôt que de tout abandonner.
130  const tryTwice = async (tool: string, args: Record<string, unknown>) => {
131    try {
132      return await asana($, server!, tool, args)
133    } catch {
134      await $.clock.sleep(1500)
135      try {
136        return await asana($, server!, tool, args)
137      } catch {
138        missed += 1
139        return null
140      }
141    }
142  }
143  let server: string | null = null
144  try {
145    await say('Lecture du projet…',isQuiet ? {} : { error: null, warning: null })
146    const target = findAsana(await readProjectFiles($, dir))
147    if (!target) throw new Error(`Pas de section Asana dans ${dir} (${ASANA_FILES.join(', ')}).`)
148    server = await asanaServer($)
149    if (!server) throw new Error('Connecteur Asana introuvable dans cette session.')
150    // Le titulaire du compte, une fois par session : un échec laisse ses tâches sans « Toi ».
151    if (!isMeKnown) {
152      const who = await asana($, server, 'get_me', {}).catch(() => null)
153      if (who?.data?.name) {
154        setMe(who.data.name)
155        isMeKnown = true
156      }
157    }
158    await say('Lecture de la section…', { ...target, server })
159
160    // Les tâches terminées ne sont pas chargées : Asana rend les tâches non terminées
161    // plus celles terminées depuis `completed_since`, ici maintenant.
162    const top = await asana($, server, 'get_tasks', {
163      section: target.sectionGid,
164      completed_since: new Date(await $.clock.now()).toISOString(),
165      opt_fields: FIELDS,
166      limit: 100,
167    })
168    const all: AsanaTask[] = (top.data ?? []).map((t: any) => toTask(t, null))
169    // Les sous-tâches ne sont pas dans la section : on les lit sous chaque parent, sur deux niveaux.
170    let parents = all.filter(t => t.childCount > 0)
171    for (let depth = 0; depth < 2 && parents.length > 0; depth++) {
172      const children: { gid: string; name: string; parent: AsanaTask }[] = []
173      let p = 0
174      for (const parent of parents) {
175        p += 1
176        await say(`Lecture des sous-tâches… parent ${p} / ${parents.length}`)
177        const d = await tryTwice('get_task', { task_id: parent.gid, include_comments: false, opt_fields: 'subtasks.name,subtasks.completed' })
178        // Sous-tâches terminées écartées avant d'en lire le détail.
179        for (const s of d?.data?.subtasks ?? []) if (!s.completed) children.push({ gid: s.gid, name: s.name, parent })
180      }
181      let n = 0
182      const details = await pool(children, 4, async c => {
183        const d = await tryTwice('get_task', {
184          task_id: c.gid,
185          include_comments: false,
186          include_subtasks: false,
187          opt_fields: FIELDS,
188        })
189        n += 1
190        if (n % 5 === 0 || n === children.length) await say(`Lecture des sous-tâches… ${n} / ${children.length}`)
191        return toTask(d?.data ?? { gid: c.gid, name: c.name, notes: '_Détails illisibles : Asana a répondu une erreur. ↻ pour réessayer._' }, c.parent)
192      })
193      all.push(...details)
194      parents = details.filter(t => t.childCount > 0)
195    }
196    // Une tâche peut être à la fois dans la section et sous une tâche mère : une seule fois.
197    const seen = new Set<string>()
198    const unique = all.filter(t => !seen.has(t.gid) && seen.add(t.gid))
199    await update($, tasks, () => unique)
200    await say(null, {
201      loadedAt: await $.clock.now(),
202      error: null,
203      warning: missed > 0 ? `${missed} lecture(s) Asana en échec : ces tâches s'affichent sans leurs détails. ↻ pour réessayer.` : null,
204    })
205    await syncStatus($)
206    return true
207  } catch (err: any) {
208    // L'ancienne liste reste affichée : une erreur ne vide pas `tasks`.
209    await say(null, isQuiet ? {} : { error: String(err?.message ?? err) })
210    return false
211  }
212}
213
214/**
215 * Au démarrage, en arrière-plan : si le CLAUDE.md du dossier nomme une section Asana, une
216 * lecture silencieuse remplit le bandeau. Connecteur pas encore là : nouvel essai après
217 * 5 s, 15 s puis 45 s, puis abandon sans un mot. Pas de section : rien, aucun appel.
218 */
219async function warmUp($: any, cwd: string, attempt: number) {
220  try {
221    const dir = cwd.replace(/[\\/]+$/, '')
222    const m = await read($, meta)
223    // Une lecture tourne, ou a déjà réussi : rien à faire. Une lecture demandée sur un autre
224    // dossier fait foi. Une lecture demandée ratée sur ce dossier (connecteur pas encore là,
225    // par exemple) n'arrête pas les essais : le connecteur arrivé, le panneau se remplit.
226    if (inflight || m.loadedAt !== null || (m.folder !== null && m.folder !== dir)) return
227    if (!findAsana(await readProjectFiles($, dir))) return
228    const server = await asanaServer($).catch(() => null)
229    if (!server) {
230      const delay = RETRIES[attempt]
231      if (delay !== undefined) $.clock.after(delay, () => void warmUp($, cwd, attempt + 1))
232      return
233    }
234    await load($, dir, true)
235  } catch {
236    // Démarrage en arrière-plan : un échec ne se montre pas.
237  }
238}
239
240// Les fichiers où un projet nomme sa section Asana ; un fichier absent compte pour vide.
241async function readProjectFiles($: any, dir: string): Promise<string[]> {
242  return Promise.all(ASANA_FILES.map(f => $.fs.read(`${dir}/${f}`).then(String).catch(() => '')))
243}
244
245// Le connecteur Asana porte un nom propre à chaque compte : on le retrouve par ses outils.
246async function asanaServer($: any): Promise<string | null> {
247  const list: { name: string; mcp: boolean }[] = await $.tool.list()
248  const names = new Set(list.map(t => t.name))
249  const hit = list.find(t => t.mcp && t.name.endsWith('__get_tasks') && names.has(t.name.replace(/__get_tasks$/, '__update_tasks')))
250  return hit ? hit.name.slice('mcp__'.length, -'__get_tasks'.length) : null
251}
252
253async function asana($: any, server: string, tool: string, args: Record<string, unknown>) {
254  // En mode automatique, l'appel passe par le classifieur, qui ne juge que les
255  // demandes du modèle : il faut une règle permissions.allow pour l'outil.
256  const r = await $.mcp.call(server, tool, args)
257  let text = (r.content ?? []).map((c: any) => c.text ?? '').join('')
258  // Réponse trop longue : le moteur l'a rangée dans un fichier, on la relit là. Son message
259  // commence par « Error: » : il passe avant le test d'erreur, au cas où il serait marqué comme tel.
260  const saved = persistedPath(text)
261  if (saved) text = String(await $.fs.read(saved))
262  else if (r.isError) throw new Error(`Asana (${tool}) : ${text.slice(0, 200)}`)
263  if (!text.trim()) return {}
264  try {
265    return JSON.parse(text)
266  } catch {
267    throw new Error(`Asana (${tool}) : réponse illisible (${text.trim().slice(0, 80)}…)`)
268  }
269}
270
271async function pool<T, R>(items: T[], size: number, work: (item: T) => Promise<R>) {
272  const out: R[] = new Array(items.length)
273  let next = 0
274  const lane = async () => {
275    while (next < items.length) {
276      const i = next++
277      out[i] = await work(items[i]!)
278    }
279  }
280  await Promise.all(Array.from({ length: Math.min(size, items.length) }, lane))
281  return out
282}
283
284// ─── Bandeau masqué : la ligne d'état prend le relais ──────────────────────
285
286/** La ligne d'état sous le prompt ne sert que quand le bandeau est masqué. */
287async function syncStatus($: any) {
288  const hidden = await read($, isBandHidden)
289  const m = await read($, meta)
290  if (!hidden || m.loadedAt === null) {
291    await quietly(() => $.ui.status(undefined))
292    return
293  }
294  const counts = countsOf(await read($, tasks), await $.clock.now())
295  await quietly(() => $.ui.status(statusLine(m.section, counts)))
296}
297
298// ─── Écritures ──────────────────────────────────────────────────────────────
299
300/** Réserve une tâche pour une écriture ; faux si une écriture y est déjà en cours (double appui). */
301async function claim($: any, gid: string): Promise<boolean> {
302  let isMine = false
303  await update($, busy, ids => {
304    isMine = !ids.includes(gid)
305    return isMine ? [...ids, gid] : ids
306  })
307  return isMine
308}
309
310const release = ($: any, gid: string) => update($, busy, ids => ids.filter(id => id !== gid))
311
312// Une écriture du panneau : l'appel, la relecture de la tâche, puis un toast. Une erreur
313// reste aussi sous la tâche (le toast passe, le message reste lisible).
314async function act($: any, t: AsanaTask, done: string | (() => string), write: (server: string) => Promise<unknown>) {
315  const { server } = await read($, meta)
316  if (!server) return
317  if (!(await claim($, t.gid))) return toast($, `${named(t)} : écriture déjà en cours, attends sa fin`)
318  ownWrites += 1
319  try {
320    await write(server)
321    const d = await asana($, server, 'get_task', {
322      task_id: t.gid,
323      include_comments: false,
324      include_subtasks: false,
325      opt_fields: FIELDS,
326    })
327    const fresh = { ...toTask(d.data, null), parent: t.parent, parentName: t.parentName }
328    await update($, tasks, list => list.map(x => (x.gid === t.gid ? fresh : x)))
329    await update($, notice, n => (n?.gid === t.gid ? null : n))
330    // Le retour sous la tâche se perdrait quand elle change de groupe : un toast.
331    await toast($, `${named(t)} : ${typeof done === 'function' ? done() : done}`)
332  } catch (err: any) {
333    await failed($, t, err)
334  } finally {
335    ownWrites -= 1
336    await release($, t.gid)
337    await syncStatus($)
338  }
339}
340
341/** Le début lisible d'un message d'erreur : sans le JSON renvoyé par Asana, 80 caractères au plus. */
342const brief = (text: string): string => {
343  const head = text.split(/\s[{[]/)[0]!.replace(/\s*:\s*$/, '').trim()
344  return head.length > 80 ? `${head.slice(0, 79)}…` : head
345}
346
347/**
348 * Une écriture refusée : le détail complet reste sous la tâche, dépliée pour qu'il se voie ;
349 * le toast, fait pour une phrase courte, n'en garde que le début.
350 */
351async function failed($: any, t: AsanaTask, err: any) {
352  const text = String(err?.message ?? err)
353  const head = brief(text)
354  await update($, notice, () => ({ gid: t.gid, text, isError: true }))
355  await update($, expanded, ids => (ids.includes(t.gid) ? ids : [...ids, t.gid]))
356  await toast($, `${named(t)} : ${head}${head === text ? '' : ' (détail sous la tâche)'}`)
357}
358
359// update_tasks répond même quand la modification échoue : on lit « failed ».
360async function writeTask($: any, server: string, change: Record<string, unknown>) {
361  const r = await asana($, server, 'update_tasks', { tasks: [change] })
362  if (r?.failed?.length) throw new Error(`Asana a refusé : ${JSON.stringify(r.failed[0]?.errors ?? r.failed[0]).slice(0, 200)}`)
363  return r
364}
365
366// La description relue juste avant d'écrire : elle a pu changer depuis la lecture du panneau.
367// Le panneau écrit du texte brut : une description mise en forme dans Asana (gras,
368// listes…) perdrait sa forme, donc on refuse plutôt que de l'aplatir.
369async function freshNotes($: any, server: string, gid: string): Promise<string> {
370  const d = await asana($, server, 'get_task', { task_id: gid, include_comments: false, include_subtasks: false, opt_fields: 'notes,html_notes' })
371  if (/<(strong|em|u|s|code|ul|ol|li|h1|h2|blockquote|pre|table|a|img)\b/.test(d.data?.html_notes ?? '')) {
372    throw new Error('Description mise en forme dans Asana : modifie-la dans Asana pour ne pas perdre la mise en forme.')
373  }
374  return d.data?.notes ?? ''
375}
376
377/**
378 * La coche et la ligne « Statut : » dans la même écriture. Une description mise en forme dans
379 * Asana ne se réécrit pas (voir `freshNotes`) : la tâche est cochée quand même, sans son statut.
380 */
381async function closingChange($: any, server: string, t: AsanaTask, completed: boolean, value: string) {
382  try {
383    return { change: { task: t.gid, completed, notes: withWholeStatut(await freshNotes($, server, t.gid), value) }, note: '' }
384  } catch (err: any) {
385    if (!/mise en forme/.test(String(err?.message))) throw err
386    return { change: { task: t.gid, completed }, note: ' (statut inchangé : description mise en forme dans Asana)' }
387  }
388}
389
390function writeDone($: any, t: AsanaTask, completed: boolean) {
391  let note = ''
392  return act($, t, () => `${completed ? 'cochée ✓' : 'décochée'}${note}`, async server => {
393    const closing = await closingChange($, server, t, completed, completed ? 'fait' : 'à faire')
394    note = closing.note
395    await writeTask($, server, closing.change)
396  })
397}
398
399function writeStatut($: any, t: AsanaTask, value: string) {
400  // Une valeur « =… » décrit le statut actuel (prêt, sans statut) : rien à écrire.
401  if (value.startsWith('=')) return
402  return act($, t, `statut : ${value}`, async server => {
403    await writeTask($, server, { task: t.gid, notes: withStatut(await freshNotes($, server, t.gid), value) })
404  })
405}
406
407function writeAssignee($: any, t: AsanaTask, assignee: string | null) {
408  return act($, t, assignee ? 'assignée à toi' : 'confiée à Claude (non assignée)', server => writeTask($, server, { task: t.gid, assignee }))
409}
410
411function writeComment($: any, t: AsanaTask, text: string) {
412  if (!text.trim()) return
413  return act($, t, 'commentaire ajouté', server => asana($, server, 'add_comment', { task_id: t.gid, text: text.trim() }))
414}
415
416function writeName($: any, t: AsanaTask, name: string) {
417  if (!name.trim() || name.trim() === t.name) return
418  return act($, t, 'titre enregistré', server => writeTask($, server, { task: t.gid, name: name.trim() }))
419}
420
421function writeDue($: any, t: AsanaTask, text: string) {
422  return act($, t, 'échéance enregistrée', server => writeTask($, server, { task: t.gid, due_on: isoDate(text) }))
423}
424
425// Remplace une ligne de la description (null la retire), retrouvée telle qu'affichée.
426function writeLine($: any, t: AsanaTask, before: string, after: string | null) {
427  if (after === before) return
428  return act($, t, after === null ? 'ligne retirée' : 'ligne enregistrée', async server => {
429    const lines = (await freshNotes($, server, t.gid)).split('\n')
430    const i = lines.indexOf(before)
431    if (i < 0) throw new Error('La description a changé dans Asana : ↻ puis recommence.')
432    if (after === null) lines.splice(i, 1)
433    else lines[i] = after
434    await writeTask($, server, { task: t.gid, notes: lines.join('\n') })
435  })
436}
437
438function appendLine($: any, t: AsanaTask, text: string) {
439  if (!text.trim()) return
440  return act($, t, 'ligne ajoutée', async server => {
441    const notes = (await freshNotes($, server, t.gid)).replace(/\s+$/, '')
442    await writeTask($, server, { task: t.gid, notes: notes ? `${notes}\n${text.trim()}` : text.trim() })
443  })
444}
445
446// Abandonner garde une trace : la tâche est cochée avec un commentaire.
447function writeAbandon($: any, t: AsanaTask) {
448  let note = ''
449  return act($, t, () => `abandonnée (cochée, avec un commentaire)${note}`, async server => {
450    const closing = await closingChange($, server, t, true, 'abandonnée')
451    note = closing.note
452    await asana($, server, 'add_comment', { task_id: t.gid, text: 'Abandonnée depuis le panneau Asana de Claude Code.' })
453    await writeTask($, server, closing.change)
454  })
455}
456
457async function deleteTask($: any, t: AsanaTask) {
458  const { server } = await read($, meta)
459  if (!server) return
460  if (!(await claim($, t.gid))) return toast($, `${named(t)} : écriture déjà en cours, attends sa fin`)
461  ownWrites += 1
462  try {
463    await asana($, server, 'delete_task', { task: t.gid })
464    await update($, tasks, list => list.filter(x => x.gid !== t.gid && x.parent !== t.gid))
465    await update($, confirmDelete, () => null)
466    await toast($, `Tâche supprimée : ${named(t)}`)
467  } catch (err: any) {
468    await failed($, t, err)
469  } finally {
470    ownWrites -= 1
471    await release($, t.gid)
472    await syncStatus($)
473  }
474}
475
476// Crée la tâche du formulaire, au format du skill asana-board:suivi-asana.
477async function createTask($: any) {
478  const d = await read($, draft)
479  const m = await read($, meta)
480  const fail = (text: string) => update($, notice, () => ({ gid: 'new', text, isError: true }))
481  if (!m.server) return
482  if (!d.name.trim()) return fail('Le titre est obligatoire.')
483  let due: string | null
484  try {
485    due = isoDate(d.due)
486  } catch (err: any) {
487    return fail(String(err.message))
488  }
489  const notes = [
490    `Statut : ${d.statut}`,
491    d.contexte.trim() && `Contexte : ${d.contexte.trim()}`,
492    d.fini.trim() && `Fini quand : ${d.fini.trim()}`,
493    'Origine : panneau Asana (Claude Code)',
494  ]
495    .filter(Boolean)
496    .join('\n')
497  const name = /^\[/.test(d.name.trim()) ? d.name.trim() : `[code] ${d.name.trim()}`
498  const task: Record<string, unknown> = { name, notes }
499  if (d.who === 'toi') task.assignee = 'me'
500  if (due) task.due_on = due
501  if (d.where === 'section') {
502    if (!m.projectGid || !m.sectionGid) return fail('Projet ou section introuvable dans le CLAUDE.md.')
503    task.project_id = m.projectGid
504    task.section_id = m.sectionGid
505  } else {
506    task.parent = d.where
507  }
508  if (!(await claim($, 'new'))) return toast($, 'Création déjà en cours, attends sa fin')
509  ownWrites += 1
510  try {
511    // Pas de champ personnalisé : le plan gratuit les refuse (references/conventions.md du
512    // skill asana-board:suivi-asana). create_tasks rend les tâches créées dans `data` et les refus dans `failed`.
513    const created = (res: any): any[] => res?.data ?? res?.succeeded ?? []
514    const r = await asana($, m.server, 'create_tasks', { tasks: [task] })
515    if (!created(r).length) throw new Error(`Asana a refusé : ${JSON.stringify(r?.failed?.[0] ?? r).slice(0, 200)}`)
516    const gid = created(r)[0]?.gid ?? created(r)[0]?.task?.gid
517    if (gid) {
518      const fresh = await asana($, m.server, 'get_task', { task_id: gid, include_comments: false, include_subtasks: false, opt_fields: FIELDS })
519      const parent = d.where === 'section' ? null : ((await read($, tasks)).find(x => x.gid === d.where) ?? null)
520      await update($, tasks, list => [...list, toTask(fresh.data, parent)])
521      await update($, expanded, ids => [...ids, gid])
522    }
523    await update($, draft, () => EMPTY_DRAFT)
524    await update($, creating, () => false)
525    await update($, notice, n => (n?.gid === 'new' ? null : n))
526    await toast($, `Tâche créée ✓ : « ${name.replace(/^\[(code|chat)\]\s*/, '')} »`)
527  } catch (err: any) {
528    const text = String(err?.message ?? err)
529    await fail(text)
530    // Le détail reste sous le formulaire, qui reste ouvert ; le toast n'en garde que le début.
531    const head = brief(text)
532    await toast($, `Création refusée : ${head}${head === text ? '' : ' (détail sous le formulaire)'}`)
533  } finally {
534    ownWrites -= 1
535    await release($, 'new')
536    await syncStatus($)
537  }
538}
539
540/** Remplit le prompt sans l'envoyer : rien ne part sans la personne. */
541async function fillPrompt($: any, t: AsanaTask) {
542  const text = `Travaille sur la tâche Asana « ${shortName(t.name)} » (gid ${t.gid}). Lis-la d'abord dans Asana.`
543  let isFilled = false
544  await quietly(async () => {
545    isFilled = (await $.prompt.fill({ text, mode: 'replace' }))?.isFilled === true
546  })
547  // Repli actionnable : la demande elle-même, à taper.
548  await toast($, isFilled ? 'La demande est dans le prompt : Entrée pour lancer' : `Prompt indisponible. Tape : ${text}`)
549}
550
551// ─── Vue et actions ─────────────────────────────────────────────────────────
552
553const readView = async ($: any): Promise<View> => ({
554  tasks: await read($, tasks),
555  meta: await read($, meta),
556  who: await read($, who),
557  expanded: await read($, expanded),
558  collapsed: await read($, collapsed),
559  showDone: await read($, showDone),
560  showAll: await read($, showAll),
561  notice: await read($, notice),
562  editing: await read($, editing),
563  confirmDelete: await read($, confirmDelete),
564  creating: await read($, creating),
565  draft: await read($, draft),
566  busy: await read($, busy),
567  now: await $.clock.now(),
568})
569
570// Le bandeau ne lit que ce qu'il dessine : la chaîne des bandeaux est partagée par les mods.
571const readBand = async ($: any, columns: number): Promise<BandView> => ({
572  columns,
573  tasks: await read($, tasks),
574  meta: await read($, meta),
575  isBandHidden: await read($, isBandHidden),
576  now: await $.clock.now(),
577})
578
579const flip = (ids: string[], id: string) => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
580
581const openPane = ($: any) => quietly(() => $.ui.open({ id: PANE, title: PANE_TITLE }))
582
583const actionsFor = ($: any): Actions => ({
584  setWho: (value: string) => update($, who, () => value),
585  flipTask: (gid: string) => update($, expanded, ids => flip(ids, gid)),
586  flipGroup: (key: string) => update($, collapsed, ids => flip(ids, key)),
587  flipDone: () => update($, showDone, v => !v),
588  flipAll: (key: string) => update($, showAll, ids => flip(ids, key)),
589  toggleCreating: () => update($, creating, v => !v),
590  setDraft: (patch: Partial<Draft>) => update($, draft, x => ({ ...x, ...patch })),
591  // Annuler vide le brouillon : la prochaine ouverture repart d'un formulaire neuf.
592  cancelCreate: async () => {
593    await update($, draft, () => EMPTY_DRAFT)
594    await update($, creating, () => false)
595    await update($, notice, n => (n?.gid === 'new' ? null : n))
596  },
597  create: () => createTask($),
598  // Pendant une lecture, ↻ l'attend au lieu d'en lancer une seconde (voir `load`).
599  refresh: async () => {
600    await load($, (await read($, meta)).folder)
601  },
602  setDone: (t, completed) => writeDone($, t, completed),
603  setStatut: (t, value) => writeStatut($, t, value),
604  assign: (t, assignee) => writeAssignee($, t, assignee),
605  work: t => fillPrompt($, t),
606  comment: (t, text) => writeComment($, t, text),
607  startEdit: (gid: string) => update($, editing, () => gid),
608  endEdit: () => update($, editing, () => null),
609  rename: (t, name) => writeName($, t, name),
610  setDue: (t, text) => writeDue($, t, text),
611  editLine: (t, before, after) => writeLine($, t, before, after),
612  addLine: (t, text) => appendLine($, t, text),
613  abandon: t => writeAbandon($, t),
614  askDelete: (gid: string) => update($, confirmDelete, () => gid),
615  cancelDelete: () => update($, confirmDelete, () => null),
616  remove: t => deleteTask($, t),
617  hideBand: async () => {
618    await update($, isBandHidden, () => true)
619    await syncStatus($)
620    await toast($, 'Bandeau Asana masqué : /asana show pour le réafficher')
621  },
622  openPane: () => openPane($),
623})
624
625/** Redessine pour faire avancer les âges et le spinner : 5 s pendant une activité, 30 s au repos. */
626const tick = async ($: any) => {
627  ticks += 1
628  const m = await read($, meta)
629  if (m.loadedAt === null && !m.loading) return
630  const isBusy = m.loading !== null || (await read($, busy)).length > 0
631  if (ticks % (isBusy ? 5 : 30) === 0) $.ui.invalidate('ui.render')
632}
633
634// `/asana` n'existe qu'après le démarrage du mod ; `/asana-board:asana`, déclarée par
635// `commands/asana.md`, est connue dès le premier message. Les deux reçoivent la même
636// réponse, sans passer par le modèle. Rien ici ne suppose que session.start a tourné.
637const answer = async ($: any, e: { args?: string }) => {
638  // Un autre plugin peut lancer la commande sans `args` ; tapée, elle reçoit "".
639  const raw = (e.args ?? '').trim()
640  const arg = raw.toLowerCase()
641  if (arg === 'hide') {
642    await update($, isBandHidden, () => true)
643    await syncStatus($)
644    return { text: 'Bandeau Asana masqué (/asana show pour le réafficher).' }
645  }
646  if (arg === 'show') await update($, isBandHidden, () => false)
647  if (TABS.includes(arg)) await update($, who, () => arg)
648  const isReserved = arg === 'show' || TABS.includes(arg)
649  const folder = (!isReserved && raw) || (await read($, meta)).folder || (await $.session.cwd())
650  await openPane($)
651  await syncStatus($)
652  // Lecture demandée : son erreur s'affiche. La réponse attend la fin de la lecture (une
653  // lecture de fond déjà en cours est attendue, pas doublée).
654  await load($, folder)
655  const m = await read($, meta)
656  return { text: m.error ? `Panneau Asana : ${m.error}` : `Panneau Asana ouvert (${m.section}).` }
657}
658
659/**
660 * Une lecture si longue que le hook dépasse son budget (les pauses entre deux essais y
661 * comptent) : sans ce repli, le moteur passerait la main dessous, et pour
662 * `/asana-board:asana` le modèle lirait le corps de `commands/asana.md`, « le mod n'est pas
663 * chargé », ce qui est faux. Le contrat ne dit pas si la lecture survit au hook abandonné :
664 * le texte ne le promet pas, et ↻ la relance (elle n'est pas gardée par `meta.loading`).
665 */
666const answerLate = async (_$: any, _e: unknown, next: any) => ({
667  text:
668    next.error?.kind === 'timeout'
669      ? 'Lecture Asana trop longue pour attendre ici : le panneau se remplit s’il la termine, sinon ↻ Relire.'
670      : `Panneau Asana : ${next.error?.message ?? 'erreur inattendue'}`,
671})
672
673// `/asana` reste utilisable tapée en entier, mais sort du menu : `/asana-board:asana`, déjà
674// là avant le premier message, y suffit et le doublon disparaît.
675const hideDuplicate = async ($: any, e: any, next: any) => ({ ...(await next(e)), isHidden: true })
676
677// ─── Hooks ──────────────────────────────────────────────────────────────────
678
679export const register: Register = on => {
680  on('session.start', async ($, e, next) => {
681    // Après un rechargement à chaud, une écriture ou une lecture coupée en route laisse son
682    // atom figé (son `finally` ne tournera plus) : on le libère. Les variables de module,
683    // elles, repartent à zéro avec le nouveau code ; tant qu'elles disent que rien ne tourne
684    // ici, ce qui reste dans les atoms vient de l'ancien.
685    if (ownWrites === 0) await update($, busy, () => [])
686    if (!inflight) await update($, meta, m => (m.loading ? { ...m, loading: null } : m))
687    // Un enregistrement refusé ne doit pas empêcher la suite (minuterie, lecture de fond).
688    await quietly(() =>
689      $.command.register({
690        name: 'asana',
691        description: 'Panneau des tâches Asana du projet : /asana [tous | toi | claude | hide | show | dossier]',
692        argumentHint: '[tous|toi|claude|hide|show|dossier du projet]',
693      }),
694    )
695    $.clock.every(1000, () => void tick($))
696    // Jamais attendu ici : sur le desktop, la lecture tourne pendant le premier tour.
697    $.clock.after(0, () => void warmUp($, e.cwd, 0))
698    return next(e)
699  })
700
701  on('command.run', { command: 'asana' }, answer).catch(answerLate)
702  on('command.describe', { command: 'asana' }, hideDuplicate)
703  on('command.run', { command: STATIC_ASANA }, answer).catch(answerLate)
704
705  // Quand Claude modifie Asana pendant la discussion, le panneau se relit (en silence).
706  on('tool.call', async ($, e, next) => {
707    const result = await next(e)
708    if (ownWrites === 0 && WRITE_TOOLS.test(e.tool)) {
709      const m = await read($, meta)
710      if (m.folder && m.server && !inflight) $.clock.after(0, () => load($, m.folder, true))
711    }
712    return result
713  })
714
715  // Un appel d'écriture de Claude dans Asana : une ligne courte avec le nom des tâches, lu
716  // dans celles déjà chargées (aucune relecture), à la place des GID et du JSON. Le résultat
717  // (ToolResult) reste celui du moteur. Le nom de l'outil est testé avant toute lecture
718  // d'état : chaque ligne d'outil de la session passe par ici, seules celles d'Asana s'abonnent.
719  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
720    const p = e.props
721    if (!WRITE_TOOLS.test(p.tool) || p.isErrored || p.isInterrupted) return next(e)
722    const names = new Map((await read($, tasks)).map(t => [t.gid, t.name] as const))
723    const text = describeWrite(p.tool, p.input, names)
724    if (text === null) return next(e)
725    const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.viewport?.columns ?? 80, abilitiesOf(e.surface))
726    return drawWrite(kit, text, p.isRunning) as any
727  })
728
729  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
730    const view = await readView($)
731    const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, abilitiesOf(e.surface))
732    return drawPane(kit, view, actionsFor($)) as any
733  })
734
735  // Le bandeau se compose avec ceux des autres mods (flow-board, git-board…) : le leur
736  // reste au-dessus, celui d'Asana dessous. Jamais le nôtre seul quand un autre est là.
737  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
738    if (e.props.hasSurvey) return next(e)
739    const view = await readBand($, e.props.bodyColumns)
740    if (view.meta.loadedAt === null || view.isBandHidden) return next(e)
741    const below = await next(e)
742    const kit = kitOf($.ui.resolve(e) as unknown as Record<string, unknown>, e.props.bodyColumns, abilitiesOf(e.surface))
743    const own = drawBand(kit, view, actionsFor($)) as any
744    if (!own) return below
745    if (below.type === 'engine') return own
746    const { Box } = kit
747    return (
748      <Box flexDirection="column" width={e.props.bodyColumns}>
749        {below}
750        {own}
751      </Box>
752    )
753  })
754}
755
hooks/model.ts 357 lines
1// Données du panneau Asana, sans `$` : lecture des tâches renvoyées par le connecteur,
2// rangement par la ligne « Statut : », comptes du bandeau et de l'en-tête, échéances.
3// Tout ce qui touche à Asana ou à l'état passe par register.tsx.
4
5import type { AsanaTask } from '../types'
6
7// Couleurs brutes (hex) mi-tons, copiées de flow-board (hooks/catalog.ts:9-31) : lisibles
8// en thème clair comme sombre, sur le terminal comme sur le desktop.
9export const C = {
10  violet: '#9B7BEA',
11  lilac: '#B79BF5',
12  blue: '#4A9EE8',
13  cyan: '#2EB8C9',
14  teal: '#3FB68B',
15  tangerine: '#E8913A',
16  coral: '#EF7D57',
17  amber: '#D9A520',
18  green: '#4CB782',
19  red: '#E5534B',
20  pink: '#E06C9F',
21  magenta: '#C36BD9',
22  indigo: '#7C83F2',
23  sky: '#56B4E9',
24  sand: '#C9A66B',
25  slate: '#7F8EA3',
26  gray: '#8B8B93',
27  /** Texte foncé posé sur un aplat de couleur. */
28  ink: '#17171C',
29} as const
30
31/** La couleur du mod : celle d'Asana (le violet reste celle de flow-board, pour distinguer les bandeaux empilés). */
32export const BRAND = C.coral
33
34export const FIELDS = 'name,completed,assignee.name,notes,due_on,modified_at,num_subtasks,permalink_url'
35// Les fichiers du projet où chercher la section, dans l'ordre : un projet peut garder ses GID
36// hors du CLAUDE.md (PolePredict les tient dans docs/agents/issue-tracker.md).
37export const ASANA_FILES = ['CLAUDE.md', 'docs/agents/issue-tracker.md'] as const
38// Dans le texte : « section « Mon projet » (GID `1200000000000002`) », retours à la ligne compris.
39export const SECTION = /section\s*«\s*([^»]+?)\s*»\s*\(GID\s*`(\d+)`\)/
40export const PROJECT = /projet\s*«\s*[^»]+?\s*»\s*\(GID\s*`(\d+)`\)/
41// En tableau : « | Section | « Mon projet », GID `1200000000000002` | ».
42const SECTION_ROW = /\|\s*Section\s*\|\s*«\s*([^»]+?)\s*»\s*,\s*GID\s*`(\d+)`/i
43const PROJECT_ROW = /\|\s*Projet\s*\|\s*«\s*[^»]+?\s*»\s*,\s*GID\s*`(\d+)`/i
44
45export type AsanaTarget = { section: string; sectionGid: string; projectGid: string | null }
46
47/**
48 * La section Asana du projet, lue dans les textes des fichiers (`ASANA_FILES`, dans l'ordre) :
49 * la première section trouvée, et le projet du même fichier, sinon du premier qui en nomme un.
50 */
51export function findAsana(texts: string[]): AsanaTarget | null {
52  const sectionOf = (s: string) => s.match(SECTION) ?? s.match(SECTION_ROW)
53  const projectOf = (s: string) => (s.match(PROJECT) ?? s.match(PROJECT_ROW))?.[1] ?? null
54  const i = texts.findIndex(s => sectionOf(s) !== null)
55  if (i < 0) return null
56  const found = sectionOf(texts[i]!)!
57  const projectGid = projectOf(texts[i]!) ?? texts.map(projectOf).find(g => g !== null) ?? null
58  return { section: found[1]!, sectionGid: found[2]!, projectGid }
59}
60
61/**
62 * Le fichier où Claude Code a rangé une réponse d'outil trop longue, ou null. `$.mcp.call`
63 * rend le texte que voit le modèle : au-delà d'un seuil (une section de 22 tâches, 55 Ko, le
64 * 14/10/2026), c'est un encart « <persisted-output> Output too large (…). Full output saved
65 * to: <chemin> » suivi d'un aperçu, et non plus le JSON d'Asana. Second format, vu le
66 * 16/10/2026 (74 Ko) : « Error: result (… characters) exceeds maximum allowed tokens. Output
67 * has been saved to <chemin>. » suivi de conseils de lecture ; le point final n'est pas au chemin.
68 */
69export function persistedPath(text: string): string | null {
70  const head = text.trimStart()
71  if (head.startsWith('<persisted-output>')) return text.match(/Full output saved to:\s*(.+?)\s*$/m)?.[1] ?? null
72  if (/^Error: result \([^)]*\) exceeds maximum allowed tokens/.test(head)) return head.match(/Output has been saved to\s+(.+?)\.?\s*$/m)?.[1] ?? null
73  return null
74}
75
76// Le titulaire du compte Asana, lu une fois par get_me : ses tâches s'affichent « Toi ».
77// Tant qu'il est inconnu, aucune tâche n'est la sienne.
78let me: string | null = null
79export function setMe(name: string | null): void {
80  me = name
81}
82export const isMe = (assignee: string | null): boolean => assignee !== null && assignee === me
83
84export function toTask(t: any, parent: AsanaTask | null): AsanaTask {
85  const notes: string = t.notes ?? ''
86  return {
87    gid: t.gid,
88    name: t.name ?? '',
89    completed: Boolean(t.completed),
90    assignee: t.assignee?.name ?? null,
91    notes,
92    statut: notes.match(/^\s*Statut\s*:\s*(.+)$/m)?.[1]?.trim() ?? null,
93    due: t.due_on ?? null,
94    modified: t.modified_at ?? null,
95    url: t.permalink_url ?? null,
96    parent: parent?.gid ?? null,
97    parentName: parent?.name ?? null,
98    childCount: t.num_subtasks ?? 0,
99  }
100}
101
102// ─── Statut et saisie ─────────────────────────────────────────────────────
103
104export const STATUTS = ['à faire', 'en cours', 'en attente', 'à trancher', 'à confirmer', 'à proposer']
105// Le mot-clé de tête de la ligne « Statut : », seul remplacé : la parenthèse et la suite restent.
106export const STATUT_LINE = /^(\s*Statut\s*:\s*)([àa] faire|en cours|en attente|[àa] trancher|[àa] confirmer|[àa] proposer|pr[êe]t|fait|abandonn[ée]e)?/im
107
108export function statutKeyword(t: AsanaTask) {
109  const kw = t.statut?.toLowerCase().replace(/^a /, 'à ')
110  return STATUTS.find(s => kw?.startsWith(s))
111}
112
113export function withStatut(notes: string, value: string) {
114  if (!/^\s*Statut\s*:/im.test(notes)) return `Statut : ${value}\n${notes}`
115  return notes.replace(STATUT_LINE, (_all, head: string, kw?: string) => (kw ? `${head}${value}` : `${head}${value} · `))
116}
117
118// Cochée, décochée ou abandonnée : toute la ligne est remplacée, la parenthèse (qui ou quoi
119// on attendait) ne vaut plus.
120export function withWholeStatut(notes: string, value: string) {
121  if (!/^\s*Statut\s*:/im.test(notes)) return notes ? `Statut : ${value}\n${notes}` : `Statut : ${value}`
122  return notes.replace(/^(\s*Statut\s*:\s*).*$/im, (_all, head: string) => `${head}${value}`)
123}
124
125/**
126 * Les choix du Select de statut et la valeur à montrer, jamais `undefined` : une tâche
127 * « prêt » ou sans statut reçoit une option de plus, qui la décrit sans rien écrire.
128 */
129export function statutChoices(t: AsanaTask): { options: { value: string; label: string }[]; value: string } {
130  const options = STATUTS.map(s => ({ value: s, label: s }))
131  const kw = statutKeyword(t)
132  if (kw) return { options, value: kw }
133  const current = t.statut ? (/^pr[êe]t/i.test(t.statut) ? 'prêt' : t.statut.split(/[\s(·]/)[0] || 'autre') : 'sans statut'
134  const value = `=${current}`
135  return { options: [{ value, label: current }, ...options], value }
136}
137
138// « Contexte : … » → « Contexte » ; une ligne sans étiquette → null.
139export function labelOf(line: string) {
140  return line.match(/^\s*([A-ZÀ-Ý][^:]{0,40}?)\s*:\s/)?.[1] ?? null
141}
142
143// « 12/10/2026 » ou « 12/10 » (année en cours) → « 2026-10-12 » ; vide → null.
144export function isoDate(text: string) {
145  const v = text.trim()
146  if (!v) return null
147  const d = v.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{4}))?$/)
148  if (!d) throw new Error('Échéance attendue au format JJ/MM/AAAA.')
149  const year = d[3] ?? String(new Date().getFullYear())
150  return `${year}-${d[2]!.padStart(2, '0')}-${d[1]!.padStart(2, '0')}`
151}
152
153// ─── Rangement ────────────────────────────────────────────────────────────
154
155export type GroupKey = 'trancher' | 'cours' | 'attente' | 'faire' | 'autre'
156
157export type Group = {
158  key: GroupKey
159  title: string
160  short: string
161  glyph: string
162  color: string
163  /** Rien à y faire soi-même : atténué dans le bandeau. */
164  isQuiet: boolean
165  match: RegExp
166}
167
168// Rôles de la palette : ambre pour ce qui attend une décision, mandarine pour ce qui avance,
169// gris pour l'attente d'autres, bleu ciel pour ce qui est prêt à faire.
170export const GROUPS: Group[] = [
171  { key: 'trancher', title: 'À trancher', short: 'à trancher', glyph: '⚖', color: C.amber, isQuiet: false, match: /^([àa] trancher|[àa] confirmer|[àa] proposer)/i },
172  { key: 'cours', title: 'En cours', short: 'en cours', glyph: '◉', color: C.tangerine, isQuiet: false, match: /^en cours/i },
173  { key: 'attente', title: "En attente d'autres", short: 'en attente', glyph: '⏸', color: C.gray, isQuiet: true, match: /^en attente/i },
174  { key: 'faire', title: 'À faire', short: 'à faire', glyph: '○', color: C.sky, isQuiet: false, match: /^([àa] faire|pr[êe]t)/i },
175  { key: 'autre', title: 'Sans statut', short: 'sans statut', glyph: '·', color: C.slate, isQuiet: false, match: /.*/ },
176]
177
178export function groupOf(t: AsanaTask): GroupKey {
179  return GROUPS.find(g => g.match.test(t.statut ?? ''))!.key
180}
181
182export function groupBy(key: string): Group {
183  return GROUPS.find(g => g.key === key) ?? GROUPS[GROUPS.length - 1]!
184}
185
186/**
187 * L'ordre des comptes (bandeau, légende de l'en-tête, ligne d'état) : ce qui demande d'agir,
188 * puis les tâches sans statut, et l'attente d'autres, atténuée, en dernier. Les groupes du
189 * panneau gardent l'ordre de GROUPS.
190 */
191export const COUNT_ORDER: Group[] = (['trancher', 'cours', 'faire', 'autre', 'attente'] as const).map(groupBy)
192
193// Une tâche mère sans statut n'est qu'un dossier : ses sous-tâches sont listées à sa place.
194export function isShown(t: AsanaTask) {
195  return !(t.childCount > 0 && t.statut === null)
196}
197
198export function shortName(name: string) {
199  // [code] (Claude Code), [chat] (claude.ai) : la source, inutile dans le panneau.
200  return name.replace(/^\[(code|chat)\]\s*/, '')
201}
202
203export function whoOf(t: AsanaTask) {
204  if (!t.assignee) return 'Claude'
205  return isMe(t.assignee) ? 'Toi' : t.assignee.split(' ')[0]!
206}
207
208export function matchesWho(t: AsanaTask, person: string) {
209  if (person === 'toi') return isMe(t.assignee)
210  if (person === 'claude') return t.assignee === null
211  return true
212}
213
214export type Due = { text: string; color: string; days: number }
215
216// Échéance : rouge si dépassée ou sous 2 jours, ambre sous 7 jours, gris au-delà.
217export function dueOf(t: AsanaTask, now: number): Due | null {
218  if (!t.due || t.completed) return null
219  const days = Math.ceil((Date.parse(`${t.due}T23:59:59`) - now) / 86_400_000)
220  const text = `${t.due.slice(8, 10)}/${t.due.slice(5, 7)}${days < 0 ? '\u00a0dépassée' : days <= 7 ? `\u00a0J-${days}` : ''}`
221  return { text, color: days <= 2 ? C.red : days <= 7 ? C.amber : C.gray, days }
222}
223
224/** Dans un groupe : l'échéance la plus proche d'abord, sans échéance ensuite, puis par titre. */
225export function byDueThenName(a: AsanaTask, b: AsanaTask) {
226  if (a.due !== b.due) {
227    if (a.due === null) return 1
228    if (b.due === null) return -1
229    return a.due < b.due ? -1 : 1
230  }
231  return shortName(a.name).localeCompare(shortName(b.name), 'fr')
232}
233
234// Les descriptions Asana sont du texte brut : une ligne = un paragraphe, et les
235// étiquettes de tête (« Contexte : », « Fini quand : ») en gras.
236export function notesMarkdown(notes: string) {
237  if (!notes.trim()) return '_Pas de description._'
238  return notes
239    .split(/\n+/)
240    .map(line => line.replace(/^([A-ZÀ-Ý][^:\n]{0,40}?)\s*:\s/, '**$1 :** '))
241    .join('\n\n')
242}
243
244// ─── Comptes ──────────────────────────────────────────────────────────────
245
246export type Counts = {
247  /** Tâches ouvertes (non cochées) par groupe. */
248  byGroup: Record<GroupKey, number>
249  open: number
250  /** Cochées depuis la dernière lecture. */
251  done: number
252  overdue: number
253  mine: number
254  forClaude: number
255}
256
257/** Les comptes d'une liste de tâches (déjà filtrée ou non : c'est à l'appelant de le dire). */
258export function countsOf(list: readonly AsanaTask[], now: number): Counts {
259  const byGroup: Record<GroupKey, number> = { trancher: 0, cours: 0, attente: 0, faire: 0, autre: 0 }
260  let open = 0
261  let done = 0
262  let overdue = 0
263  let mine = 0
264  let forClaude = 0
265  for (const t of list) {
266    if (!isShown(t)) continue
267    if (t.completed) {
268      done += 1
269      continue
270    }
271    open += 1
272    byGroup[groupOf(t)] += 1
273    if ((dueOf(t, now)?.days ?? 0) < 0) overdue += 1
274    if (isMe(t.assignee)) mine += 1
275    if (t.assignee === null) forClaude += 1
276  }
277  return { byGroup, open, done, overdue, mine, forClaude }
278}
279
280/** La tâche ouverte à l'échéance la plus proche (une dépassée d'abord), ou null. */
281export function nextDue(list: readonly AsanaTask[]): AsanaTask | null {
282  const dated = list.filter(t => isShown(t) && !t.completed && t.due !== null)
283  return dated.sort(byDueThenName)[0] ?? null
284}
285
286/** « ◆ Section · 2 à trancher · 1 en cours · ⚠ 1 en retard », pour la ligne d'état. */
287export function statusLine(section: string | null, counts: Counts): string {
288  // Les tâches sans statut comptent aussi : sinon une section qui n'a qu'elles dirait « aucune tâche ouverte ».
289  const parts = COUNT_ORDER.filter(g => counts.byGroup[g.key] > 0).map(g => `${counts.byGroup[g.key]} ${g.short}`)
290  if (counts.overdue > 0) parts.push(`⚠ ${counts.overdue} en retard`)
291  if (parts.length === 0) parts.push('aucune tâche ouverte')
292  return `◆ ${section ?? 'Asana'} · ${parts.join(' · ')}`
293}
294
295// ─── Écritures de Claude, lisibles dans la conversation ─────────────────────
296
297/** Les outils d'écriture du connecteur Asana, quel que soit le nom du serveur. */
298export const WRITE_TOOLS = /__(update_tasks|create_tasks|add_comment|delete_task)$/
299
300const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
301const quoted = (name: string) => `« ${clip(shortName(name), 40)} »`
302const isGid = (v: unknown): v is string => typeof v === 'string' && v.trim() !== ''
303
304/** Trois noms au plus ; au-delà, les deux premiers et « +n ». */
305function listed(names: string[]): string {
306  if (names.length <= 3) return names.join(', ')
307  return `${names.slice(0, 2).join(', ')} +${names.length - 2}`
308}
309
310/** Ce que fait une modification de `update_tasks`, du plus parlant au plus vague. */
311function changeOf(c: Record<string, unknown>): { verb: string; suffix: string } {
312  // Cocher porte aussi « Statut : fait » dans les notes : la coche l'emporte.
313  if (c.completed === true) return { verb: 'coche', suffix: '' }
314  if (c.completed === false) return { verb: 'décoche', suffix: '' }
315  const statut = typeof c.notes === 'string' ? /^\s*Statut\s*:\s*(.+?)\s*$/im.exec(c.notes)?.[1] : undefined
316  if (statut) return { verb: `statut « ${clip(statut, 30)} » sur`, suffix: '' }
317  if (typeof c.name === 'string') return { verb: 'renomme', suffix: ` en ${quoted(c.name)}` }
318  if (c.assignee === null) return { verb: 'confie à Claude', suffix: '' }
319  if (c.assignee === 'me') return { verb: 'assigne', suffix: ' à toi' }
320  if (typeof c.assignee === 'string') return { verb: 'assigne', suffix: '' }
321  if (c.due_on === null) return { verb: "retire l'échéance de", suffix: '' }
322  if (typeof c.due_on === 'string') return { verb: `échéance ${c.due_on} sur`, suffix: '' }
323  return { verb: 'modifie', suffix: '' }
324}
325
326/**
327 * La ligne courte d'un appel d'écriture de Claude (« coche « X » », « crée « Y » »), les
328 * tâches nommées d'après les tâches déjà lues (`names`, gid → nom), sans relecture : un
329 * gid inconnu s'affiche tel quel. Null quand l'entrée n'a pas la forme attendue : la
330 * ligne du moteur reste alors.
331 */
332export function describeWrite(tool: string, input: unknown, names: ReadonlyMap<string, string>): string | null {
333  const kind = WRITE_TOOLS.exec(tool)?.[1]
334  if (!kind || typeof input !== 'object' || input === null) return null
335  const args = input as Record<string, unknown>
336  const nameOf = (gid: string) => {
337    const known = names.get(gid)
338    return known ? quoted(known) : `tâche ${gid}`
339  }
340  if (kind === 'add_comment') return isGid(args.task_id) ? `commentaire sur ${nameOf(args.task_id)}` : null
341  if (kind === 'delete_task') return isGid(args.task) ? `supprime ${nameOf(args.task)}` : null
342  const items = Array.isArray(args.tasks) ? args.tasks : []
343  if (items.length === 0 || !items.every(t => typeof t === 'object' && t !== null)) return null
344  const list = items as Record<string, unknown>[]
345  if (kind === 'create_tasks') {
346    if (!list.every(t => typeof t.name === 'string' && t.name.trim())) return null
347    return `crée ${listed(list.map(t => quoted(String(t.name))))}`
348  }
349  if (!list.every(t => isGid(t.task))) return null
350  const changes = list.map(changeOf)
351  const targets = listed(list.map(t => nameOf(String(t.task))))
352  const first = changes[0]!
353  // Une seule sorte de modification : on la dit ; sinon, « modifie » et les tâches.
354  const isSame = changes.every(c => c.verb === first.verb && c.suffix === first.suffix)
355  return isSame ? `${first.verb} ${targets}${first.suffix}` : `modifie ${targets}`
356}
357
hooks/view.tsx 814 lines
1// Dessin du panneau Asana et de son bandeau au-dessus du prompt. Un seul arbre pour toutes
2// les surfaces, sur le modèle de flow-board : le desktop, VS Code et le mobile reçoivent
3// des SVG, le terminal des glyphes et des barres en blocs.
4//
5// Pièges du desktop (relevés dans git-board et flow-board) : les espaces ordinaires y sont
6// fondus et `gap` ignoré ; les écarts dans un texte sont donc insécables, ceux entre
7// éléments des marges. Aucune prop n'est passée à `undefined` : les arbres restent valides
8// partout. Aucun `$` ici : les actions arrivent en fermetures depuis register.tsx.
9
10import type { AsanaTask, Draft, Notice, PanelMeta } from '../types'
11import {
12  BRAND,
13  C,
14  COUNT_ORDER,
15  GROUPS,
16  isMe,
17  byDueThenName,
18  countsOf,
19  dueOf,
20  groupBy,
21  groupOf,
22  isShown,
23  labelOf,
24  matchesWho,
25  notesMarkdown,
26  shortName,
27  statutChoices,
28  STATUTS,
29  whoOf,
30  type Counts,
31  type Group,
32} from './model.ts'
33import { barSvg, donutSvg, pulseSvg, spinnerSvg, type BarPart } from './svg.ts'
34
35const NB = '\u00a0'
36const SEP = `${NB}${NB}·${NB}${NB}`
37/** Tâches montrées par groupe avant « ▸ tout voir (N) ». */
38const CAP = 12
39
40export type Kit = {
41  Box: any
42  Text: any
43  Button: any
44  Svg: any
45  /** Lien externe ; absent d'une table qui n'en a pas. */
46  Link: any
47  Markdown: any
48  /** Champ et liste de choix : absents sur le mobile. */
49  Input: any
50  Select: any
51  /** La surface dessine les SVG (desktop, VS Code, mobile). */
52  isRich: boolean
53  /** Colonnes du corps. */
54  width: number
55}
56
57/**
58 * Ce que la surface sait dessiner, dit par register.tsx d'après la table du moteur : une
59 * table complète toujours chaque nom (un élément absent y dessine un fragment vide), donc
60 * `typeof table.Svg` ne suffit pas à dire si la surface dessine les SVG.
61 */
62export type Abilities = { isRich: boolean; hasFields: boolean }
63
64export const kitOf = (table: Record<string, unknown>, width: number, can: Abilities): Kit => ({
65  Box: table.Box,
66  Text: table.Text,
67  Button: table.Button,
68  Svg: can.isRich ? (table.Svg ?? null) : null,
69  Link: table.Link ?? null,
70  Markdown: table.Markdown ?? null,
71  Input: can.hasFields ? (table.Input ?? null) : null,
72  Select: can.hasFields ? (table.Select ?? null) : null,
73  isRich: can.isRich && typeof table.Svg === 'function',
74  width: Math.max(24, Math.floor(width)),
75})
76
77/** Ce que le panneau dessine : tout l'état du mod, lu avant le rendu. */
78export type View = {
79  tasks: AsanaTask[]
80  meta: PanelMeta
81  who: string
82  expanded: string[]
83  collapsed: string[]
84  showDone: boolean
85  showAll: string[]
86  notice: Notice
87  editing: string | null
88  confirmDelete: string | null
89  creating: boolean
90  draft: Draft
91  busy: string[]
92  now: number
93}
94
95/** Ce que le bandeau dessine : peu de lectures, la chaîne des bandeaux est partagée. */
96export type BandView = {
97  /** Colonnes du bandeau, telles que le moteur les donne (le kit, lui, plancher à 24). */
98  columns: number
99  tasks: AsanaTask[]
100  meta: PanelMeta
101  isBandHidden: boolean
102  now: number
103}
104
105export type Actions = {
106  setWho: (who: string) => unknown
107  flipTask: (gid: string) => unknown
108  flipGroup: (key: string) => unknown
109  flipDone: () => unknown
110  flipAll: (key: string) => unknown
111  toggleCreating: () => unknown
112  setDraft: (patch: Partial<Draft>) => unknown
113  cancelCreate: () => unknown
114  create: () => unknown
115  refresh: () => unknown
116  setDone: (t: AsanaTask, completed: boolean) => unknown
117  setStatut: (t: AsanaTask, value: string) => unknown
118  assign: (t: AsanaTask, assignee: string | null) => unknown
119  work: (t: AsanaTask) => unknown
120  comment: (t: AsanaTask, text: string) => unknown
121  startEdit: (gid: string) => unknown
122  endEdit: () => unknown
123  rename: (t: AsanaTask, name: string) => unknown
124  setDue: (t: AsanaTask, text: string) => unknown
125  editLine: (t: AsanaTask, before: string, after: string | null) => unknown
126  addLine: (t: AsanaTask, text: string) => unknown
127  abandon: (t: AsanaTask) => unknown
128  askDelete: (gid: string) => unknown
129  cancelDelete: () => unknown
130  remove: (t: AsanaTask) => unknown
131  hideBand: () => unknown
132  openPane: () => unknown
133}
134
135// ─── Petits outils (copiés de flow-board, hooks/view.tsx:104-183) ─────────
136
137export const dur = (ms: number): string => {
138  const s = Math.max(0, Math.round(ms / 1000))
139  if (s < 60) return `${s}${NB}s`
140  const m = Math.floor(s / 60)
141  if (m < 60) return `${m}${NB}min`
142  const h = Math.floor(m / 60)
143  if (h < 48) return `${h}${NB}h${NB}${String(m % 60).padStart(2, '0')}`
144  return `${Math.floor(h / 24)}${NB}j`
145}
146
147/** « à l'instant », « il y a 4 min ». */
148export const ago = (ms: number): string => (ms < 10_000 ? `à${NB}l'instant` : `il${NB}y${NB}a${NB}${dur(ms)}`)
149
150const plural = (n: number, one: string, many = `${one}s`): string => `${n}${NB}${n > 1 ? many : one}`
151const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
152const spinFrame = (now: number): string => SPIN[Math.floor(now / 1000) % SPIN.length] ?? '⠋'
153
154type Style = { color?: string; bg?: string; bold?: boolean; dim?: boolean; italic?: boolean; strike?: boolean; wrap?: string }
155
156/** Un Text sans prop indéfinie. */
157const T = (k: Kit, text: string, style: Style = {}): unknown => {
158  const props: Record<string, unknown> = {}
159  if (style.color) props.color = style.color
160  if (style.bg) props.backgroundColor = style.bg
161  if (style.bold) props.bold = true
162  if (style.dim) props.dimColor = true
163  if (style.italic) props.italic = true
164  if (style.strike) props.strikethrough = true
165  if (style.wrap) props.wrap = style.wrap
166  return h(k.Text, props, text)
167}
168
169/** Pastille pleine : texte foncé sur la couleur. */
170const chip = (k: Kit, text: string, color: string): unknown => T(k, `${NB}${text}${NB}`, { bg: color, color: C.ink, bold: true })
171
172const isPresent = (item: unknown): boolean => item !== null && item !== undefined && item !== false && item !== ''
173
174/** Une rangée d'éléments séparés par une marge (le desktop ignore `gap`). */
175const line = (k: Kit, items: unknown[], wrap = false): unknown => {
176  const { Box } = k
177  const list = items.filter(isPresent)
178  return (
179    <Box flexDirection="row" alignItems="center" flexWrap={wrap ? 'wrap' : 'nowrap'}>
180      {list.map((item, i) => (i < list.length - 1 ? <Box marginRight={1}>{item}</Box> : item))}
181    </Box>
182  )
183}
184
185const svgOf = (k: Kit, drawn: { source: string; width: number; height: number }, alt: string, isSized = true): unknown => {
186  const props: Record<string, unknown> = { source: drawn.source, alt }
187  if (isSized) {
188    props.width = drawn.width
189    props.height = drawn.height
190  }
191  // Jamais `isInteractive` : ce mode dessine dans un cadre à fond blanc, rechargé (donc
192  // clignotant) à chaque redessin. En image, le fond reste transparent et le dessin stable.
193  return h(k.Svg, props)
194}
195
196/** Barre en blocs du terminal. */
197const blocks = (k: Kit, parts: readonly BarPart[], total: number, cells: number): unknown => {
198  const { Box } = k
199  const width = Math.max(6, cells)
200  const sum = Math.max(1, total, parts.reduce((n, p) => n + p.value, 0))
201  let used = 0
202  const segs: unknown[] = []
203  parts.forEach(part => {
204    if (part.value <= 0) return
205    const n = Math.max(1, Math.round((part.value / sum) * width))
206    const take = Math.min(n, width - used)
207    if (take <= 0) return
208    used += take
209    segs.push(T(k, '█'.repeat(take), { color: part.color }))
210  })
211  if (used < width) segs.push(T(k, '░'.repeat(width - used), { color: C.gray, dim: true }))
212  return <Box flexDirection="row">{segs}</Box>
213}
214
215/** Un indicateur « en cours » : arc qui tourne (SVG) ou spinner braille. */
216const spinner = (k: Kit, now: number, color: string, alt: string): unknown =>
217  k.isRich ? svgOf(k, spinnerSvg(color, 12), alt) : T(k, spinFrame(now), { color })
218
219// ─── Données dérivées ─────────────────────────────────────────────────────
220
221const barParts = (counts: Counts): BarPart[] => COUNT_ORDER.map(g => ({ value: counts.byGroup[g.key], color: g.color }))
222
223const countsAlt = (counts: Counts): string => {
224  const parts = COUNT_ORDER.filter(g => counts.byGroup[g.key] > 0).map(g => `${counts.byGroup[g.key]} ${g.short}`)
225  return parts.length > 0 ? `${counts.open} tâches ouvertes : ${parts.join(', ')}` : 'Aucune tâche ouverte'
226}
227
228const whoColor = (t: AsanaTask): string => (!t.assignee ? C.tangerine : isMe(t.assignee) ? C.amber : C.slate)
229
230const cut = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`)
231
232// ─── Panneau ──────────────────────────────────────────────────────────────
233
234/** Au-delà, la lecture n'est plus dite « à jour » : le panneau ne se relit pas seul (sauf après une écriture de Claude). */
235const FRESH_MS = 10 * 60_000
236
237const isFresh = (view: View): boolean => view.meta.loadedAt !== null && view.now - view.meta.loadedAt < FRESH_MS
238
239/**
240 * La pastille d'état de l'en-tête : lecture, erreur, à jour, lue. Statique pendant une
241 * lecture : le spinner et la progression sont dans la zone d'état, juste dessous.
242 */
243const livePill = (k: Kit, view: View): unknown => {
244  const m = view.meta
245  if (m.loading) return T(k, `◌${NB}Lecture…`, { color: C.tangerine, bold: true })
246  if (m.error) return T(k, `⚠${NB}Erreur`, { color: C.red, bold: true })
247  if (m.loadedAt === null) return T(k, `○${NB}Pas encore lue`, { color: C.gray, bold: true })
248  if (!isFresh(view)) return T(k, `●${NB}Lue`, { color: C.gray, bold: true })
249  const dot = k.isRich ? svgOf(k, pulseSvg(C.green, false), 'À jour') : T(k, '●', { color: C.green })
250  return line(k, [dot, T(k, 'À jour', { color: C.green, bold: true })])
251}
252
253const hero = (k: Kit, view: View, act: Actions, counts: Counts): unknown => {
254  const { Box, Button } = k
255  const m = view.meta
256  const inner = k.width - 6
257  const parts = barParts(counts)
258  const visual = k.isRich ? svgOf(k, donutSvg(parts, counts.open), countsAlt(counts)) : T(k, String(counts.open), { color: BRAND, bold: true })
259  const scope = view.who === 'toi' ? `filtre${NB}:${NB}pour${NB}toi` : view.who === 'claude' ? `filtre${NB}:${NB}pour${NB}Claude` : ''
260  const meta = [
261    plural(counts.open, `tâche${NB}ouverte`, `tâches${NB}ouvertes`),
262    scope,
263    view.who === 'tous' ? `pour${NB}toi${NB}:${NB}${counts.mine}` : '',
264    m.loadedAt !== null ? `${isFresh(view) ? `à${NB}jour` : 'lue'}${NB}${ago(view.now - m.loadedAt)}` : '',
265  ]
266    .filter(Boolean)
267    .join(SEP)
268  // Les comptes suivent le filtre : la méta le dit (« filtre : pour toi »), une seule fois.
269  const legend = [
270    ...COUNT_ORDER.filter(g => counts.byGroup[g.key] > 0).map(g =>
271      T(k, `${g.glyph}${NB}${counts.byGroup[g.key]}${NB}${g.short}`, { color: g.color, bold: !g.isQuiet, dim: g.isQuiet }),
272    ),
273    counts.done > 0 && T(k, `✓${NB}${counts.done}${NB}${counts.done > 1 ? 'cochées' : 'cochée'}`, { color: C.green, dim: true }),
274  ]
275  const canWrite = k.Input !== null && k.Select !== null
276  return (
277    <Box key="hero" flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={1}>
278      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
279        <Box flexShrink={1}>{chip(k, `◆${NB}ASANA`, BRAND)}</Box>
280        <Box marginLeft={1} flexShrink={0}>
281          {livePill(k, view)}
282        </Box>
283      </Box>
284      <Box flexDirection="row" alignItems="center" marginTop={1}>
285        <Box marginRight={2} flexShrink={0}>
286          {visual}
287        </Box>
288        <Box flexDirection="column" flexShrink={1}>
289          {T(k, view.meta.section ?? 'Asana', { bold: true, wrap: 'truncate-end' })}
290          {line(k, [T(k, meta, { dim: true, wrap: 'truncate-end' }), counts.overdue > 0 && T(k, `⚠${NB}${counts.overdue}${NB}en${NB}retard`, { color: C.red, bold: true })])}
291          {!k.isRich && blocks(k, parts, counts.open, Math.min(32, inner - 8))}
292        </Box>
293      </Box>
294      {legend.some(isPresent) && <Box marginTop={1}>{line(k, legend, true)}</Box>}
295      <Box flexDirection="row" flexWrap="wrap" alignItems="center" marginTop={1}>
296        {canWrite && (
297          <Box marginRight={2}>
298            {/* Formulaire ouvert : une bascule qui dit ce qu'elle fait (le brouillon reste ; « Annuler » le vide). */}
299            {view.creating ? (
300              <Button key="new" plain={true} dimColor={true} label="▾ Replier le formulaire" onPress={() => act.toggleCreating()} />
301            ) : (
302              <Button key="new" label="+ Nouvelle tâche" onPress={() => act.toggleCreating()} />
303            )}
304          </Box>
305        )}
306        {/* Pendant une lecture, ↻ attendrait la lecture en cours : il le dit, atténué. */}
307        {m.loading ? (
308          <Button key="refresh" plain={true} dimColor={true} label="Lecture…" onPress={() => act.refresh()} />
309        ) : (
310          <Button key="refresh" label="↻ Relire" onPress={() => act.refresh()} />
311        )}
312      </Box>
313    </Box>
314  )
315}
316
317/** Avant toute lecture : ce que fait le panneau, et de quoi partir. */
318const welcome = (k: Kit, view: View, act: Actions): unknown => {
319  const { Box, Button } = k
320  const m = view.meta
321  return (
322    <Box key="welcome" flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={2} paddingY={1}>
323      {T(k, `◆${NB}Tâches Asana du projet`, { color: BRAND, bold: true })}
324      {T(k, 'Le panneau lit la section Asana nommée dans le CLAUDE.md du dossier (ou docs/agents/issue-tracker.md), puis ses tâches ouvertes et leurs sous-tâches.', { dim: true, wrap: 'wrap' })}
325      {m.folder !== null && T(k, `Dossier${NB}:${NB}${m.folder}`, { dim: true, wrap: 'truncate-end' })}
326      {m.section !== null && T(k, `Section${NB}:${NB}${m.section}`, { dim: true, wrap: 'truncate-end' })}
327      {!m.error && !m.loading && (
328        <Box marginTop={1}>
329          <Button key="refresh" variant="primary" label="↻ Lire la section" onPress={() => act.refresh()} />
330        </Box>
331      )}
332    </Box>
333  )
334}
335
336const tabsRow = (k: Kit, view: View, open: readonly AsanaTask[], act: Actions): unknown => {
337  const { Box, Button } = k
338  const count = (who: string) => open.filter(t => matchesWho(t, who)).length
339  const tabs: [string, string][] = [
340    ['tous', `Tous${NB}·${NB}${count('tous')}`],
341    ['toi', `Pour toi${NB}·${NB}${count('toi')}`],
342    ['claude', `Pour Claude${NB}·${NB}${count('claude')}`],
343  ]
344  return (
345    <Box key="tabs" flexDirection="row" flexWrap="wrap" marginTop={1}>
346      {tabs.map(([tab, label]) => (
347        <Box marginRight={2}>
348          <Button key={`tab-${tab}`} plain={true} dimColor={tab !== view.who} label={tab === view.who ? `▸ ${label}` : label} onPress={() => act.setWho(tab)} />
349        </Box>
350      ))}
351    </Box>
352  )
353}
354
355/** Sous l'en-tête, à la même place : lecture en cours, erreur avec reprise, avertissement. */
356const statusZone = (k: Kit, view: View, act: Actions): unknown => {
357  const { Box, Button } = k
358  const m = view.meta
359  if (m.loading) {
360    return (
361      <Box key="loading" marginTop={1}>
362        {line(k, [spinner(k, view.now, C.tangerine, m.loading), T(k, m.loading, { color: C.tangerine, wrap: 'truncate-end' })])}
363      </Box>
364    )
365  }
366  if (m.error) {
367    const isNoSection = /^Pas de section/.test(m.error)
368    const isNoServer = /^Connecteur/.test(m.error)
369    return (
370      <Box key="error" flexDirection="column" borderStyle="round" borderColor={C.red} paddingX={1} marginTop={1}>
371        {T(k, `⚠${NB}Lecture impossible`, { color: C.red, bold: true })}
372        {T(k, m.error, { wrap: 'wrap' })}
373        {isNoSection && T(k, 'Attendu dans le CLAUDE.md ou docs/agents/issue-tracker.md : section « Nom » (GID `123…`), ou une ligne de tableau | Section | « Nom », GID `123…` | ; le projet de la même façon pour créer des tâches.', { dim: true, wrap: 'wrap' })}
374        {isNoServer && T(k, 'Le connecteur Asana doit être actif dans la session (outils get_tasks et update_tasks).', { dim: true, wrap: 'wrap' })}
375        {view.tasks.length > 0 && m.loadedAt !== null && T(k, `La liste ci-dessous date de la dernière lecture réussie, ${ago(view.now - m.loadedAt)}.`, { dim: true, wrap: 'wrap' })}
376        <Box marginTop={1}>
377          <Button key="retry" variant="primary" label="↻ Réessayer" onPress={() => act.refresh()} />
378        </Box>
379      </Box>
380    )
381  }
382  if (m.warning) {
383    return (
384      <Box key="warning" marginTop={1}>
385        {T(k, `⚠${NB}${m.warning}`, { color: C.amber, wrap: 'wrap' })}
386      </Box>
387    )
388  }
389  return null
390}
391
392/** Le retour d'erreur gardé sous une tâche ou sous le formulaire (le reste passe par les toasts). */
393const noticeFor = (k: Kit, view: View, gid: string): unknown => {
394  const { Box } = k
395  const n = view.notice
396  if (!n || n.gid !== gid) return null
397  return <Box marginTop={1}>{T(k, n.text, { color: n.isError ? C.red : C.green, wrap: 'wrap' })}</Box>
398}
399
400const form = (k: Kit, view: View, act: Actions): unknown => {
401  const { Box, Button, Input, Select } = k
402  const d = view.draft
403  const isBusy = view.busy.includes('new')
404  const parents = view.tasks.filter(t => !t.completed && t.parent === null && (t.childCount > 0 || /prochaines sessions/i.test(t.name)))
405  const field = (key: string, label: string, placeholder: string, value: string, patch: (v: string) => Partial<Draft>, isFirst = false) => {
406    const props: Record<string, unknown> = { key, label, placeholder, value, onInput: (v: string) => act.setDraft(patch(v)), onSubmit: (v: string) => act.setDraft(patch(v)) }
407    if (isFirst) props.autoFocus = true
408    return <Box marginTop={1}>{h(Input, props)}</Box>
409  }
410  return (
411    <Box key="form" flexDirection="column" borderStyle="round" borderColor={BRAND} paddingX={1} marginTop={1}>
412      {T(k, '+ Nouvelle tâche', { color: BRAND, bold: true })}
413      {field('f-name', 'Titre', 'Verbe + objet concret', d.name, v => ({ name: v }), true)}
414      {T(k, 'Préfixe [code] ajouté, sauf si le titre commence par « [ ».', { dim: true, wrap: 'wrap' })}
415      <Box marginTop={1}>
416        {line(
417          k,
418          [
419            <Select key="f-statut" label="Statut" options={STATUTS.map(s => ({ value: s, label: s }))} value={d.statut} onSelect={(v: string) => act.setDraft({ statut: v })} />,
420            <Select
421              key="f-who"
422              label="Pour"
423              options={[
424                { value: 'claude', label: 'Claude (non assignée)' },
425                { value: 'toi', label: 'Toi' },
426              ]}
427              value={d.who}
428              onSelect={(v: string) => act.setDraft({ who: v })}
429            />,
430            <Select
431              key="f-where"
432              label="Emplacement"
433              options={[{ value: 'section', label: 'Dans la section' }, ...parents.map(p => ({ value: p.gid, label: `Sous « ${cut(shortName(p.name), 40)} »` }))]}
434              value={d.where}
435              onSelect={(v: string) => act.setDraft({ where: v })}
436            />,
437          ],
438          true,
439        )}
440      </Box>
441      {field('f-ctx', 'Contexte', '1 à 3 lignes', d.contexte, v => ({ contexte: v }))}
442      {field('f-fini', 'Fini quand', 'critère observable', d.fini, v => ({ fini: v }))}
443      {field('f-due', 'Échéance', 'JJ/MM/AAAA, facultatif', d.due, v => ({ due: v }))}
444      {noticeFor(k, view, 'new')}
445      <Box flexDirection="row" alignItems="center" marginTop={1}>
446        <Box marginRight={2}>
447          <Button key="f-create" variant="primary" label={isBusy ? 'Création…' : '▶ Créer la tâche'} onPress={() => act.create()} />
448        </Box>
449        <Button key="f-cancel" label="Annuler" onPress={() => act.cancelCreate()} />
450      </Box>
451    </Box>
452  )
453}
454
455const editForm = (k: Kit, t: AsanaTask, act: Actions): unknown => {
456  const { Box, Button, Input } = k
457  const lines = t.notes.split('\n').filter(l => l.trim() && !/^\s*Statut\s*:/i.test(l))
458  return (
459    <Box key={`e-${t.gid}`} flexDirection="column">
460      {T(k, 'Entrée enregistre chaque champ ; un champ vidé retire sa ligne.', { dim: true, wrap: 'wrap' })}
461      <Box marginTop={1}>
462        <Input key={`e-name-${t.gid}`} label="Titre" value={t.name} submitLabel="Enregistrer" onSubmit={(v: string) => act.rename(t, v)} />
463      </Box>
464      <Box marginTop={1}>
465        <Input
466          key={`e-due-${t.gid}`}
467          label="Échéance"
468          placeholder="JJ/MM/AAAA, vide pour aucune"
469          value={t.due ? `${t.due.slice(8, 10)}/${t.due.slice(5, 7)}/${t.due.slice(0, 4)}` : ''}
470          submitLabel="Enregistrer"
471          onSubmit={(v: string) => act.setDue(t, v)}
472        />
473      </Box>
474      {lines.map((l, i) => {
475        const label = labelOf(l)
476        return (
477          <Box key={`e-l-${t.gid}-${i}`} marginTop={1}>
478            <Input
479              key={`e-line-${t.gid}-${i}`}
480              label={label ?? 'Ligne'}
481              value={label ? l.slice(l.indexOf(':') + 1).trim() : l}
482              submitLabel="Enregistrer"
483              onSubmit={(v: string) => act.editLine(t, l, v.trim() ? (label ? `${label} : ${v.trim()}` : v.trim()) : null)}
484            />
485          </Box>
486        )
487      })}
488      <Box marginTop={1}>
489        <Input key={`e-add-${t.gid}`} label="Ajouter" placeholder="Étiquette : texte" submitLabel="Ajouter" onSubmit={(v: string) => act.addLine(t, v)} />
490      </Box>
491      <Box marginTop={1}>
492        <Button key={`e-end-${t.gid}`} label="Terminer la modification" onPress={() => act.endEdit()} />
493      </Box>
494    </Box>
495  )
496}
497
498/** Le détail d'une tâche dépliée : une carte bordée de la couleur de son groupe. */
499const taskCard = (k: Kit, view: View, t: AsanaTask, g: Group, act: Actions): unknown => {
500  const { Box, Button, Link, Markdown, Select, Input } = k
501  const isBusy = view.busy.includes(t.gid)
502  const isEditing = view.editing === t.gid && Input !== null
503  const due = dueOf(t, view.now)
504  const statut = statutChoices(t)
505  const meta = [
506    T(k, t.statut ? `Statut${NB}:${NB}${t.statut}` : `Sans${NB}statut`, { color: t.completed ? C.green : g.color, bold: true, wrap: 'truncate-end' }),
507    T(k, `pour${NB}${whoOf(t)}`, { color: whoColor(t) }),
508    t.childCount > 0 && T(k, plural(t.childCount, 'sous-tâche'), { dim: true }),
509    due && T(k, `échéance${NB}${due.text}`, { color: due.color }),
510  ]
511  const notes = Markdown ? <Markdown key={`n-${t.gid}`} text={notesMarkdown(t.notes)} /> : T(k, t.notes || 'Pas de description.', { wrap: 'wrap' })
512  // Un seul bouton principal : se mettre à la tâche. Le reste en secondaire, la suppression atténuée.
513  const actions = [
514    <Button key={`w-${t.gid}`} variant="primary" label="▶ Travailler dessus" onPress={() => act.work(t)} />,
515    Select && <Select key={`s-${t.gid}`} label="Statut" options={statut.options} value={statut.value} onSelect={(v: string) => act.setStatut(t, v)} />,
516    <Button key={`a-${t.gid}`} label={isMe(t.assignee) ? 'Confier à Claude' : "M'assigner"} onPress={() => act.assign(t, isMe(t.assignee) ? null : 'me')} />,
517    Input !== null && !isEditing && <Button key={`m-${t.gid}`} label="Modifier" onPress={() => act.startEdit(t.gid)} />,
518    !t.completed && <Button key={`ab-${t.gid}`} label="Abandonner" onPress={() => act.abandon(t)} />,
519    <Button key={`del-${t.gid}`} plain={true} dimColor={true} label="Supprimer…" onPress={() => act.askDelete(t.gid)} />,
520    t.url && Link && <Link href={t.url} label="Asana ↗" />,
521  ]
522  return (
523    <Box key={`d-${t.gid}`} flexDirection="column" borderStyle="round" borderColor={t.completed ? C.green : g.color} paddingX={1} marginLeft={4}>
524      {t.parentName && T(k, `↳${NB}Dans « ${shortName(t.parentName)} »`, { dim: true, wrap: 'truncate-end' })}
525      {line(k, meta, true)}
526      <Box marginTop={1} flexDirection="column">
527        {isEditing ? editForm(k, t, act) : notes}
528      </Box>
529      {isBusy && <Box marginTop={1}>{line(k, [spinner(k, view.now, C.tangerine, 'Enregistrement'), T(k, 'Enregistrement dans Asana…', { color: C.tangerine })])}</Box>}
530      {noticeFor(k, view, t.gid)}
531      <Box marginTop={1}>{line(k, actions, true)}</Box>
532      {view.confirmDelete === t.gid && (
533        <Box flexDirection="column" borderStyle="round" borderColor={C.red} paddingX={1} marginTop={1}>
534          {T(k, `Supprimer « ${shortName(t.name)} »${t.childCount ? ` et ses ${t.childCount} sous-tâches` : ''} ? Difficile à annuler : « Abandonner » garde une trace.`, { color: C.red, wrap: 'wrap' })}
535          <Box flexDirection="row" marginTop={1}>
536            <Box marginRight={2}>
537              <Button key={`dy-${t.gid}`} label="Oui, supprimer" onPress={() => act.remove(t)} />
538            </Box>
539            <Button key={`dn-${t.gid}`} label="Annuler" onPress={() => act.cancelDelete()} />
540          </Box>
541        </Box>
542      )}
543      {Input !== null && (
544        <Box marginTop={1}>
545          <Input key={`k-${t.gid}`} label="Commentaire" placeholder="Ajouter un commentaire…" submitLabel="Commenter" onSubmit={(v: string) => act.comment(t, v)} />
546        </Box>
547      )}
548    </Box>
549  )
550}
551
552/** Une tâche en ligne compacte : case, titre tronqué, personne et échéance à droite, flèche. */
553const taskRow = (k: Kit, view: View, t: AsanaTask, g: Group, act: Actions): unknown => {
554  const { Box, Button } = k
555  const isOpen = view.expanded.includes(t.gid)
556  const isBusy = view.busy.includes(t.gid)
557  const due = dueOf(t, view.now)
558  const check = isBusy ? (
559    spinner(k, view.now, C.tangerine, 'Enregistrement')
560  ) : (
561    <Button key={`c-${t.gid}`} plain={true} dimColor={!t.completed} label={t.completed ? '✓' : '○'} onPress={() => act.setDone(t, !t.completed)} />
562  )
563  const meta = [
564    k.width >= 72 && t.childCount > 0 && T(k, plural(t.childCount, 'sous-tâche'), { dim: true }),
565    // Sous « Pour toi » ou « Pour Claude », la personne est la même partout : inutile de la répéter.
566    k.width >= 56 && view.who === 'tous' && T(k, whoOf(t), { color: whoColor(t) }),
567    due && T(k, due.text, { color: due.color }),
568  ]
569  return (
570    <Box key={`t-${t.gid}`} flexDirection="column">
571      <Box flexDirection="row" alignItems="center">
572        <Box marginRight={1} flexShrink={0} width={3}>
573          {check}
574        </Box>
575        <Box flexShrink={1} flexGrow={1}>
576          {T(k, shortName(t.name), { wrap: 'truncate-end', bold: isOpen, dim: t.completed, strike: t.completed })}
577        </Box>
578        {meta.some(isPresent) && (
579          <Box marginLeft={1} flexShrink={0}>
580            {line(k, meta)}
581          </Box>
582        )}
583        <Box marginLeft={1} flexShrink={0}>
584          <Button key={`x-${t.gid}`} plain={true} dimColor={true} label={isOpen ? '▾' : '▸'} onPress={() => act.flipTask(t.gid)} />
585        </Box>
586      </Box>
587      {isOpen && taskCard(k, view, t, g, act)}
588    </Box>
589  )
590}
591
592/** Un groupe : titre glyphe + mot coloré, compte, puis ses tâches plafonnées. */
593const groupBlock = (
594  k: Kit,
595  view: View,
596  key: string,
597  head: { glyph: string; title: string; color: string },
598  items: AsanaTask[],
599  isOpen: boolean,
600  onFlip: () => unknown,
601  act: Actions,
602  note = '',
603): unknown => {
604  const { Box, Button } = k
605  const isAll = view.showAll.includes(key)
606  const shown = isAll ? items : items.slice(0, CAP)
607  const g = groupBy(key)
608  return (
609    <Box key={`g-${key}`} flexDirection="column" marginTop={1}>
610      {line(k, [
611        <Button key={`fold-${key}`} plain={true} dimColor={true} label={isOpen ? '▾' : '▸'} onPress={() => onFlip()} />,
612        T(k, `${head.glyph}${NB}${head.title}`, { color: head.color, bold: true }),
613        T(k, String(items.length), { dim: true }),
614      ])}
615      {isOpen && note !== '' && <Box paddingLeft={2}>{T(k, note, { dim: true, wrap: 'truncate-end' })}</Box>}
616      {isOpen && shown.map(t => taskRow(k, view, t, key === 'done' ? g : groupBy(groupOf(t)), act))}
617      {isOpen && items.length > CAP && (
618        <Box paddingLeft={2}>
619          <Button key={`more-${key}`} plain={true} dimColor={true} label={isAll ? '▾ réduire' : `▸ tout voir (${items.length})`} onPress={() => act.flipAll(key)} />
620        </Box>
621      )}
622    </Box>
623  )
624}
625
626/** Rien à lister : on dit pourquoi, selon le filtre. */
627const emptyCard = (k: Kit, view: View): unknown => {
628  const { Box } = k
629  const [title, hint] =
630    view.who === 'toi'
631      ? ['Aucune tâche pour toi.', 'L’onglet « Tous » montre celles des autres et de Claude.']
632      : view.who === 'claude'
633        ? ['Aucune tâche pour Claude.', 'Une tâche non assignée est une tâche pour Claude.']
634        : [`✓${NB}Aucune tâche ouverte dans cette section.`, 'Les tâches terminées ne sont pas chargées. « + Nouvelle tâche » pour en créer une.']
635  return (
636    <Box key="empty" flexDirection="column" borderStyle="round" borderDimColor={true} paddingX={1} marginTop={1}>
637      {T(k, title, { color: view.who === 'tous' ? C.green : C.gray, bold: true })}
638      {T(k, hint, { dim: true, wrap: 'wrap' })}
639    </Box>
640  )
641}
642
643export const drawPane = (k: Kit, view: View, act: Actions): unknown => {
644  const { Box } = k
645  const m = view.meta
646  const visible = view.tasks.filter(t => isShown(t))
647  const list = visible.filter(t => matchesWho(t, view.who))
648  // Les comptes de l'en-tête suivent le filtre (la légende le dit) ; les onglets, non.
649  const counts = countsOf(list, view.now)
650  const open = visible.filter(t => !t.completed)
651  const groups = GROUPS.map(g => ({ g, items: list.filter(t => !t.completed && groupOf(t) === g.key).sort(byDueThenName) })).filter(x => x.items.length > 0)
652  const done = list.filter(t => t.completed).sort(byDueThenName)
653  // Avant la première lecture réussie, aucun compte : l'accueil, pas un « 0 tâche ouverte » faux.
654  const hasData = m.loadedAt !== null
655  const isMobile = k.Input === null || k.Select === null
656  return (
657    <Box flexDirection="column" paddingX={1} paddingBottom={1}>
658      {hasData ? hero(k, view, act, counts) : welcome(k, view, act)}
659      {hasData && tabsRow(k, view, open, act)}
660      {statusZone(k, view, act)}
661      {isMobile &&
662        hasData && (
663          <Box key="mobile" marginTop={1}>
664            {T(k, 'Mobile : pas de saisie (création, statut, commentaire).', { dim: true, wrap: 'wrap' })}
665          </Box>
666        )}
667      {view.creating && !isMobile && form(k, view, act)}
668      {groups.map(({ g, items }) => groupBlock(k, view, g.key, g, items, !view.collapsed.includes(g.key), () => act.flipGroup(g.key), act))}
669      {/* Seulement les tâches cochées depuis la dernière lecture : on peut encore les décocher. */}
670      {done.length > 0 &&
671        groupBlock(k, view, 'done', { glyph: '✓', title: 'Terminées', color: C.green }, done, view.showDone, () => act.flipDone(), act, 'Cochées depuis la dernière lecture : décoche pour annuler.')}
672      {m.loadedAt !== null && !m.loading && groups.length === 0 && done.length === 0 && emptyCard(k, view)}
673    </Box>
674  )
675}
676
677// ─── Bandeau ──────────────────────────────────────────────────────────────
678
679/** Glyphes comptés pour deux cellules, par prudence : certains terminaux les dessinent larges. */
680const WIDE = new Set([...'⚖◉⏸○⚠◆✓'])
681/** Largeur estimée d'un texte, en cellules. */
682const cellsOf = (text: string): number => [...text].reduce((n, ch) => n + (WIDE.has(ch) ? 2 : 1), 0)
683/** À droite de la ligne 1 : marge, « ▸ /asana », marge, « x ». */
684const BAND_RIGHT = 1 + cellsOf('▸ /asana') + 1 + 1
685/** Ce que le nom de section garde au moins quand il est montré ; au-delà il se tronque. */
686const SECTION_MIN = 20
687/** La mini-barre : 60 px en SVG, 8 blocs sur le terminal. */
688const MINI_CELLS = 8
689
690/** Ce que la ligne 1 montre : les paliers fixent le plus riche, le budget retire le reste. */
691type BandPlan = { hasTag: boolean; isWords: boolean; hasSection: boolean; hasMini: boolean; hasMine: boolean; hasQuiet: boolean; isLongLate: boolean }
692
693/** Ce qu'on retire, dans l'ordre, tant que la ligne 1 ne tient pas dans sa largeur. */
694// Le nom de section part avant les comptes en toutes lettres : l'étiquette ASANA dit déjà
695// de quoi il s'agit. L'étiquette elle-même ne part qu'en dernier, sur un bandeau très étroit.
696const DEGRADE: ((p: BandPlan) => BandPlan)[] = [
697  p => ({ ...p, hasSection: false }),
698  p => ({ ...p, isWords: false }),
699  p => ({ ...p, hasMine: false }),
700  p => ({ ...p, hasMini: false }),
701  p => ({ ...p, hasQuiet: false }),
702  p => ({ ...p, isLongLate: false }),
703  p => ({ ...p, hasTag: false }),
704]
705
706/** Les éléments de la ligne 1 selon un plan, et la largeur qu'ils prennent, marges comprises. */
707const bandPieces = (k: Kit, view: BandView, counts: Counts, plan: BandPlan): { items: unknown[]; cells: number } => {
708  const { Box } = k
709  const pieces: { cells: number; node: unknown }[] = []
710  const text = (value: string, style: Style) => pieces.push({ cells: cellsOf(value), node: T(k, value, { ...style, wrap: 'truncate-end' }) })
711  pieces.push(plan.hasTag ? { cells: TAG_CELLS, node: bandTag(k, 'Asana', TAG_COLOR) } : { cells: 1, node: bandTag(k, null, TAG_COLOR) })
712  const section = view.meta.section ?? 'Asana'
713  if (plan.hasSection) pieces.push({ cells: Math.min(cellsOf(section), SECTION_MIN), node: <Box flexShrink={1}>{T(k, section, { bold: true, wrap: 'truncate-end' })}</Box> })
714  if (plan.hasMini && counts.open > 0) {
715    const parts = barParts(counts)
716    pieces.push({ cells: MINI_CELLS, node: k.isRich ? svgOf(k, barSvg(parts, counts.open, 60, 7), countsAlt(counts)) : blocks(k, parts, counts.open, MINI_CELLS) })
717  }
718  for (const g of COUNT_ORDER) {
719    const n = counts.byGroup[g.key]
720    if (n === 0 || (g.isQuiet && !plan.hasQuiet)) continue
721    // Seuls les groupes où il y a à faire sont en gras ; l'attente d'autres est atténuée.
722    text(plan.isWords ? `${n}${NB}${g.short}` : `${g.glyph}${n}`, { color: g.color, bold: !g.isQuiet && g.key !== 'autre', dim: g.isQuiet })
723  }
724  if (counts.open === 0) text(`✓${NB}aucune${NB}tâche${NB}ouverte`, { color: C.green })
725  if (counts.overdue > 0) text(plan.isLongLate ? `⚠${NB}${counts.overdue}${NB}en${NB}retard` : `⚠${counts.overdue}`, { color: C.red, bold: true })
726  if (plan.hasMine && counts.mine > 0) text(`pour${NB}toi${NB}:${NB}${counts.mine}`, { color: C.amber })
727  return { items: pieces.map(p => p.node), cells: pieces.reduce((n, p) => n + p.cells, 0) + pieces.length - 1 }
728}
729
730
731
732
733/** Largeur du nom dans la colonne d'étiquette, la même dans les bandeaux Git, Flow et Asana. */
734const LABEL_CELLS = 5
735/** La colonne d'étiquette : la barre, une espace, le nom calé sur LABEL_CELLS. */
736export const TAG_CELLS = 2 + LABEL_CELLS
737/** La couleur d'Asana dans les bandeaux (rose, comme son logo). */
738const TAG_COLOR = C.pink
739
740/**
741 * L'étiquette d'outil en tête du bandeau : une barre verticale et le nom du mod en gras, de la
742 * couleur du mod. Les bandeaux empilés s'alignent sur cette colonne et se distinguent par sa
743 * couleur, sans trait horizontal (un « ─ » répété n'a pas la même largeur sur le desktop que
744 * sur le terminal). `label` vide : la barre et la colonne vide, pour la ligne 2 ; `null` : la
745 * barre seule, sur un bandeau trop étroit.
746 */
747const bandTag = (k: Kit, label: string | null, color: string): unknown => (
748  <k.Box flexDirection="row" flexShrink={0}>
749    {T(k, '▍', { color, bold: true })}
750    {label !== null && T(k, `${NB}${label.padEnd(LABEL_CELLS, NB)}`, { color, bold: true })}
751  </k.Box>
752)
753
754/**
755 * Une ligne, deux au plus. Ligne 1 : étiquette ▍ Asana, [section ≥ 90], [répartition ≥ 64], comptes par
756 * groupe (à trancher, en cours, à faire, sans statut ; en attente atténué en dernier),
757 * retards, [pour toi ≥ 72] ; à droite ▸ /asana et x. Les paliers fixent le plus riche ; si
758 * ça ne tient pas, la section s'en va, puis les comptes passent en glyphes (⚖1 ◉1 ○2), puis
759 * « pour toi », la répartition, l'attente, la forme longue du retard et l'étiquette s'en vont.
760 * Ligne 2 (≥ 56) : seulement la lecture en cours ou l'erreur d'une relecture demandée. Ni
761 * échéance ni retard détaillés : les retards sont comptés en ligne 1 (« ⚠ N en retard »). Les comptes ne suivent pas le filtre du panneau.
762 */
763export const drawBand = (k: Kit, view: BandView, act: Actions): unknown => {
764  const m = view.meta
765  if (m.loadedAt === null || view.isBandHidden) return null
766  const { Box, Button } = k
767  const width = Math.max(1, Math.floor(view.columns))
768  const counts = countsOf(view.tasks, view.now)
769  let plan: BandPlan = { hasTag: true, isWords: true, hasSection: width >= 90, hasMini: width >= 64, hasMine: width >= 72, hasQuiet: true, isLongLate: true }
770  let first = bandPieces(k, view, counts, plan)
771  for (const step of DEGRADE) {
772    if (first.cells + BAND_RIGHT <= width) break
773    plan = step(plan)
774    first = bandPieces(k, view, counts, plan)
775  }
776  const second =
777    width < 56
778      ? null
779      : m.loading
780        ? line(k, [spinner(k, view.now, C.tangerine, 'Lecture Asana'), T(k, m.loading, { color: C.tangerine, wrap: 'truncate-end' })])
781        : m.error
782          ? T(k, `⚠${NB}${m.error}`, { color: C.red, wrap: 'truncate-end' })
783          : null
784  return (
785    <Box key="asana-band" flexDirection="column" width={width}>
786      <Box flexDirection="row" alignItems="center" justifyContent="space-between">
787        <Box flexShrink={1}>{line(k, first.items)}</Box>
788        <Box flexDirection="row" flexShrink={0} marginLeft={1}>
789          <Box marginRight={1}>
790            <Button key="asana-band-open" plain={true} dimColor={true} label="▸ /asana" onPress={() => act.openPane()} />
791          </Box>
792          {/* Pas de role="dismiss" : plusieurs bandeaux empilés auraient leur fermeture native au même endroit. */}
793          <Button key="asana-band-hide" plain={true} dimColor={true} label="x" onPress={() => act.hideBand()} />
794        </Box>
795      </Box>
796      {second !== null && (
797        <Box flexDirection="row">
798          {bandTag(k, plan.hasTag ? '' : null, TAG_COLOR)}
799          <Box flexShrink={1} marginLeft={1}>
800            {second}
801          </Box>
802        </Box>
803      )}
804    </Box>
805  )
806}
807
808/**
809 * La ligne d'un appel d'écriture de Claude dans la conversation : « Asana · coche « X » »,
810 * à la place des GID et du JSON de l'appel. Atténuée tant que l'appel tourne.
811 */
812export const drawWrite = (k: Kit, text: string, isRunning: boolean): unknown =>
813  line(k, [T(k, 'Asana', { color: BRAND, bold: true }), T(k, `·${NB}${text}${isRunning ? '…' : ''}`, { dim: isRunning, wrap: 'truncate-end' })])
814
hooks/svg.ts 332 lines
1// Dessins SVG des surfaces distantes (desktop, VS Code, mobile), copiés de flow-board
2// (hooks/svg.ts, 0.12.1) : feuille de route, anneau, barre segmentée, points, indicateurs
3// vivants. S'y ajoute l'anneau de répartition propre à asana-board (en fin de fichier).
4//
5// - Les SVG sont dessinés en image, jamais avec `isInteractive` (cadre à fond blanc,
6//   rechargé à chaque redessin) : les animations SMIL y sont probablement figées sur le
7//   desktop. Le texte qui change (âges, durées) reste hors des SVG, pour qu'un SVG
8//   identique ne soit pas rechargé à chaque redessin.
9// - Couleurs mi-tons et gris neutre : lisibles sur fond clair comme sombre.
10
11const FONT = `font-family="'Segoe UI', system-ui, -apple-system, sans-serif"`
12const MUTED = '#8B8B93'
13const WHITE = '#FFFFFF'
14
15export const esc = (text: string): string =>
16  text.replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c] ?? c)
17
18const cut = (text: string, max: number): string =>
19  max <= 1 ? '' : text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
20
21const svg = (width: number, height: number, body: string): string =>
22  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
23
24/** Coupe un libellé en deux lignes au plus, aux espaces ; la seconde se termine par « … » s'il en reste. */
25const wrap2 = (text: string, max: number): string[] => {
26  if (text.length <= max) return [text]
27  const words = text.split(/\s+/)
28  let first = ''
29  let i = 0
30  while (i < words.length) {
31    const next = first ? `${first} ${words[i]}` : words[i]!
32    if (next.length > max && first) break
33    first = next
34    i += 1
35  }
36  const rest = words.slice(i).join(' ')
37  return rest ? [cut(first, max), cut(rest, max)] : [cut(first, max)]
38}
39
40/** Largeur moyenne d'un caractère à 11,5 px (Segoe UI, system-ui). */
41const CHAR_PX = 6.2
42
43const r1 = (n: number): number => Math.round(n * 10) / 10
44
45export type NodeState = 'done' | 'active' | 'next' | 'pending' | 'skipped'
46
47export type RoadNode = {
48  label: string
49  sub?: string
50  color: string
51  state: NodeState
52  isOptional?: boolean
53  detours?: { label: string; color: string; isActive: boolean }[]
54}
55
56export type Drawn = { source: string; width: number; height: number }
57
58const check = (x: number, y: number, size: number, color: string): string =>
59  `<path d="M${r1(x - size * 0.45)} ${r1(y + size * 0.02)} l${r1(size * 0.32)} ${r1(size * 0.32)} l${r1(size * 0.6)} ${r1(-size * 0.62)}" fill="none" stroke="${color}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
60
61/**
62 * La feuille de route : une station par phase (ou par étape pour un parcours d'une
63 * phase), reliées par une ligne. Station faite pleine et cochée, station en cours
64 * pulsante, segment en cours animé vers la suite, suites facultatives en pointillé,
65 * détours en bulles au-dessus de leur station.
66 */
67export const roadmapSvg = (nodes: readonly RoadNode[], width: number): Drawn => {
68  const n = nodes.length
69  const w = Math.max(200, Math.round(width))
70  const hasDetours = nodes.some(x => (x.detours?.length ?? 0) > 0)
71  const top = hasDetours ? 34 : 8
72  const cy = top + 14
73  const pad = n > 1 ? Math.max(34, Math.min(64, w / (n * 2))) : w / 2
74  const xs = nodes.map((_, i) => (n > 1 ? pad + (i * (w - 2 * pad)) / (n - 1) : w / 2))
75  const slot = n > 1 ? (w - 2 * pad) / (n - 1) : w
76  const charsAt = (i: number): number => {
77    const isEdge = n > 1 && (i === 0 || i === n - 1)
78    const room = n > 1 ? (isEdge ? Math.min(slot, pad + slot / 2) : slot) : Math.min(w, 260)
79    return Math.max(3, Math.floor((room - 6) / CHAR_PX))
80  }
81  const labels = nodes.map((node, i) => wrap2(node.label, charsAt(i)))
82  const isTall = labels.some(l => l.length > 1)
83  const subY = cy + (isTall ? 58 : 45)
84  const height = cy + (isTall ? 64 : 50)
85  const defs: string[] = []
86  const lines: string[] = []
87  const marks: string[] = []
88  const texts: string[] = []
89  const bubbles: string[] = []
90
91  for (let i = 0; i < n - 1; i++) {
92    const a = nodes[i]!
93    const b = nodes[i + 1]!
94    const x1 = r1(xs[i]! + 15)
95    const x2 = r1(xs[i + 1]! - 15)
96    if (x2 <= x1) continue
97    const isDone = (a.state === 'done' || a.state === 'skipped') && (b.state === 'done' || b.state === 'active' || b.state === 'skipped')
98    if (isDone) {
99      defs.push(
100        `<linearGradient id="g${i}" gradientUnits="userSpaceOnUse" x1="${x1}" y1="0" x2="${x2}" y2="0"><stop offset="0" stop-color="${a.color}"/><stop offset="1" stop-color="${b.color}"/></linearGradient>`,
101      )
102      lines.push(`<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="url(#g${i})" stroke-width="3" stroke-linecap="round"/>`)
103    } else if (a.state === 'active' || (a.state === 'done' && b.state === 'next')) {
104      lines.push(
105        `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${a.color}" stroke-opacity="0.8" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="6 7"><animate attributeName="stroke-dashoffset" from="13" to="0" dur="0.9s" repeatCount="indefinite"/></line>`,
106      )
107    } else {
108      lines.push(
109        `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${MUTED}" stroke-opacity="0.5" stroke-width="2" stroke-linecap="round" stroke-dasharray="2 6"/>`,
110      )
111    }
112  }
113
114  nodes.forEach((node, i) => {
115    const x = r1(xs[i]!)
116    const c = node.color
117    switch (node.state) {
118      case 'done':
119        marks.push(`<circle cx="${x}" cy="${cy}" r="11" fill="${c}"/>${check(x, cy, 10, WHITE)}`)
120        break
121      case 'active':
122        marks.push(
123          `<circle cx="${x}" cy="${cy}" r="12" fill="${c}" opacity="0.4"><animate attributeName="r" values="12;21" dur="1.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.45;0" dur="1.8s" repeatCount="indefinite"/></circle>` +
124            `<circle cx="${x}" cy="${cy}" r="12" fill="${c}"/><circle cx="${x}" cy="${cy}" r="4.5" fill="${WHITE}"/>`,
125        )
126        break
127      case 'next':
128        marks.push(
129          `<circle cx="${x}" cy="${cy}" r="10.5" fill="${c}" fill-opacity="0.16" stroke="${c}" stroke-width="2.4"><animate attributeName="stroke-opacity" values="1;0.35;1" dur="2.2s" repeatCount="indefinite"/></circle>` +
130            `<path d="M${r1(x - 3)} ${r1(cy - 4.5)} l7 4.5 l-7 4.5 z" fill="${c}"/>`,
131        )
132        break
133      case 'skipped':
134        marks.push(
135          `<circle cx="${x}" cy="${cy}" r="8" fill="none" stroke="${MUTED}" stroke-opacity="0.65" stroke-width="1.6" stroke-dasharray="2 3"/>` +
136            `<line x1="${x - 4}" y1="${cy + 4}" x2="${x + 4}" y2="${cy - 4}" stroke="${MUTED}" stroke-opacity="0.65" stroke-width="1.6" stroke-linecap="round"/>`,
137        )
138        break
139      default:
140        marks.push(
141          `<circle cx="${x}" cy="${cy}" r="9.5" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-opacity="${node.isOptional ? 0.5 : 0.7}" stroke-width="1.8"${node.isOptional ? ' stroke-dasharray="3 3"' : ''}/>` +
142            `<text x="${x}" y="${r1(cy + 3.5)}" text-anchor="middle" ${FONT} font-size="10" font-weight="700" fill="${c}" fill-opacity="0.85">${i + 1}</text>`,
143        )
144    }
145
146    // Libellés : centrés, sauf aux bords où ils s'alignent sur la station pour rester entiers.
147    const isEdgeLeft = n > 1 && i === 0
148    const isEdgeRight = n > 1 && i === n - 1
149    const chars = charsAt(i)
150    const anchor = isEdgeLeft && pad < 46 ? 'start' : isEdgeRight && pad < 46 ? 'end' : 'middle'
151    const tx = anchor === 'start' ? r1(x - 12) : anchor === 'end' ? r1(x + 12) : x
152    const isLit = node.state === 'active' || node.state === 'done' || node.state === 'next'
153    const weight = node.state === 'active' ? 700 : isLit ? 600 : 500
154    const lines = labels[i]!
155    texts.push(
156      `<text x="${tx}" y="${cy + 30}" text-anchor="${anchor}" ${FONT} font-size="11.5" font-weight="${weight}" fill="${isLit ? c : MUTED}"${node.state === 'skipped' ? ' fill-opacity="0.7"' : ''}>` +
157        lines.map((l, j) => `<tspan x="${tx}" dy="${j === 0 ? 0 : 13}">${esc(l)}</tspan>`).join('') +
158        '</text>',
159    )
160    if (node.sub) {
161      texts.push(
162        `<text x="${tx}" y="${subY}" text-anchor="${anchor}" ${FONT} font-size="10" font-weight="${node.state === 'active' ? 600 : 400}" fill="${node.state === 'active' ? c : MUTED}">${esc(cut(node.sub, chars + 2))}</text>`,
163      )
164    }
165
166    // Détours : la dernière bulle au-dessus de la station, avec le nombre des autres.
167    const detours = node.detours ?? []
168    if (detours.length > 0) {
169      const last = detours[detours.length - 1]!
170      const label = `↳ ${last.label}${detours.length > 1 ? ` +${detours.length - 1}` : ''}`
171      const shown = cut(label, Math.max(6, Math.floor((slot - 10) / 6)))
172      const bw = Math.round(shown.length * 6.1 + 16)
173      const bx = Math.min(Math.max(2, x - bw / 2), w - bw - 2)
174      const by = 3
175      bubbles.push(
176        `<line x1="${x}" y1="${by + 18}" x2="${x}" y2="${cy - 13}" stroke="${last.color}" stroke-opacity="0.55" stroke-width="1.4" stroke-dasharray="2 2"/>` +
177          `<rect x="${r1(bx)}" y="${by}" width="${bw}" height="18" rx="9" fill="${last.color}" fill-opacity="0.14" stroke="${last.color}" stroke-opacity="0.75" stroke-width="1.2">${last.isActive ? '<animate attributeName="stroke-opacity" values="0.9;0.3;0.9" dur="1.6s" repeatCount="indefinite"/>' : ''}</rect>` +
178          `<text x="${r1(bx + bw / 2)}" y="${by + 12.5}" text-anchor="middle" ${FONT} font-size="10" font-weight="600" fill="${last.color}">${esc(shown)}</text>`,
179      )
180    }
181  })
182
183  const body = `${defs.length > 0 ? `<defs>${defs.join('')}</defs>` : ''}${lines.join('')}${bubbles.join('')}${marks.join('')}${texts.join('')}`
184  return { source: svg(w, height, body), width: w, height }
185}
186
187/** Anneau de progression, le pourcentage au centre. */
188export const ringSvg = (ratio: number, color: string, size = 60): Drawn => {
189  const r = size / 2 - 6
190  const c = 2 * Math.PI * r
191  const value = Math.min(1, Math.max(0, ratio))
192  const len = r1(c * value)
193  const mid = size / 2
194  const body =
195    `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.22" stroke-width="7"/>` +
196    (value > 0
197      ? `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${color}" stroke-width="7" stroke-linecap="round" stroke-dasharray="${len} ${r1(c)}" transform="rotate(-90 ${mid} ${mid})"/>`
198      : '') +
199    `<text x="${mid}" y="${mid + 5}" text-anchor="middle" ${FONT} font-size="14" font-weight="700" fill="${color}">${Math.round(value * 100)}%</text>`
200  return { source: svg(size, size, body), width: size, height: size }
201}
202
203export type BarPart = { value: number; color: string; isLive?: boolean }
204
205/** Barre segmentée arrondie (fait, en cours, bloqué…), la part vivante qui respire. */
206export const barSvg = (parts: readonly BarPart[], total: number, width: number, height = 9): Drawn => {
207  const w = Math.max(60, Math.round(width))
208  const sum = Math.max(total, parts.reduce((n, p) => n + p.value, 0), 1)
209  let x = 0
210  const segs: string[] = []
211  for (const part of parts) {
212    if (part.value <= 0) continue
213    const sw = (part.value / sum) * w
214    segs.push(
215      `<rect x="${r1(x)}" y="0" width="${r1(sw)}" height="${height}" fill="${part.color}">${part.isLive ? '<animate attributeName="opacity" values="1;0.55;1" dur="1.6s" repeatCount="indefinite"/>' : ''}</rect>`,
216    )
217    x += sw
218  }
219  const body =
220    `<defs><clipPath id="bar"><rect width="${w}" height="${height}" rx="${height / 2}"/></clipPath></defs>` +
221    `<g clip-path="url(#bar)"><rect width="${w}" height="${height}" fill="${MUTED}" fill-opacity="0.22"/>${segs.join('')}</g>`
222  return { source: svg(w, height, body), width: w, height }
223}
224
225/** Les derniers lancements de tests : un point vert ou rouge chacun, le dernier qui pulse. */
226export const dotsSvg = (runs: readonly { ok: boolean }[], width: number, green: string, red: string): Drawn => {
227  const step = 12
228  const max = Math.max(1, Math.floor((Math.max(80, width) - 10) / step))
229  const shown = runs.slice(-max)
230  const w = Math.max(20, shown.length * step + 8)
231  const h = 16
232  const dots = shown.map((run, i) => {
233    const x = 8 + i * step
234    const c = run.ok ? green : red
235    const isLast = i === shown.length - 1
236    return (
237      (isLast
238        ? `<circle cx="${x}" cy="8" r="4" fill="${c}" opacity="0.4"><animate attributeName="r" values="4;8" dur="1.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.45;0" dur="1.8s" repeatCount="indefinite"/></circle>`
239        : '') + `<circle cx="${x}" cy="8" r="${isLast ? 4.6 : 3.8}" fill="${c}"/>`
240    )
241  })
242  return { source: svg(w, h, dots.join('')), width: w, height: h }
243}
244
245/** Un arc qui tourne : un sous-agent au travail. */
246export const spinnerSvg = (color: string, size = 14): Drawn => {
247  const m = size / 2
248  const r = m - 2
249  const body =
250    `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.22" stroke-width="2"/>` +
251    `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${r1(r * 2)} ${r1(r * 5)}"><animateTransform attributeName="transform" type="rotate" from="0 ${m} ${m}" to="360 ${m} ${m}" dur="0.9s" repeatCount="indefinite"/></circle>`
252  return { source: svg(size, size, body), width: size, height: size }
253}
254
255/** Point d'état ; pulsant quand quelque chose tourne. */
256export const pulseSvg = (color: string, isPulsing: boolean, size = 12): Drawn => {
257  const m = size / 2
258  const body =
259    (isPulsing
260      ? `<circle cx="${m}" cy="${m}" r="3" fill="${color}" opacity="0.5"><animate attributeName="r" values="3;${m}" dur="1.4s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.55;0" dur="1.4s" repeatCount="indefinite"/></circle>`
261      : '') + `<circle cx="${m}" cy="${m}" r="3.4" fill="${color}"/>`
262  return { source: svg(size, size, body), width: size, height: size }
263}
264
265/** La feuille de route du bandeau : des pastilles, sans libellés. */
266export const miniRoadSvg = (nodes: readonly { color: string; state: NodeState; isOptional?: boolean }[]): Drawn => {
267  const gap = 17
268  const h = 16
269  const w = Math.max(16, (nodes.length - 1) * gap + 16)
270  const cy = h / 2
271  const parts: string[] = []
272  nodes.forEach((node, i) => {
273    if (i === nodes.length - 1) return
274    const next = nodes[i + 1]!
275    const x1 = 8 + i * gap + 6
276    const x2 = 8 + (i + 1) * gap - 6
277    const isDone = node.state === 'done' || node.state === 'skipped'
278    parts.push(
279      isDone && next.state !== 'pending' && next.state !== 'next'
280        ? `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${node.color}" stroke-width="2" stroke-linecap="round"/>`
281        : `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${MUTED}" stroke-opacity="0.5" stroke-width="1.6" stroke-dasharray="2 3"/>`,
282    )
283  })
284  nodes.forEach((node, i) => {
285    const x = 8 + i * gap
286    const c = node.color
287    if (node.state === 'done') parts.push(`<circle cx="${x}" cy="${cy}" r="4.6" fill="${c}"/>`)
288    else if (node.state === 'active')
289      parts.push(
290        `<circle cx="${x}" cy="${cy}" r="5" fill="${c}" opacity="0.4"><animate attributeName="r" values="5;8" dur="1.6s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0" dur="1.6s" repeatCount="indefinite"/></circle><circle cx="${x}" cy="${cy}" r="5.2" fill="${c}"/><circle cx="${x}" cy="${cy}" r="2" fill="${WHITE}"/>`,
291      )
292    else if (node.state === 'next') parts.push(`<circle cx="${x}" cy="${cy}" r="4.4" fill="none" stroke="${c}" stroke-width="2"/>`)
293    else if (node.state === 'skipped') parts.push(`<circle cx="${x}" cy="${cy}" r="3" fill="${MUTED}" fill-opacity="0.5"/>`)
294    else
295      parts.push(
296        `<circle cx="${x}" cy="${cy}" r="4.2" fill="none" stroke="${c}" stroke-opacity="${node.isOptional ? 0.45 : 0.7}" stroke-width="1.5"${node.isOptional ? ' stroke-dasharray="2 2"' : ''}/>`,
297      )
298  })
299  return { source: svg(w, h, parts.join('')), width: w, height: h }
300}
301
302// ─── Propre à asana-board ─────────────────────────────────────────────────
303
304/**
305 * Anneau de répartition : un arc par part (groupes de statut), le total au centre.
306 * Sans part, un anneau gris et « 0 ».
307 */
308export const donutSvg = (parts: readonly BarPart[], total: number, size = 60): Drawn => {
309  const r = size / 2 - 6
310  const c = 2 * Math.PI * r
311  const mid = size / 2
312  const sum = parts.reduce((n, p) => n + Math.max(0, p.value), 0)
313  const gapLen = sum > 0 && parts.filter(p => p.value > 0).length > 1 ? 2 : 0
314  let offset = 0
315  const arcs: string[] = []
316  for (const part of parts) {
317    if (part.value <= 0 || sum === 0) continue
318    const len = (part.value / sum) * c
319    const drawn = Math.max(0.5, len - gapLen)
320    arcs.push(
321      `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${part.color}" stroke-width="7" stroke-dasharray="${r1(drawn)} ${r1(c)}" stroke-dashoffset="${r1(-offset)}" transform="rotate(-90 ${mid} ${mid})"/>`,
322    )
323    offset += len
324  }
325  const label = String(Math.max(0, Math.round(total)))
326  const body =
327    `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.22" stroke-width="7"/>` +
328    arcs.join('') +
329    `<text x="${mid}" y="${mid + 5}" text-anchor="middle" ${FONT} font-size="${label.length > 2 ? 12 : 15}" font-weight="700" fill="${MUTED}">${esc(label)}</text>`
330  return { source: svg(size, size, body), width: size, height: size }
331}
332
types/index.d.ts 71 lines
1// Une tâche Asana telle que le panneau la garde : sous-tâches aplaties, parent noté.
2export type AsanaTask = {
3  gid: string;
4  name: string;
5  completed: boolean;
6  assignee: string | null;
7  notes: string;
8  statut: string | null;
9  due: string | null;
10  modified: string | null;
11  url: string | null;
12  parent: string | null;
13  parentName: string | null;
14  childCount: number;
15};
16
17// Ce que le panneau affiche autour de la liste : le projet, le chargement, l'erreur.
18// `error` n'est écrit que par une lecture demandée (/asana, ↻) ; une lecture de fond
19// (démarrage, relecture après un outil de Claude) échoue en silence. `loadedAt` non nul
20// = au moins une lecture réussie : le bandeau ne s'affiche qu'à partir de là.
21export type PanelMeta = {
22  folder: string | null;
23  section: string | null;
24  sectionGid: string | null;
25  projectGid: string | null;
26  server: string | null;
27  loading: string | null;
28  error: string | null;
29  warning: string | null;
30  loadedAt: number | null;
31};
32
33// Le retour gardé sous une tâche ou sous le formulaire, en plus du toast : une erreur
34// d'écriture surtout. `gid` vaut "new" pour le formulaire de création.
35export type Notice = { gid: string; text: string; isError: boolean } | null;
36
37// Le formulaire « Nouvelle tâche », gardé pendant la saisie.
38export type Draft = {
39  name: string;
40  statut: string;
41  who: string;
42  where: string;
43  contexte: string;
44  fini: string;
45  due: string;
46};
47
48declare module "claude-code" {
49  interface PluginState {
50    "asana-board": {
51      tasks: AsanaTask[];
52      meta: PanelMeta;
53      expanded: string[];
54      showDone: boolean;
55      collapsed: string[];
56      who: string;
57      notice: Notice;
58      editing: string | null;
59      confirmDelete: string | null;
60      creating: boolean;
61      draft: Draft;
62      /** Bandeau masqué par « x » ou /asana hide, le temps de la session. */
63      isBandHidden: boolean;
64      /** Tâches (gid, ou « new » pour le formulaire) dont une écriture est en cours. */
65      busy: string[];
66      /** Groupes montrés en entier (« ▸ tout voir »), par clé de groupe ou « done ». */
67      showAll: string[];
68    };
69  }
70}
71