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…

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.
CLAUDE.md nomme une section Asana, une lecture de fond remplit le bandeau, sans rien bloquer.Version actuelle : 0.7.2. Historique des versions : CHANGELOG.md.
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
…__get_tasks et …__update_tasks, quel que soit son nom.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.
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.CLAUDE.md nomme une section Asana. Le bandeau apparaît après la première lecture réussie.▸ /asana dans le bandeau, /asana, ou /asana-board:asana en tout premier message.▸ la déplie. « ▶ Travailler dessus » met la demande dans le prompt, sans l'envoyer : Entrée la lance.De haut en bas :
| Zone | Contenu |
|---|---|
| En-tête | Pastille « ◆ 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. |
| Formulaire | Titre (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âche | Une 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. |
Une ligne, deux au plus, sous les bandeaux des autres mods (flow-board, git-board…) :
▸ /asana ouvre le panneau, x masque le bandeau.⚖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.
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 :
| Appel | Ligne |
|---|---|
update_tasks avec completed: true (ou false) | Asana · coche (décoche) « X » |
update_tasks avec une ligne « Statut : » dans notes | Asana · statut « en cours » sur « X » |
update_tasks avec name, assignee, due_on | Asana · renomme « X » en « Y » ; assigne « X » à toi ; confie à Claude « X » ; échéance 2026-10-20 sur « X » |
update_tasks aux modifications de sortes différentes | Asana · modifie « X », « Y » |
add_comment | Asana · commentaire sur « X » |
create_tasks | Asana · crée « Y » (le nom vient de l'appel) |
delete_task | Asana · supprime « X » |
| Commande | Effet |
|---|---|
/asana | Ouvre 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 claude | Ouvre le panneau sur cet onglet. |
/asana hide | Masque le bandeau ; une ligne d'état prend le relais. |
/asana show | Ré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.
| Lecture | Lancée par | En cas d'échec |
|---|---|---|
| Demandée | /asana, ↻ Relire, ↻ Réessayer | L'erreur s'affiche dans le panneau (carte rouge) et, en ligne 2, dans le bandeau. /asana répond « Panneau Asana : <erreur> ». |
| De fond | Le démarrage de la session ; une écriture de Claude dans Asana | Rien : ni toast, ni bandeau d'erreur, ni ligne d'état. |
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./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.| Donnée | Emplacement | Porté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).
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.
claude plugin validate plugins/asana-board
claude plugin test plugins/asana-board
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.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.$.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.~/.claude/mods-archives/asana-board-<version> sur le PC Windows.payment_required). Détail dans skills/suivi-asana/references/conventions.md.ME dans hooks/model.ts).…__update_tasks, …__create_tasks, …__add_comment, …__delete_task), quel que soit le serveur.session.start suivant du mod.hooks/register.tsx 755 lines1// 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}
755hooks/model.ts 357 lines1// 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}
357hooks/view.tsx 814 lines1// 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' })])
814hooks/svg.ts 332 lines1// 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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[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}
332types/index.d.ts 71 lines1// 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