31 KiB
Projets + aperçu réductible — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: l'agent travaille dans des projets (sous-dossiers du workspace) choisis par session depuis le composer, et le panneau d'aperçu se replie.
Architecture: une contextvar dans tools.py re-racine _safe_path/_workspace_root sur workspace/<projet> pour toute la requête (chat, outils, shell, Aider) ; les routes fichiers/git prennent un paramètre project optionnel et posent la même contextvar. La session porte son projet (colonne SQLite). Frontend : chip 📁 dans le composer, store re-racine les appels fichiers.
Tech Stack: FastAPI, SQLite (migrations douces ALTER TABLE), contextvars, React/zustand.
Spec: docs/superpowers/specs/2026-07-19-projets-apercu-design.md
Global Constraints
- Nom de projet :
^[a-z0-9][a-z0-9_-]{0,40}$— sinon 400. - Session sans projet (NULL) = racine du workspace, comportement actuel intact.
- Confinement
_safe_pathinchangé dans sa logique (re-raciné seulement). - Un dépôt git PAR projet (
ensure_gità la création et au premier usage git). - Projet disparu du disque → chat retombe sur la racine + notice SSE.
- UI française, styles existants. Tests :
python -m pytestdepuisbackend/. - Après chaque tâche frontend :
cd frontend && npx tsc -bpasse.
Task 1: Racine active (contextvar) + colonne project + routes sessions
Files:
- Modify:
backend/app/tools.py(contextvar + validation nom) - Modify:
backend/app/db.py(migration + create/set project) - Modify:
backend/app/routes/sessions.py(create + PATCH avec project) - Test:
backend/tests/test_projects.py
Interfaces:
-
Produces:
tools.set_project(name: str | None) -> None(valide le nom, pose la contextvar ;ToolErrorsi nom invalide) ;tools.active_root() -> str(racine effective, créée si absente) ;tools.PROJECT_NAME(regex compilée) ;db.create_session(title, model, project=None);db.set_session_project(sid, project: str | None); PATCH/api/sessions/{sid}accepte{title?, project?}(project: ""= retour racine). -
Step 1: Écrire les tests qui échouent
backend/tests/test_projects.py :
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import db, tools # noqa: E402
from app.config import settings # noqa: E402
db.init_db()
_ROOT = os.path.abspath(settings.workspace_dir)
@pytest.fixture(autouse=True)
def _reset_project():
tools.set_project(None)
yield
tools.set_project(None)
def test_racine_par_defaut():
assert tools.active_root() == _ROOT
def test_set_project_reracine():
tools.set_project("demo")
root = tools.active_root()
assert root == os.path.join(_ROOT, "demo")
assert os.path.isdir(root) # créée à la volée
def test_confinement_conserve():
tools.set_project("demo")
with pytest.raises(tools.ToolError):
tools._safe_path("../hors-projet")
def test_nom_projet_invalide():
for bad in ("../x", "UPPER", "a b", "", "x" * 50):
with pytest.raises(tools.ToolError):
tools.set_project(bad)
def test_session_porte_son_projet():
s = db.create_session("t", None, project="demo")
assert db.get_session(s["id"])["project"] == "demo"
db.set_session_project(s["id"], None)
assert db.get_session(s["id"])["project"] is None
- Step 2: Vérifier l'échec
Run: cd backend && python -m pytest tests/test_projects.py -q
Expected: FAIL — AttributeError: module 'app.tools' has no attribute 'set_project'
- Step 3: tools.py — contextvar
En tête de backend/app/tools.py (après l'import settings) :
from contextvars import ContextVar
# Projet actif pour la requête en cours : re-racine tous les outils sur
# workspace/<projet>. None = racine du workspace (comportement historique).
_ACTIVE_PROJECT: ContextVar[str | None] = ContextVar("loki_project", default=None)
PROJECT_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
def set_project(name: str | None) -> None:
"""Fixe le projet actif de la requête (None = racine)."""
if name is not None and not PROJECT_NAME.match(name):
raise ToolError(f"nom de projet invalide : {name!r}")
_ACTIVE_PROJECT.set(name)
def active_root() -> str:
"""Racine effective (workspace ou projet), créée si nécessaire."""
return _workspace_root()
Remplacer _workspace_root :
def _workspace_root() -> str:
root = os.path.abspath(settings.workspace_dir)
project = _ACTIVE_PROJECT.get()
if project:
root = os.path.join(root, project)
os.makedirs(root, exist_ok=True)
return root
(_safe_path ne change pas : il s'appuie sur _workspace_root().)
- Step 4: db.py — colonne + accesseurs
Dans init_db, à côté de la migration summary :
if "project" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT")
create_session — ajouter le paramètre et la colonne (adapter l'INSERT
existant) :
def create_session(title: str, model: str | None, project: str | None = None) -> dict:
et inclure project dans l'INSERT et le dict renvoyé. Ajouter :
def set_session_project(sid: str, project: str | None) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET project = ? WHERE id = ?", (project, sid)
)
Vérifier que list_sessions/get_session renvoient la colonne (elles font
dict(row) — la colonne suit automatiquement).
- Step 5: sessions.py — create + PATCH
class CreateSession(BaseModel):
title: str = "Nouvelle session"
model: str | None = None
project: str | None = None
class UpdateSession(BaseModel):
title: str | None = None
project: str | None = None # "" = retour à la racine du workspace
@router.post("")
async def post_session(req: CreateSession) -> dict:
if req.project and not tools.PROJECT_NAME.match(req.project):
raise HTTPException(400, "nom de projet invalide")
return db.create_session(req.title, req.model, req.project or None)
@router.patch("/{sid}")
async def patch_session(sid: str, req: UpdateSession) -> dict:
if not db.get_session(sid):
raise HTTPException(404, "session introuvable")
if req.title is not None:
db.rename_session(sid, req.title)
if req.project is not None:
project = req.project or None
if project and not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
db.set_session_project(sid, project)
return {"ok": True}
(import : from .. import db, tools)
- Step 6: Vérifier + commit
Run: cd backend && python -m pytest tests/ -q — tout PASS.
git add backend/app/tools.py backend/app/db.py backend/app/routes/sessions.py backend/tests/test_projects.py
git commit -m "feat(projets): racine active par contextvar + projet par session"
Task 2: Routes /api/projects + files/git re-racinés
Files:
- Create:
backend/app/routes/projects.py - Modify:
backend/app/routes/files.py(paramprojectsur les 4 routes) - Modify:
backend/app/routes/git.py(paramprojectsur les 3 routes) - Modify:
backend/app/main.py(include projects.router) - Test:
backend/tests/test_projects_routes.py
Interfaces:
-
Consumes:
tools.set_project,tools.active_root,tools.PROJECT_NAME,coder.ensure_git(existant). -
Produces:
GET /api/projects→{projects: [{name, files}], root_files};POST /api/projects {name}→{name}(201) ; toutes les routes files/git acceptent?project=. -
Step 1: Tests qui échouent
backend/tests/test_projects_routes.py :
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from fastapi.testclient import TestClient # noqa: E402
from app import tools # noqa: E402
from app.main import app # noqa: E402
from app.config import settings # noqa: E402
client = TestClient(app)
_ROOT = os.path.abspath(settings.workspace_dir)
def test_creation_et_liste():
r = client.post("/api/projects", json={"name": "demo"})
assert r.status_code == 201
assert os.path.isdir(os.path.join(_ROOT, "demo", ".git"))
names = [p["name"] for p in client.get("/api/projects").json()["projects"]]
assert "demo" in names
def test_nom_invalide_400():
assert client.post("/api/projects", json={"name": "../x"}).status_code == 400
assert client.post("/api/projects", json={"name": "Demo"}).status_code == 400
def test_existant_400():
client.post("/api/projects", json={"name": "dup"})
assert client.post("/api/projects", json={"name": "dup"}).status_code == 400
def test_files_re_racine():
client.post("/api/projects", json={"name": "scoped"})
with open(os.path.join(_ROOT, "scoped", "a.txt"), "w") as f:
f.write("x")
with open(os.path.join(_ROOT, "racine.txt"), "w") as f:
f.write("y")
tree = client.get("/api/files", params={"project": "scoped"}).json()["tree"]
names = [n["name"] for n in tree]
assert "a.txt" in names and "racine.txt" not in names
def test_files_projet_invalide_400():
assert client.get("/api/files", params={"project": "../x"}).status_code == 400
Run: cd backend && python -m pytest tests/test_projects_routes.py -q
Expected: FAIL (404 sur /api/projects).
Note : TestClient requiert httpx (déjà présent). Si starlette réclame
un extra, python -m pip install "httpx<1" est déjà satisfait.
- Step 2: routes/projects.py
"""Routes des projets : sous-dossiers de premier niveau du workspace."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import coder, tools
from ..config import settings
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _count_files(path: str) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
total += sum(1 for f in filenames if not f.startswith("."))
return total
@router.get("")
async def list_projects() -> dict:
root = _root()
projects = []
root_files = 0
for name in sorted(os.listdir(root)):
full = os.path.join(root, name)
if name.startswith("."):
continue
if os.path.isdir(full):
projects.append({"name": name, "files": _count_files(full)})
else:
root_files += 1
return {"projects": projects, "root_files": root_files}
class CreateProject(BaseModel):
name: str
@router.post("", status_code=201)
async def create_project(req: CreateProject) -> dict:
name = req.name.strip()
if not tools.PROJECT_NAME.match(name):
raise HTTPException(400, "nom de projet invalide (a-z, 0-9, - et _)")
target = os.path.join(_root(), name)
if os.path.exists(target):
raise HTTPException(400, "ce projet existe déjà")
os.makedirs(target)
# Dépôt git par projet : commits Aider + onglet Git propres au projet.
coder.ensure_git(target)
return {"name": name}
main.py : ajouter projects à l'import des routes et
app.include_router(projects.router).
- Step 3: files.py — param project
Chaque route gagne project: str | None = None et commence par :
try:
tools_mod.set_project(project or None)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
avec en tête du fichier from .. import tools as tools_mod (l'import existant
from ..tools import ToolError, _safe_path reste). Puis remplacer chaque
usage de os.path.abspath(settings.workspace_dir) par tools_mod.active_root() :
list_files:root = tools_mod.active_root()(leos.makedirsest déjà fait paractive_root)._tree: signature_tree(path: str, root: str)— lerelse calcule contrerootpassé en argument (adapter l'appel récursif et l'appel depuislist_files).delete_file: le refus de racine devientif os.path.abspath(target) == tools_mod.active_root():.file_content/download_file: seulementset_projecten tête (_safe_pathsuit la contextvar).
IMPORTANT : remettre tools_mod.set_project(None) n'est pas nécessaire —
la contextvar est par-tâche asyncio, chaque requête FastAPI a son contexte.
- Step 4: git.py — param project
_git gagne project: str | None = None :
def _git(*args: str, timeout: int = 15, project: str | None = None) -> subprocess.CompletedProcess:
root = os.path.abspath(settings.workspace_dir)
if project:
if not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
root = os.path.join(root, project)
ensure_git(root)
return subprocess.run(
["git", *args], cwd=root, ...
)
(reprendre les kwargs existants de l'appel subprocess.run du fichier ;
imports : from .. import tools, from fastapi import HTTPException déjà
présent ou à ajouter, import os). Les trois routes (git_log, git_diff,
git_revert) gagnent project: str | None = None et le propagent à chaque
appel _git(..., project=project).
- Step 5: Vérifier + commit
Run: cd backend && python -m pytest tests/ -q — tout PASS.
git add backend/app/routes/projects.py backend/app/routes/files.py backend/app/routes/git.py backend/app/main.py backend/tests/test_projects_routes.py
git commit -m "feat(projets): routes /api/projects + files et git re-racinés"
Task 3: Chat re-raciné (contexte, Aider, notice projet disparu)
Files:
- Modify:
backend/app/routes/chat.py - Modify:
backend/app/coder.py(run_code_taskparamètreroot) - Modify:
backend/app/agent.py(dispatchcode_taskpasse la racine) - Test:
backend/tests/test_projects.py(ajout)
Interfaces:
-
Consumes:
tools.set_project,tools.active_root(Task 1). -
Produces:
coder.run_code_task(instruction, model, files=None, root: str | None = None). -
Step 1: Test qui échoue (aides de contexte re-racinées)
Ajouter à backend/tests/test_projects.py :
def test_aides_contexte_suivent_le_projet():
from app.routes.chat import _mentioned_files, _workspace_listing
tools.set_project("ctxdemo")
root = tools.active_root()
with open(os.path.join(root, "app.py"), "w", encoding="utf-8") as f:
f.write("x = 1")
assert "app.py" in _workspace_listing()
assert _mentioned_files("corrige app.py") == ["app.py"]
tools.set_project(None)
assert _mentioned_files("corrige app.py") == [] # absent de la racine
Run: cd backend && python -m pytest tests/test_projects.py -q
Expected: FAIL — _workspace_listing/_mentioned_files lisent encore
settings.workspace_dir.
- Step 2: chat.py — racine active partout
Dans chat() juste après la récupération de la session :
# Projet de la session : re-racine outils, shell, Aider et aides de
# contexte pour tout le tour. Projet disparu -> retour racine + notice.
project = session.get("project") or None
project_missing = False
if project:
proj_dir = os.path.join(os.path.abspath(settings.workspace_dir), project)
if not os.path.isdir(proj_dir):
project_missing, project = True, None
tools.set_project(project)
avec from .. import tools dans les imports du fichier. Dans
event_stream(), après le yield _sse("start", …) :
if project_missing:
yield _sse("notice", {"message":
"Projet de la session introuvable sur le disque — retour au workspace."})
Remplacer dans _session_code_context, _workspace_listing et
_mentioned_files chaque root = os.path.abspath(settings.workspace_dir)
par root = tools.active_root().
Chemin code : passer la racine à Aider —
async for chunk in _code_stream(
req, code_model, extra=extra, plan=plan, files=code_files,
):
_code_stream → _run_aider_keepalive(instruction, model, files) →
task = asyncio.create_task(
asyncio.to_thread(
coder.run_code_task, instruction, model, files, tools.active_root()
)
)
(active_root() évalué AVANT le to_thread : la contextvar ne suit pas dans
le thread.)
- Step 3: coder.py — paramètre root
def run_code_task(
instruction: str,
model: str,
files: list[str] | None = None,
root: str | None = None,
) -> dict:
et remplacer root = os.path.abspath(settings.workspace_dir) par
root = os.path.abspath(root or settings.workspace_dir) (le reste — ensure_git,
chdir, fnames — utilise déjà root).
- Step 4: agent.py — code_task scoped
Dans le dispatch code_task de run_agent, remplacer l'appel :
if name == "code_task":
from .tools import active_root
code_model = await coder.pick_code_model(model)
result = await asyncio.to_thread(
coder.run_code_task,
args.get("instruction", ""),
code_model,
args.get("files") or [],
active_root(),
)
- Step 5: Vérifier + commit
Run: cd backend && python -m pytest tests/ -q — tout PASS ;
python -m compileall -q app.
git add backend/app/routes/chat.py backend/app/coder.py backend/app/agent.py backend/tests/test_projects.py
git commit -m "feat(projets): chat, aides de contexte et Aider suivent le projet de la session"
Task 4: Frontend — API client + store projets
Files:
- Modify:
frontend/src/api/client.ts - Modify:
frontend/src/store/useStore.ts
Interfaces:
-
Consumes: routes Tasks 1-2.
-
Produces:
listProjects(): Promise<{projects: {name: string; files: number}[]; root_files: number}>;createProject(name): Promise<void>;setSessionProject(id, project: string | null): Promise<void>; store :currentProject(): string | null(getter),setProject(name: string | null),projects: {name: string; files: number}[],refreshProjects(). -
Step 1: client.ts
Type Session : ajouter project?: string | null;. Ajouter :
export async function listProjects(): Promise<{
projects: { name: string; files: number }[];
root_files: number;
}> {
const res = await fetch("/api/projects");
return res.json();
}
export async function createProject(name: string): Promise<void> {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "création du projet impossible");
}
export async function setSessionProject(
id: string,
project: string | null
): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project: project ?? "" }),
});
if (!res.ok) throw await apiError(res, "changement de projet impossible");
}
Re-raciner les appels fichiers — signatures :
const projQuery = (project?: string | null) =>
project ? `&project=${encodeURIComponent(project)}` : "";
export async function listFiles(project?: string | null): Promise<FileNode[]> {
const query = project ? `?project=${encodeURIComponent(project)}` : "";
const res = await fetch(`/api/files${query}`);
return (await res.json()).tree;
}
export async function fileContent(
path: string,
project?: string | null
): Promise<string> {
const res = await fetch(
`/api/files/content?path=${encodeURIComponent(path)}${projQuery(project)}`
);
if (!res.ok) return "";
return (await res.json()).content;
}
export function downloadFile(path: string, project?: string | null): void {
const link = document.createElement("a");
link.href = `/api/files/download?path=${encodeURIComponent(path)}${projQuery(project)}`;
link.download = path.split(/[\\/]/).pop() ?? "fichier";
document.body.appendChild(link);
link.click();
link.remove();
}
export async function deleteFile(
path: string,
project?: string | null
): Promise<void> {
const res = await fetch(
`/api/files?path=${encodeURIComponent(path)}${projQuery(project)}`,
{ method: "DELETE" }
);
if (!res.ok) {
throw await apiError(res, `suppression impossible (${res.status})`);
}
}
Idem routes git utilisées par PreviewPanel (getGitLog, getGitDiff,
revertCommit) : paramètre optionnel project ajouté en query string de la
même façon.
createSession : paramètre project?: string | null transmis dans le body.
- Step 2: useStore.ts
État : projects: { name: string; files: number }[] (init []). Actions :
refreshProjects: async () => {
try {
const { projects } = await listProjects();
set({ projects });
} catch { /* backend indisponible */ }
},
currentProject: () => {
const s = get().sessions.find((x) => x.id === get().currentSessionId);
return s?.project ?? null;
},
setProject: async (name) => {
let sid = get().currentSessionId;
if (!sid) {
const s = await createSession(get().selectedModel || undefined, name);
set({ currentSessionId: s.id, messages: [] });
sid = s.id;
} else {
await setSessionProject(sid, name);
}
await get().refreshSessions();
await get().refreshFiles();
set({ previewPath: null, previewContent: "" });
},
(createSession(model?, project?) : étendre la fonction client existante.)
refreshFiles passe le projet : listFiles(get().currentProject()).
openPreview : fileContent(path, get().currentProject()).
removeFile : deleteFile(path, get().currentProject()).
newSession : createSession(model, get().currentProject()) — héritage.
openSession : après chargement, await get().refreshFiles() (l'arbre suit
la session ouverte).
Les composants qui appellent downloadFile(path) passent aussi
useStore.getState().currentProject() (LeftPanel, ActivityViews,
PreviewPanel).
Types : ajouter projects, refreshProjects, currentProject, setProject
à LokiState.
- Step 3: Vérifier + commit
Run: cd frontend && npx tsc -b — OK (les composants utilisant downloadFile
sont ajustés dans cette tâche pour compiler).
git add frontend/src/api/client.ts frontend/src/store/useStore.ts frontend/src/panels/LeftPanel.tsx frontend/src/panels/ActivityViews.tsx frontend/src/panels/PreviewPanel.tsx
git commit -m "feat(projets): client API + store re-racinés par projet"
Task 5: Chip projet dans le composer + aperçu réductible
Files:
- Create:
frontend/src/components/ProjectChip.tsx - Modify:
frontend/src/panels/ChatPanel.tsx(rendu du chip à côté du ModeSelector) - Modify:
frontend/src/panels/PreviewPanel.tsx(repli)
Interfaces:
-
Consumes: store Task 4 (
projects,refreshProjects,currentProject,setProject). -
Step 1: ProjectChip.tsx
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { createProject } from "../api/client";
/** Sélecteur de projet du composer : 📁 <projet> + menu (liste, création). */
export function ProjectChip() {
const { projects, refreshProjects, currentProject, setProject } = useStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const active = currentProject();
useEffect(() => {
if (open) void refreshProjects();
}, [open, refreshProjects]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setError(null);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const choose = async (name: string | null) => {
setOpen(false);
await setProject(name);
};
const create = async () => {
const name = draft.trim();
if (!name) return;
try {
await createProject(name);
setCreating(false);
setDraft("");
await choose(name);
} catch (err) {
setError(err instanceof Error ? err.message : "création impossible");
}
};
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex h-8 items-center gap-1.5 border-2 border-line bg-base px-2.5 text-[12px] text-ink-2"
title="Projet de travail de cette session"
>
📁 <span className="max-w-[140px] truncate">{active ?? "workspace"}</span>
</button>
{open && (
<div
className="absolute bottom-[calc(100%+6px)] left-0 z-30 w-[240px] border-[3px] border-line bg-card shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={() => choose(null)}
className={`block w-full px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === null ? "bg-base font-bold" : ""
}`}
>
workspace (racine)
</button>
{projects.map((p) => (
<button
key={p.name}
onClick={() => choose(p.name)}
className={`block w-full border-t-2 border-line-soft px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === p.name ? "bg-base font-bold" : ""
}`}
>
📁 {p.name}
<span className="ml-1.5 text-[11px] text-muted-2">
{p.files} fichier{p.files > 1 ? "s" : ""}
</span>
</button>
))}
<div className="border-t-2 border-line-soft p-2">
{creating ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void create();
if (e.key === "Escape") {
setCreating(false);
setError(null);
}
}}
placeholder="nom-du-projet"
className="w-full border-2 border-line bg-base px-2 py-1 text-[12px] text-ink outline-none"
/>
) : (
<button
onClick={() => setCreating(true)}
className="w-full text-left text-[13px] font-bold text-accent"
>
+ Nouveau projet
</button>
)}
{error && <div className="mt-1 text-[11px] text-warn">{error}</div>}
</div>
</div>
)}
</div>
);
}
- Step 2: ChatPanel — rendu
Localiser le rendu du sélecteur de mode dans le composer (composant local
ModeSelector, utilisé vers le bas du composer). Ajouter <ProjectChip />
juste à côté (même conteneur flex), avec
import { ProjectChip } from "../components/ProjectChip";.
- Step 3: PreviewPanel — repli
Dans PreviewPanel :
const [collapsed, setCollapsed] = useState(
() => window.localStorage.getItem("loki.preview.collapsed") === "1"
);
const toggleCollapsed = () => {
setCollapsed((c) => {
window.localStorage.setItem("loki.preview.collapsed", c ? "0" : "1");
return !c;
});
};
Rendu replié — AVANT le rendu normal :
if (collapsed) {
return (
<div className="flex w-9 flex-none flex-col items-center border-l-[3px] border-line bg-panel pt-3">
<button
onClick={toggleCollapsed}
title="Déplier l'aperçu"
className="text-[15px] text-muted-2 hover:text-accent"
>
⇤
</button>
<div className="mt-3 rotate-90 whitespace-nowrap text-[10px] font-bold tracking-wide text-muted-3">
APERÇU
</div>
</div>
);
}
Bouton de repli dans la barre d'onglets existante (à droite des Tabs) :
<div className="flex-1" />
<button
onClick={toggleCollapsed}
title="Replier l'aperçu"
className="px-2 text-[15px] text-muted-2 hover:text-accent"
>
⇥
</button>
(replié = composant retourne tôt → aucun contenu/iframe monté, conforme spec.)
- Step 4: Vérifier + commit
Run: cd frontend && npx tsc -b && npm run build — OK.
git add frontend/src/components/ProjectChip.tsx frontend/src/panels/ChatPanel.tsx frontend/src/panels/PreviewPanel.tsx
git commit -m "feat(ui): chip projet dans le composer + aperçu réductible"
Task 6: Vérification bout-en-bout + docs + push
Files:
-
Modify:
README.md -
Step 1: README
Section après « Panneau Git & Diff » :
## Projets (répertoires de travail)
Chaque session peut travailler dans un **projet** : un sous-dossier du
workspace choisi via le chip 📁 du composer (« + Nouveau projet » pour en
créer un). L'agent, le shell, le moteur code et l'arborescence sont confinés
au projet ; chaque projet a son propre dépôt git (historique et revert
indépendants). Session sans projet = racine du workspace.
- Step 2: Suite complète
cd backend && python -m pytest tests/ -q && python -m compileall -q app
cd ../frontend && npx tsc -b && npm run build
- Step 3: Smoke local (uvicorn, sans Ollama)
cd backend && python -m uvicorn app.main:app --port 8199 & # env WORKSPACE_DIR/DATA_DIR temporaires
curl -s -X POST http://localhost:8199/api/projects -H "Content-Type: application/json" -d '{"name":"demo"}'
curl -s http://localhost:8199/api/projects
curl -s -X POST http://localhost:8199/api/projects -H "Content-Type: application/json" -d '{"name":"../x"}' -o /dev/null -w "%{http_code}" # 400
SID=$(curl -s -X POST http://localhost:8199/api/sessions -H "Content-Type: application/json" -d '{"title":"t","project":"demo"}' | python -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -s -X PATCH http://localhost:8199/api/sessions/$SID -H "Content-Type: application/json" -d '{"project":""}' # retour racine
curl -s "http://localhost:8199/api/files?project=demo"
Expected : création 201 + .git ; liste contient demo ; 400 sur nom
invalide ; PATCH ok ; arbre du projet vide (pas les fichiers racine).
- Step 4: Commit + push
git add README.md
git commit -m "docs: projets par session"
git push
Vérif UI après déploiement : chip 📁 → créer demo → message « crée
hello.txt » → fichier dans workspace/demo/ ; session B sans projet → racine ;
onglet Git montre l'historique du projet actif ; bouton ⇥ replie l'aperçu,
état conservé au rechargement.