diff --git a/internal/loki/ui/src/js/15-files.js b/internal/loki/ui/src/js/15-files.js
new file mode 100644
index 0000000..b474399
--- /dev/null
+++ b/internal/loki/ui/src/js/15-files.js
@@ -0,0 +1,175 @@
+// ─── Fichiers du dossier de travail ──────────────────────────────────────────
+// L'agent écrit des rapports, des scripts, des exports, des captures. Jusqu'ici
+// on ne pouvait récupérer un fichier que si le modèle avait pensé à en mettre le
+// lien dans sa réponse — tout le reste restait invisible, et faire le ménage
+// demandait un shell.
+//
+// Le panneau est construit EN DOM, jamais en innerHTML : les noms de fichiers
+// viennent du modèle et du système de fichiers, donc d'ailleurs. Même règle que
+// pour les titres de discussions (16-conversations.js).
+//
+// Le téléchargement réutilise downloadWorkspaceFile (14-attach.js), qui sait
+// déjà gérer le blob et les cas particuliers du navigateur.
+
+let FILES_DIR = ''; // dossier affiché, relatif au dossier de travail
+let FILES_OPEN = false;
+
+// Ouvre/ferme le panneau. L'état est persisté : sur un grand écran on garde
+// souvent le panneau ouvert, et le rouvrir à chaque rechargement est pénible.
+function toggleFiles(force){
+ FILES_OPEN = (force === undefined) ? !FILES_OPEN : !!force;
+ document.documentElement.setAttribute('data-files', FILES_OPEN ? '1' : '0');
+ const btn = document.getElementById('files-btn');
+ if(btn) btn.setAttribute('aria-expanded', FILES_OPEN ? 'true' : 'false');
+ try{ localStorage.setItem('loki-files-open', FILES_OPEN ? '1' : '0'); }catch(_){}
+ // Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` :
+ // la gouttière de barre de défilement mesurée dans --sbw resterait celle de
+ // l'ancienne largeur, et la carte de saisie serait décalée du fil. La hauteur,
+ // elle, est suivie par un ResizeObserver et n'a besoin de rien.
+ if(typeof syncGutter === 'function') requestAnimationFrame(syncGutter);
+ if(FILES_OPEN) loadFiles();
+}
+
+// Restaure l'état à l'ouverture de la page. Sur petit écran le panneau est un
+// tiroir qui masque toute la conversation : on ne le rouvre jamais tout seul.
+function initFiles(){
+ let want = false;
+ try{ want = localStorage.getItem('loki-files-open') === '1'; }catch(_){}
+ toggleFiles(want && window.innerWidth > 720);
+}
+
+async function loadFiles(dir){
+ if(dir !== undefined) FILES_DIR = dir;
+ const list = document.getElementById('files-list');
+ const foot = document.getElementById('files-foot');
+ if(!list) return;
+ let r;
+ try{ r = await jget('/api/chat/files?dir='+encodeURIComponent(FILES_DIR)); }
+ catch(_){ filesMsg('lecture impossible'); return; }
+ if(!r.ok){
+ // Un dossier supprimé entre deux affichages (par l'agent, ou depuis un autre
+ // onglet) ne doit pas laisser le panneau bloqué : on remonte à la racine.
+ if(FILES_DIR){ FILES_DIR = ''; return loadFiles(); }
+ filesMsg(r.error || 'lecture impossible'); return;
+ }
+
+ renderCrumb(r);
+ list.textContent = '';
+ if(!r.entries.length){
+ const d = document.createElement('div');
+ d.className = 'files-empty muted';
+ d.textContent = FILES_DIR ? '(dossier vide)' : "L'agent n'a encore rien écrit ici.";
+ list.appendChild(d);
+ }
+ for(const e of r.entries) list.appendChild(fileRow(e));
+
+ foot.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
+ foot.title = r.root || '';
+}
+
+function filesMsg(txt){
+ const list = document.getElementById('files-list');
+ if(!list) return;
+ list.textContent = '';
+ const d = document.createElement('div');
+ d.className = 'files-empty'; d.style.color = 'var(--err)'; d.textContent = txt;
+ list.appendChild(d);
+}
+
+// Fil d'Ariane : « travail / captures / », chaque segment cliquable.
+function renderCrumb(r){
+ const c = document.getElementById('files-crumb');
+ c.textContent = '';
+ const seg = (label, dir, last)=>{
+ const b = document.createElement('button');
+ b.className = 'files-crumb-b' + (last ? ' on' : '');
+ b.textContent = label;
+ if(!last) b.onclick = ()=>loadFiles(dir);
+ else b.disabled = true;
+ c.appendChild(b);
+ };
+ const parts = r.dir ? r.dir.split('/') : [];
+ seg('travail', '', parts.length === 0);
+ let acc = '';
+ parts.forEach((p, i)=>{
+ acc = acc ? acc + '/' + p : p;
+ const sep = document.createElement('span');
+ sep.className = 'files-crumb-s'; sep.textContent = '/';
+ c.appendChild(sep);
+ seg(p, acc, i === parts.length - 1);
+ });
+}
+
+function fileRow(e){
+ const row = document.createElement('div');
+ row.className = 'files-row' + (e.dir ? ' isdir' : '');
+
+ const ic = document.createElement('span');
+ ic.className = 'files-ic';
+ ic.textContent = e.dir ? '▸' : (e.image ? '▣' : '·');
+ row.appendChild(ic);
+
+ const mid = document.createElement('span');
+ mid.className = 'files-mid';
+ const nm = document.createElement('span');
+ nm.className = 'files-name';
+ nm.textContent = e.name;
+ nm.title = e.name;
+ const meta = document.createElement('span');
+ meta.className = 'files-meta';
+ meta.textContent = fmtSize(e.size) + (e.dir && e.items ? ' · ' + e.items + ' fich.' : '') + ' · ' + fmtWhen(e.mod);
+ mid.append(nm, meta);
+ row.appendChild(mid);
+
+ // Un dossier s'ouvre ; un fichier se télécharge. Le clic sur la ligne fait
+ // l'action évidente, les boutons ne servent qu'au reste.
+ if(e.dir){
+ mid.onclick = ()=>loadFiles(e.path);
+ mid.style.cursor = 'pointer';
+ } else {
+ mid.onclick = ()=>downloadWorkspaceFile(e.path, e.name, row);
+ mid.style.cursor = 'pointer';
+ }
+
+ const acts = document.createElement('span');
+ acts.className = 'files-acts';
+ if(!e.dir){
+ const dl = document.createElement('button');
+ dl.className = 'iconbtn'; dl.textContent = '↓'; dl.title = 'télécharger';
+ dl.onclick = ev=>{ ev.stopPropagation(); downloadWorkspaceFile(e.path, e.name, row); };
+ acts.appendChild(dl);
+ }
+ const rm = document.createElement('button');
+ rm.className = 'iconbtn'; rm.textContent = '🗑'; rm.title = 'supprimer';
+ rm.onclick = ev=>{ ev.stopPropagation(); removeFile(e); };
+ acts.appendChild(rm);
+ row.appendChild(acts);
+ return row;
+}
+
+// Suppression. Toujours confirmée, et l'invite dit ce qui part : un dossier
+// emporte tout son contenu, ce qu'un nom seul ne laisse pas deviner.
+async function removeFile(e){
+ const quoi = e.dir
+ ? 'Le dossier « '+e.name+' » et tout son contenu ('+(e.items||0)+' fichiers, '+fmtSize(e.size)+') seront supprimés.'
+ : '« '+e.name+' » ('+fmtSize(e.size)+') sera supprimé.';
+ if(!await askConfirm(quoi + '\n\nC\'est définitif — le fichier ne part pas à la corbeille.',
+ {title:'Supprimer ?', okText:'Supprimer', danger:true})) return;
+ const r = await jpost('/api/chat/file/delete', {path: e.path});
+ if(!r.ok){ toast('suppression impossible : '+(r.error||'')); return; }
+ toast('supprimé — '+fmtSize(r.freed||0)+' libérés');
+ loadFiles();
+}
+
+// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
+// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
+document.addEventListener('DOMContentLoaded', initFiles);
+
+function fmtWhen(sec){
+ if(!sec) return '';
+ const d = new Date(sec*1000), now = new Date();
+ const memeJour = d.toDateString() === now.toDateString();
+ return memeJour
+ ? d.toLocaleTimeString(undefined, {hour:'2-digit', minute:'2-digit'})
+ : d.toLocaleDateString(undefined, {day:'2-digit', month:'2-digit'});
+}
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index 0ac2687..0a226b0 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -1365,3 +1365,72 @@ button:hover{border-color:var(--dim);color:var(--text)}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default}
+
+/* --- Panneau « Fichiers » (droite) ------------------------------------
+ Symétrique des réglages à gauche : une colonne fixe qui pousse la
+ conversation au lieu de la recouvrir. C'est possible ici parce que la largeur
+ de lecture est pilotée par --measure et que #chat se recentre tout seul dans
+ la place qui reste — le fil se resserre, il ne passe pas dessous.
+ Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
+ sinon il conserverait sa gouttière et le fil resterait décentré. */
+#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
+ background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
+ overflow-y:auto;flex-direction:column;gap:10px}
+html[data-files="1"] #files{display:flex}
+#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
+.files-head{display:flex;align-items:center;gap:8px}
+.files-head .stitle{flex:1}
+#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
+ background:transparent;border:1px solid transparent;color:var(--dim);border-radius:8px}
+#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
+/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
+ discussion dans le chemin dépasse largement 320 px. */
+.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
+.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
+ background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
+.files-crumb-b.on{color:var(--text);cursor:default}
+.files-crumb-s{color:var(--dim);opacity:.5}
+.files-list{display:flex;flex-direction:column;flex:1;min-height:0}
+.files-empty{font-size:12px;line-height:1.5;padding:10px 2px}
+/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
+ survol : au repos on veut lire des noms de fichiers, pas une grille de
+ boutons. Sur écran tactile (pas de survol) elles restent visibles. */
+.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
+.files-row:hover{background:var(--row-bg-on)}
+.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
+.files-row.isdir .files-ic{color:var(--accent)}
+.files-mid{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px}
+.files-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
+.files-meta{font-family:var(--mono);font-size:10px;color:var(--dim);white-space:nowrap}
+.files-acts{flex:none;display:flex;gap:2px;opacity:0;transition:opacity .12s}
+.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
+@media (hover:none){.files-acts{opacity:1}}
+.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
+ border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
+.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
+.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border)}
+/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
+.files-row.busy{opacity:.55;pointer-events:none}
+/* Bouton dossier du composeur : même gabarit que le trombone, et teinté quand
+ le panneau est ouvert — l'état se lit sans regarder à droite. */
+#files-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0;
+ display:flex;align-items:center;justify-content:center;border-radius:999px;
+ background:transparent;border:1px solid transparent;color:var(--dim)}
+#files-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)}
+#files-btn:active{transform:scale(.9)}
+#files-btn svg{display:block}
+html[data-files="1"] #files-btn{color:var(--accent)}
+/* Petit écran : tiroir par la droite, comme les réglages par la gauche. Pousser
+ une colonne de 320 px sur un téléphone ne laisserait rien à la conversation. */
+@media (max-width:720px){
+ #files{position:fixed;inset:0 0 0 auto;width:85%;max-width:340px;z-index:15;
+ background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));
+ padding-right:calc(16px + env(safe-area-inset-right))}
+ html[data-files="1"] #files-backdrop{display:block}
+}
+@media (min-width:721px){
+ /* Dans la charte « Terre », le panneau est une carte comme les autres. */
+ #files{background:var(--panel);border-left-color:transparent;border-radius:20px;
+ box-shadow:var(--shadow);margin:0}
+}
diff --git a/internal/loki/web_files.go b/internal/loki/web_files.go
new file mode 100644
index 0000000..4b89286
--- /dev/null
+++ b/internal/loki/web_files.go
@@ -0,0 +1,219 @@
+package loki
+
+// web_files.go — parcourir et supprimer les fichiers produits par l'agent.
+//
+// L'agent écrit dans son dossier de travail (chat_workspace.go) : rapports,
+// scripts, exports, captures d'écran. Jusqu'ici on ne pouvait récupérer un
+// fichier que si le modèle en avait mis le lien dans sa réponse — tout ce qu'il
+// produisait sans le dire restait invisible, et rien ne permettait de faire le
+// ménage sans passer par un shell.
+//
+// Le téléchargement existait déjà (handleChatFile, web_upload.go). Ce fichier
+// ajoute les deux verbes qui manquaient : LISTER et SUPPRIMER.
+//
+// Tout est borné par workspaceRel (web_upload.go), qui résout les liens
+// symboliques des DEUX côtés avant de comparer : un lien posé dans le dossier
+// de travail ne peut pas servir d'échappatoire vers le reste du disque.
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// wsEntry — une ligne du panneau. Pour un dossier, Size est la somme récursive
+// de son contenu : c'est ce qu'on libère en le supprimant, donc c'est ce chiffre
+// qui aide à décider.
+type wsEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"` // relatif au dossier de travail, séparateurs /
+ Dir bool `json:"dir"`
+ Size int64 `json:"size"`
+ Mod int64 `json:"mod"` // date de modification, epoch en secondes
+ Image bool `json:"image"` // affichable via /api/chat/image
+ Items int `json:"items,omitempty"`
+}
+
+// wsImageExt : extensions présentées comme images dans le panneau. Simple
+// indice d'affichage — /api/chat/image renifle le CONTENU avant de servir quoi
+// que ce soit, donc un .png qui contient du HTML est refusé là-bas, pas ici.
+var wsImageExt = map[string]bool{
+ ".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
+ ".webp": true, ".bmp": true, ".svg": true, ".avif": true,
+}
+
+// wsWalkLimit borne le parcours récursif qui calcule la taille d'un dossier.
+// Un workspace part de zéro et grossit lentement, mais rien n'empêche l'agent
+// d'y dézipper 200 000 fichiers : la liste doit rester instantanée, quitte à
+// annoncer une taille approchée (Items le dit).
+const wsWalkLimit = 20000
+
+// wsResolve traduit un chemin RELATIF venu du client en chemin absolu vérifié.
+// La chaîne vide et "." désignent la racine du dossier de travail — cas que
+// workspaceRel rejette (il renvoie false sur "."), d'où ce sas.
+func wsResolve(rel string) (string, bool) {
+ rel = strings.TrimSpace(rel)
+ rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
+ if rel == "" || rel == "." {
+ return agentWorkspace(), true
+ }
+ abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
+ if _, ok := workspaceRel(abs); !ok {
+ return "", false
+ }
+ return abs, true
+}
+
+// dirSize additionne le contenu d'un dossier, en s'arrêtant à wsWalkLimit
+// entrées. Renvoie aussi le nombre de fichiers rencontrés.
+func dirSize(root string) (total int64, count int) {
+ var walk func(string)
+ walk = func(dir string) {
+ if count >= wsWalkLimit {
+ return
+ }
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, e := range ents {
+ if count >= wsWalkLimit {
+ return
+ }
+ // Un lien symbolique n'est pas suivi : sa cible ne nous appartient
+ // pas, et une boucle de liens ferait tourner ce parcours sans fin.
+ if e.Type()&os.ModeSymlink != 0 {
+ continue
+ }
+ if e.IsDir() {
+ walk(filepath.Join(dir, e.Name()))
+ continue
+ }
+ if info, err := e.Info(); err == nil {
+ total += info.Size()
+ count++
+ }
+ }
+ }
+ walk(root)
+ return total, count
+}
+
+// handleChatFiles liste un dossier du workspace.
+//
+// GET /api/chat/files?dir=
+// → {ok, dir, parent, entries:[…], total, count}
+//
+// `total` et `count` portent sur le dossier de travail ENTIER, pas sur le
+// dossier affiché : c'est l'occupation disque de l'agent, l'information qu'on
+// veut avoir sous les yeux en faisant le ménage.
+func handleChatFiles(w http.ResponseWriter, r *http.Request) {
+ rel := r.URL.Query().Get("dir")
+ abs, ok := wsResolve(rel)
+ if !ok {
+ sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
+ return
+ }
+ st, err := os.Stat(abs)
+ if err != nil || !st.IsDir() {
+ sendJSON(w, 404, map[string]any{"ok": false, "error": "dossier introuvable"})
+ return
+ }
+ ents, err := os.ReadDir(abs)
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+
+ cur, _ := workspaceRel(abs) // "" à la racine
+ entries := []wsEntry{}
+ for _, e := range ents {
+ name := e.Name()
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ p := name
+ if cur != "" {
+ p = cur + "/" + name
+ }
+ it := wsEntry{Name: name, Path: p, Dir: e.IsDir(), Mod: info.ModTime().Unix()}
+ if e.IsDir() {
+ it.Size, it.Items = dirSize(filepath.Join(abs, name))
+ } else {
+ it.Size = info.Size()
+ it.Image = wsImageExt[strings.ToLower(filepath.Ext(name))]
+ }
+ entries = append(entries, it)
+ }
+ // Dossiers d'abord, puis du plus récent au plus ancien : ce qu'on cherche
+ // après un tour d'agent, c'est ce qu'il vient d'écrire.
+ sort.SliceStable(entries, func(i, j int) bool {
+ if entries[i].Dir != entries[j].Dir {
+ return entries[i].Dir
+ }
+ return entries[i].Mod > entries[j].Mod
+ })
+
+ parent := ""
+ if cur != "" {
+ if i := strings.LastIndexByte(cur, '/'); i >= 0 {
+ parent = cur[:i]
+ }
+ }
+ total, count := dirSize(agentWorkspace())
+ sendJSON(w, 200, map[string]any{
+ "ok": true, "dir": cur, "parent": parent, "at_root": cur == "",
+ "entries": entries, "total": total, "count": count,
+ "root": agentWorkspace(),
+ })
+}
+
+// handleChatFileDelete supprime un fichier ou un dossier du workspace.
+//
+// POST /api/chat/file/delete {"path":"rapport.md"}
+// → {ok, freed}
+//
+// La racine du dossier de travail est refusée : « tout supprimer » n'est pas un
+// geste qu'on veut à un clic de distance, et l'agent perdrait aussi le dossier
+// dans lequel il écrit.
+func handleChatFileDelete(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Path string `json:"path"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if strings.TrimSpace(req.Path) == "" {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"})
+ return
+ }
+ abs, ok := wsResolve(req.Path)
+ if !ok {
+ sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"})
+ return
+ }
+ if abs == agentWorkspace() {
+ sendJSON(w, 403, map[string]any{"ok": false, "error": "le dossier de travail lui-même ne se supprime pas"})
+ return
+ }
+ st, err := os.Lstat(abs)
+ if err != nil {
+ sendJSON(w, 404, map[string]any{"ok": false, "error": "introuvable"})
+ return
+ }
+ var freed int64
+ if st.IsDir() {
+ freed, _ = dirSize(abs)
+ err = os.RemoveAll(abs)
+ } else {
+ freed = st.Size()
+ err = os.Remove(abs)
+ }
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{"ok": true, "freed": freed})
+}
diff --git a/internal/loki/web_files_test.go b/internal/loki/web_files_test.go
new file mode 100644
index 0000000..8c6acc9
--- /dev/null
+++ b/internal/loki/web_files_test.go
@@ -0,0 +1,232 @@
+package loki
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// withWorkspace pointe le dossier de travail sur un dossier temporaire. Le
+// chemin est mémoïsé par un sync.Once (chat_workspace.go), donc on force la
+// variable directement — c'est le seul moyen d'isoler ces tests.
+func withWorkspace(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ // EvalSymlinks : sur macOS /var est un lien vers /private/var, et
+ // workspaceRel compare des chemins résolus. Sans ça tout serait « hors du
+ // dossier de travail ».
+ if r, err := filepath.EvalSymlinks(dir); err == nil {
+ dir = r
+ }
+ // On laisse d'abord agentWorkspace s'initialiser NORMALEMENT : sinon on
+ // consomme le sync.Once avec une valeur vide, et tout test ultérieur qui
+ // appelle agentWorkspace récupère "" après restauration — une panne à
+ // distance, dans un autre fichier, sans rapport visible avec celui-ci.
+ prevPath := agentWorkspace()
+ workspacePath = dir
+ t.Cleanup(func() { workspacePath = prevPath })
+ return dir
+}
+
+func writeFile(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func listFiles(t *testing.T, dir string) map[string]any {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ handleChatFiles(rec, httptest.NewRequest("GET", "/api/chat/files?dir="+dir, nil))
+ var out map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
+ }
+ out["_code"] = rec.Code
+ return out
+}
+
+func TestFilesListSortsAndSizes(t *testing.T) {
+ ws := withWorkspace(t)
+ writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
+ writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
+ writeFile(t, filepath.Join(ws, "captures", "conv1", "b.jpg"), "01234")
+
+ out := listFiles(t, "")
+ if out["ok"] != true {
+ t.Fatalf("liste refusée : %v", out)
+ }
+ entries := out["entries"].([]any)
+ if len(entries) != 2 {
+ t.Fatalf("attendu 2 entrées à la racine, obtenu %d : %v", len(entries), entries)
+ }
+ // Les dossiers passent devant, quelle que soit leur date.
+ first := entries[0].(map[string]any)
+ if first["name"] != "captures" || first["dir"] != true {
+ t.Errorf("le dossier doit être en tête, obtenu %v", first)
+ }
+ // La taille d'un dossier est celle de TOUT son contenu : c'est ce qu'on
+ // libère en le supprimant, donc c'est le chiffre qui compte.
+ if got := first["size"].(float64); got != 15 {
+ t.Errorf("taille du dossier = %v, attendu 15 (10+5)", got)
+ }
+ if got := first["items"].(float64); got != 2 {
+ t.Errorf("items = %v, attendu 2", got)
+ }
+ // Le total porte sur le dossier de travail entier, pas sur le dossier affiché.
+ if got := out["total"].(float64); got != 22 {
+ t.Errorf("total = %v, attendu 22 (7+10+5)", got)
+ }
+ if out["at_root"] != true || out["parent"] != "" {
+ t.Errorf("racine mal signalée : %v", out)
+ }
+}
+
+func TestFilesListSubdirAndImageFlag(t *testing.T) {
+ ws := withWorkspace(t)
+ writeFile(t, filepath.Join(ws, "captures", "conv1", "shot.jpg"), "x")
+ writeFile(t, filepath.Join(ws, "captures", "conv1", "notes.txt"), "y")
+
+ out := listFiles(t, "captures/conv1")
+ if out["ok"] != true {
+ t.Fatalf("liste refusée : %v", out)
+ }
+ if out["dir"] != "captures/conv1" || out["parent"] != "captures" {
+ t.Errorf("fil d'Ariane cassé : dir=%v parent=%v", out["dir"], out["parent"])
+ }
+ img := map[string]bool{}
+ for _, e := range out["entries"].([]any) {
+ m := e.(map[string]any)
+ img[m["name"].(string)] = m["image"] == true
+ // Le chemin renvoyé doit être utilisable tel quel par /api/chat/file.
+ if !strings.HasPrefix(m["path"].(string), "captures/conv1/") {
+ t.Errorf("chemin non préfixé : %v", m["path"])
+ }
+ }
+ if !img["shot.jpg"] {
+ t.Error("shot.jpg devrait être marqué image")
+ }
+ if img["notes.txt"] {
+ t.Error("notes.txt ne devrait pas être marqué image")
+ }
+}
+
+// Le bornage est la partie qui compte : ces routes lisent et SUPPRIMENT sur
+// disque à partir d'un chemin fourni par le navigateur.
+func TestFilesRejectsEscapes(t *testing.T) {
+ ws := withWorkspace(t)
+ writeFile(t, filepath.Join(ws, "dedans.txt"), "ok")
+ secret := filepath.Join(filepath.Dir(ws), "dehors.txt")
+ writeFile(t, secret, "secret")
+
+ for _, bad := range []string{"..", "../", "../..", "/etc", "captures/../..", `..\..`} {
+ out := listFiles(t, bad)
+ if out["ok"] == true {
+ t.Errorf("dir=%q accepté alors qu'il sort du dossier de travail : %v", bad, out)
+ }
+ }
+
+ // Suppression : même bornage, et le fichier voisin doit survivre.
+ for _, bad := range []string{"../dehors.txt", "/etc/passwd", "..", ""} {
+ rec := httptest.NewRecorder()
+ body := strings.NewReader(`{"path":` + jsonQuote(bad) + `}`)
+ handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete", body))
+ if rec.Code == http.StatusOK {
+ t.Errorf("suppression de %q acceptée : %s", bad, rec.Body.String())
+ }
+ }
+ if _, err := os.Stat(secret); err != nil {
+ t.Fatalf("le fichier hors workspace a été supprimé : %v", err)
+ }
+}
+
+// La racine elle-même ne se supprime pas : l'agent perdrait le dossier dans
+// lequel il écrit, et « tout effacer » ne doit pas être à un clic de distance.
+func TestFilesRootNotDeletable(t *testing.T) {
+ ws := withWorkspace(t)
+ writeFile(t, filepath.Join(ws, "a.txt"), "a")
+ for _, p := range []string{".", "./", "/"} {
+ rec := httptest.NewRecorder()
+ handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
+ strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
+ if rec.Code == http.StatusOK {
+ t.Errorf("path=%q a supprimé la racine", p)
+ }
+ }
+ if _, err := os.Stat(ws); err != nil {
+ t.Fatalf("dossier de travail détruit : %v", err)
+ }
+}
+
+func TestFilesDeleteFileAndDir(t *testing.T) {
+ ws := withWorkspace(t)
+ writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
+ writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
+
+ del := func(p string) map[string]any {
+ rec := httptest.NewRecorder()
+ handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
+ strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
+ var out map[string]any
+ _ = json.Unmarshal(rec.Body.Bytes(), &out)
+ out["_code"] = rec.Code
+ return out
+ }
+
+ r := del("rapport.md")
+ if r["ok"] != true || r["freed"].(float64) != 7 {
+ t.Errorf("suppression de fichier : %v", r)
+ }
+ if _, err := os.Stat(filepath.Join(ws, "rapport.md")); !os.IsNotExist(err) {
+ t.Error("le fichier est toujours là")
+ }
+
+ // Un dossier part avec tout son contenu — c'est ce que l'interface annonce.
+ r = del("captures")
+ if r["ok"] != true || r["freed"].(float64) != 10 {
+ t.Errorf("suppression de dossier : %v", r)
+ }
+ if _, err := os.Stat(filepath.Join(ws, "captures")); !os.IsNotExist(err) {
+ t.Error("le dossier est toujours là")
+ }
+ // Le dossier de travail, lui, doit avoir survécu.
+ if _, err := os.Stat(ws); err != nil {
+ t.Fatalf("dossier de travail détruit : %v", err)
+ }
+}
+
+// Un lien symbolique posé dans le dossier de travail ne doit pas servir de
+// passage vers le reste du disque — ni en lecture, ni en suppression.
+func TestFilesSymlinkEscape(t *testing.T) {
+ ws := withWorkspace(t)
+ outside := t.TempDir()
+ writeFile(t, filepath.Join(outside, "cible.txt"), "secret")
+ if err := os.Symlink(outside, filepath.Join(ws, "evasion")); err != nil {
+ t.Skipf("liens symboliques indisponibles : %v", err)
+ }
+ if out := listFiles(t, "evasion"); out["ok"] == true {
+ t.Errorf("le lien symbolique a été suivi : %v", out)
+ }
+ rec := httptest.NewRecorder()
+ handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
+ strings.NewReader(`{"path":"evasion/cible.txt"}`)))
+ if rec.Code == http.StatusOK {
+ t.Errorf("suppression à travers le lien acceptée : %s", rec.Body.String())
+ }
+ if _, err := os.Stat(filepath.Join(outside, "cible.txt")); err != nil {
+ t.Fatalf("fichier hors workspace supprimé : %v", err)
+ }
+}
+
+func jsonQuote(s string) string {
+ b, _ := json.Marshal(s)
+ return string(b)
+}
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index dbb9843..bb0689f 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -217,13 +217,15 @@ func newWebMux() *http.ServeMux {
api("/api/restart", svcHandler("restart"))
api("/api/bench", handleBench)
api("/api/bench/last", handleBenchLast)
- api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
- api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
- api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
- api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
- api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
- api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
- api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
+ api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
+ api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
+ api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
+ api("/api/chat/files", handleChatFiles) // liste un dossier du dossier de travail (panneau Fichiers)
+ api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier du dossier de travail
+ api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
+ api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
+ api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
+ api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
// Discussions : liste, création, bascule, renommage, suppression.
api("/api/conversations", handleConvList)
api("/api/conversations/new", handleConvNew)