Barre latérale de la maquette + contexte workspace conditionnel

Remaniement de la navigation selon « Loki App.dc.html » : le rail
d'icônes de 60 px et le panneau de sessions laissent place à UNE barre
latérale de 264 px — marque, navigation libellée (Demander, Historique,
Fichiers, Outils), section Configuration, état Ollama avec jauge VRAM et
carte de compte. La marque quitte la barre supérieure, qui ne garde que
le contexte de la conversation.

Les sessions restent accessibles par le sélecteur de la barre
supérieure, et les fichiers par la vue Fichiers.

Performance : l'inventaire du workspace n'est plus injecté à CHAQUE
message, seulement quand la demande touche au code ou qu'un travail de
code est en cours. Un simple « bonjour » partait avec des milliers de
jetons de contexte, d'où une longue phase de traitement avant le premier
jeton.

L'attente avant le premier jeton affiche désormais le modèle en cours de
chargement et le temps écoulé, au lieu de rester figée sur « Connexion à
Ollama… » sans distinguer lenteur et blocage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
Claude committed 2026-08-03 11:47:01 +00:00
1 parent d99ff11262
commit b3835e7977
4 files changed
+195 -27

No files matched your search

+36 -5
View File
@@ -14,6 +14,7 @@ import json
import logging
import os
import re
import time
from contextlib import suppress
from fastapi import APIRouter, HTTPException
@@ -61,6 +62,19 @@ def _apply_mode(cfg: dict, mode: str) -> dict:
return cfg
def _loading_message(model: str, seconds: float) -> str:
"""Message d'attente avant le premier jeton (chargement du modèle)."""
waited = int(seconds)
delay = f"{waited // 60} min" if waited >= 60 else f"{waited} s"
text = f"Chargement de {model} en mémoire… ({delay})"
if waited >= 90:
# On CONSTATE l'attente sans présumer de la cause : selon la machine,
# un gros modèle peut se répartir sur plusieurs GPU, ou déborder sur
# le CPU. Le panneau Matériel donne le placement réel.
text += " — placement réel visible dans Réglages › Matériel."
return text
def _merge_system(convo: list[dict], extras: list[str]) -> None:
"""Garantit UN SEUL message système, en première position.
@@ -415,16 +429,22 @@ async def chat(req: ChatRequest) -> StreamingResponse:
# échouer toute la requête en 400.
extras: list[str] = []
# La demande touche-t-elle au code / aux fichiers ? Calculé AVANT les
# injections : tout ce qui suit est conditionné à cette réponse.
is_code_like = use_code or msg_router.is_code_task(req.content)
needs_workspace = is_code_like or prev_code
if memories:
extras.append(
"Souvenirs pertinents d'anciennes sessions :\n"
+ "\n---\n".join(memories)
)
# État du workspace injecté chaque tour : sans ça le modèle ignore
# quels fichiers existent et régurgite du code en chat au lieu de
# modifier le bon fichier.
listing = _workspace_listing()
# État du workspace : indispensable pour travailler sur les fichiers,
# mais inutile pour une simple discussion. On l'injectait à CHAQUE
# message — un « bonjour » partait avec des milliers de jetons de
# contexte, donc une longue phase de traitement avant le 1er jeton.
listing = _workspace_listing() if needs_workspace else []
if listing:
recap, session_files = _session_code_context(history)
parts = ["Fichiers du workspace : " + ", ".join(listing)]
@@ -458,7 +478,6 @@ async def chat(req: ChatRequest) -> StreamingResponse:
# Ponytail : méthode « code minimal » injectée pour toute tâche de code
# (les deux chemins). Contre la sur-ingénierie qui casse les rendus.
is_code_like = use_code or msg_router.is_code_task(req.content)
if cfg.get("ponytail", True) and is_code_like:
extras.append(skills.PONYTAIL_GUIDANCE)
@@ -574,6 +593,8 @@ async def chat(req: ChatRequest) -> StreamingResponse:
await queue.put(None)
producer = asyncio.create_task(produce_events())
started = time.monotonic()
got_event = False
try:
while True:
try:
@@ -582,8 +603,18 @@ async def chat(req: ChatRequest) -> StreamingResponse:
# Empêche OpenResty/Nginx/Cloudflare de fermer le SSE pendant
# le chargement parfois long d'un modèle Ollama.
yield _sse("ping", {"status": "waiting"})
# …et DIT ce qui se passe : sans ça, l'interface restait sur
# « Connexion à Ollama… » pendant tout le chargement d'un
# gros modèle, sans moyen de distinguer lenteur et blocage.
if not got_event:
yield _sse("status", {
"message": _loading_message(
model, time.monotonic() - started
)
})
continue
got_event = True
if ev is None:
break
+2 -4
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { ActivityBar, type View } from "./components/ActivityBar";
import { Sidebar, type View } from "./components/Sidebar";
import { TopBar } from "./components/TopBar";
import { LeftPanel } from "./panels/LeftPanel";
import { ChatPanel } from "./panels/ChatPanel";
import { PreviewPanel } from "./panels/PreviewPanel";
import { SettingsView } from "./panels/SettingsView";
@@ -57,11 +56,10 @@ export default function App() {
<div className="flex h-full flex-col bg-base text-ink">
<TopBar />
<div className="flex min-h-0 flex-1">
<ActivityBar active={view} onChange={setView} />
<Sidebar active={view} onChange={setView} />
{view === "chat" && (
<>
<LeftPanel />
<ChatPanel />
<PreviewPanel />
</>
+146
View File
@@ -0,0 +1,146 @@
import { useStore } from "../store/useStore";
import {
ChatIcon,
ClockIcon,
FilesIcon,
LokiMark,
NodesIcon,
SettingsIcon,
} from "./Icon";
export type View = "chat" | "history" | "files" | "tools" | "settings";
const NAV: { id: View; label: string; icon: React.ReactNode }[] = [
{ id: "chat", label: "Demander", icon: <ChatIcon /> },
{ id: "history", label: "Historique", icon: <ClockIcon /> },
{ id: "files", label: "Fichiers", icon: <FilesIcon /> },
{ id: "tools", label: "Outils", icon: <NodesIcon /> },
];
/** Barre latérale (264 px) : marque, navigation, état Ollama, compte. */
export function Sidebar({
active,
onChange,
}: {
active: View;
onChange: (v: View) => void;
}) {
const { status, selectedModel, systemStats } = useStore();
const gpu = systemStats?.gpu ?? null;
return (
<aside className="flex w-[264px] flex-none flex-col border-r border-line bg-panel px-3.5 py-4">
{/* Marque */}
<div className="flex items-center gap-2.5 px-1.5 pb-4">
<LokiMark size={26} />
<span className="text-[16px] font-medium tracking-tight text-ink">Loki</span>
<span className="ml-auto rounded-full border border-line px-2 py-0.5 text-[11px] text-muted-3">
local
</span>
</div>
<div className="mx-1.5 mb-3.5 h-px bg-line" />
<nav className="flex flex-col gap-[3px]">
{NAV.map((it) => (
<NavItem
key={it.id}
{...it}
active={active === it.id}
onClick={() => onChange(it.id)}
/>
))}
</nav>
<div className="font-pixel px-2.5 pb-2 pt-5 text-[10px] text-label">
Configuration
</div>
<NavItem
id="settings"
label="Modèle & génération"
icon={<SettingsIcon />}
active={active === "settings"}
onClick={() => onChange("settings")}
/>
<div className="flex-1" />
{/* État Ollama + GPU */}
<div className="rounded-card border border-line p-3">
<div className="mb-2 flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full ${
status?.connected ? "bg-accent" : "bg-warn"
}`}
/>
<span className="text-[12.5px] text-ink-3">
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
</span>
</div>
<div className="mb-2.5 truncate font-mono text-[12px] text-muted-3">
{selectedModel || "aucun modèle"}
</div>
{gpu && (
<>
<div className="h-1 overflow-hidden rounded-sm bg-line-soft">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(
100,
(gpu.vram_used_mb / Math.max(gpu.vram_total_mb, 1)) * 100
)}%`,
}}
/>
</div>
<div className="mt-2 text-[11.5px] text-muted-3">
VRAM {(gpu.vram_used_mb / 1000).toFixed(1)} /{" "}
{(gpu.vram_total_mb / 1000).toFixed(1)} Go · GPU{" "}
{Math.round(gpu.util_pct)} %
</div>
</>
)}
</div>
{/* Compte local */}
<div className="mt-3 flex items-center gap-2.5 rounded-card border border-line px-3 py-2.5">
<span className="grid h-[30px] w-[30px] flex-none place-items-center rounded-full bg-line-soft text-[12px] text-ink-3">
M
</span>
<span className="min-w-0">
<span className="block text-[13.5px] text-ink">Compte local</span>
<span className="block truncate font-mono text-[11.5px] text-muted-3">
{status?.host || "workspace"}
</span>
</span>
</div>
</aside>
);
}
function NavItem({
label,
icon,
active,
onClick,
}: {
id: View;
label: string;
icon: React.ReactNode;
active: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-2.5 rounded-card border px-2.5 py-2 text-left text-[14px] ${
active
? "border-accent-line bg-accent-ghost text-ink"
: "border-transparent text-ink-3 hover:bg-accent-ghost hover:text-ink"
}`}
>
{icon}
{label}
</button>
);
}
+11 -18
View File
@@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { useStore } from "../store/useStore";
import { getVersion } from "../api/client";
import { LokiMark } from "./Icon";
import { SessionMenu } from "./SessionMenu";
/** Barre supérieure sombre : logo, fil d'Ariane, statut Ollama, sélecteur. */
/** Barre supérieure : conversation courante, ressources, statut Ollama. */
export function TopBar() {
const status = useStore((s) => s.status);
const stats = useStore((s) => s.systemStats);
@@ -17,23 +16,17 @@ export function TopBar() {
return (
<div className="flex h-[54px] flex-none items-center gap-3.5 border-b border-line bg-bar px-4">
<div className="flex items-center gap-2.5">
<LokiMark />
<div className="font-pixel text-[14px] text-ink">LOKI</div>
<div
className="text-[13px] leading-none text-on-dark-2"
title={version ? `build ${version}` : undefined}
>
agent local
{version && version !== "dev" && (
<span className="ml-1.5 text-on-dark-3">· {version.slice(0, 7)}</span>
)}
</div>
</div>
<div className="h-6 w-[3px] bg-chrome-3" />
{/* La marque vit désormais dans la barre latérale : on ne garde ici que
le contexte de la conversation en cours. */}
<SessionMenu />
{version && version !== "dev" && (
<span
className="text-[12px] text-muted-3"
title={`build ${version}`}
>
{version.slice(0, 7)}
</span>
)}
<div className="flex-1" />