Cronologia chat: salvataggio su disco, barra laterale e ripresa all'avvio

Ogni chat è salvata come JSON in %LOCALAPPDATA%\LlmRun\chats (nome file =
id di sessione di claude). La barra laterale elenca le chat, permette di
riaprirle con --resume e di eliminarle. Il messaggio dell'utente è
salvato subito, prima della risposta, e all'avvio si riapre l'ultima
conversazione. Documentazione e roadmap aggiornate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-08 13:49:28 +02:00
1 parent 0d1784c0f4
commit c83d69f435
5 files changed
+166 -22

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ LlmRun: eseguibile C# (.NET 10, Windows) che invoca Claude Code (`claude -p`) da
## Architettura
- [Program.cs](Program.cs): top-level statements. Senza argomenti avvia `Gui`; con argomenti è la CLI (`claude -p <prompt>` con `ArgumentList`, stdout/stderr ereditati, exit code propagato, 127 se `claude` non è nel PATH).
- [Gui.cs](Gui.cs): host WinForms sottile. Un solo WebView2 che carica [ui.html](ui.html) (embedded resource; `{{MODELS}}` è sostituito con le opzioni dei modelli). Pagina e C# dialogano con `WebMessage` JSON (protocollo in `docs/MVP.md`). Per ogni `send` C# lancia `claude -p` con stdout/stderr rediretti, converte la risposta da markdown a HTML con Markdig (`DisableHtml`) e la rimanda alla pagina. Contesto: `--session-id <guid>` al primo messaggio, `--resume <guid>` poi. `--model`/`--effort` omessi se "Predefinito". Lista modelli hardcoded in `Gui.Models`, da aggiornare quando escono nuovi modelli.
- [Gui.cs](Gui.cs): host WinForms sottile. Un solo WebView2 che carica [ui.html](ui.html) (embedded resource; `{{MODELS}}` è sostituito con le opzioni dei modelli). Pagina e C# dialogano con `WebMessage` JSON (protocollo in `docs/MVP.md`). Per ogni `send` C# lancia `claude -p` con stdout/stderr rediretti, converte la risposta da markdown a HTML con Markdig (`DisableHtml`) e la rimanda alla pagina. Contesto: `--session-id <guid>` al primo messaggio, `--resume <guid>` poi. Cronologia: un JSON per chat in `%LOCALAPPDATA%\LlmRun\chats\<guid>.json` (il nome file è l'id di sessione, validato come Guid prima dell'uso), salvato appena l'utente invia e di nuovo alla risposta; all'avvio (primo `list` dalla pagina) viene riaperta la chat più recente. `--model`/`--effort` omessi se "Predefinito". Lista modelli hardcoded in `Gui.Models`, da aggiornare quando escono nuovi modelli.
- [ui.html](ui.html): tutta la UI (HTML+CSS+JS, nessuna libreria). Token di colore/tipografia come variabili CSS in cima; specifiche in `docs/MVP.md` → "Design (UI/UX)". Cambiando l'aspetto aggiornare quella sezione.
- Dipendenze NuGet (solo GUI): Markdig, Microsoft.Web.WebView2.
+92 -7
View File
@@ -11,6 +11,8 @@ using Microsoft.Web.WebView2.WinForms;
class Gui : Form
{
record Model(string Label, string Id);
record Msg(string Role, string Text); // Role: user | ai | err
record Stored(string Id, string Title, DateTime Updated, bool Started, List<Msg> Messages);
static readonly Model[] Models =
[
new("Fable 5.1", "claude-fable-5-1"),
@@ -28,9 +30,13 @@ class Gui : Form
DefaultBackgroundColor = Application.IsDarkModeEnabled ? Color.FromArgb(0x0F, 0x17, 0x2A) : Color.White,
};
static readonly string ChatsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LlmRun", "chats");
string sessionId = Guid.NewGuid().ToString();
bool started; // true dopo il primo messaggio riuscito: da lì in poi --resume
bool busy;
bool restored; // la chat più recente si riapre solo al primo avvio
List<Msg> messages = [];
public Gui()
{
@@ -77,8 +83,18 @@ class Gui : Form
var r = doc.RootElement;
switch (r.GetProperty("type").GetString())
{
case "reset":
sessionId = Guid.NewGuid().ToString(); started = false;
case "reset" when !busy:
NewSession(); PostChats();
break;
case "list":
if (!restored) { restored = true; if (LatestId() is { } last) { OpenChat(last); break; } }
PostChats();
break;
case "open" when !busy:
OpenChat(r.GetProperty("id").GetString()!);
break;
case "delete" when !busy:
Delete(r.GetProperty("id").GetString()!);
break;
case "send" when !busy:
_ = HandleSend(r.GetProperty("text").GetString()!, r.GetProperty("model").GetString()!, r.GetProperty("effort").GetString()!);
@@ -89,15 +105,84 @@ class Gui : Form
async Task HandleSend(string text, string model, string effort)
{
busy = true;
messages.Add(new("user", text));
try { Save(); PostChats(); } catch (IOException) { } // il messaggio non va perso se l'app si chiude durante l'attesa
try
{
var (code, stdout, stderr) = await RunClaude(text, model, effort);
if (code == 0) { started = true; Post(new { type = "ai", html = Markdown.ToHtml(stdout, Md), md = stdout }); }
else Post(new { type = "err", text = $"claude è uscito con codice {code}\n{stderr}".Trim() });
if (code == 0) { started = true; messages.Add(new("ai", stdout)); Post(new { type = "ai", html = Markdown.ToHtml(stdout, Md), md = stdout }); }
else Fail($"claude è uscito con codice {code}\n{stderr}".Trim());
}
catch (System.ComponentModel.Win32Exception) { Post(new { type = "err", text = "'claude' non trovato nel PATH." }); }
catch (Exception ex) { Post(new { type = "err", text = ex.Message }); }
finally { busy = false; Post(new { type = "done" }); }
catch (System.ComponentModel.Win32Exception) { Fail("'claude' non trovato nel PATH."); }
catch (Exception ex) { Fail(ex.Message); }
finally
{
busy = false;
try { Save(); } catch (IOException) { } // la chat resta a schermo anche se il salvataggio fallisce
PostChats();
Post(new { type = "done" });
}
}
void Fail(string text) { messages.Add(new("err", text)); Post(new { type = "err", text }); }
void NewSession() { sessionId = Guid.NewGuid().ToString(); started = false; messages = []; }
// L'id arriva dalla pagina e diventa un nome file: accettare solo Guid.
static string? PathFor(string id) => Guid.TryParse(id, out var g) ? Path.Combine(ChatsDir, g.ToString() + ".json") : null;
void Save()
{
if (messages.Count == 0) return;
Directory.CreateDirectory(ChatsDir);
var first = messages.First(m => m.Role == "user").Text.ReplaceLineEndings(" ").Trim();
var title = first.Length > 50 ? first[..50] + "…" : first;
File.WriteAllText(PathFor(sessionId)!, JsonSerializer.Serialize(new Stored(sessionId, title, DateTime.Now, started, messages)));
}
static Stored? Read(string file)
{
try { return JsonSerializer.Deserialize<Stored>(File.ReadAllText(file)); }
catch (Exception e) when (e is IOException or JsonException) { return null; } // file corrotto: ignorato
}
static string? LatestId() => Directory.Exists(ChatsDir)
? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType<Stored>().OrderByDescending(c => c.Updated).FirstOrDefault()?.Id
: null;
void PostChats()
{
var items = Directory.Exists(ChatsDir)
? Directory.EnumerateFiles(ChatsDir, "*.json").Select(Read).OfType<Stored>()
.OrderByDescending(c => c.Updated).Select(c => new { id = c.Id, title = c.Title, updated = c.Updated }).ToArray()
: [];
Post(new { type = "chats", current = sessionId, items });
}
void OpenChat(string id)
{
var file = PathFor(id);
var chat = file is not null && File.Exists(file) ? Read(file) : null;
if (chat is null) { PostChats(); return; }
sessionId = chat.Id; started = chat.Started; messages = chat.Messages;
Post(new
{
type = "load",
messages = messages.Select(m => new { role = m.Role, text = m.Text, html = m.Role == "ai" ? Markdown.ToHtml(m.Text, Md) : null }).ToArray(),
});
PostChats();
}
void Delete(string id)
{
var file = PathFor(id);
if (file is not null && File.Exists(file)) File.Delete(file);
if (id == sessionId)
{
NewSession();
Post(new { type = "load", messages = Array.Empty<object>() });
}
PostChats();
}
async Task<(int Code, string Out, string Err)> RunClaude(string prompt, string model, string effort)
+6 -3
View File
@@ -20,17 +20,19 @@ Tutti gli argomenti sono uniti in un unico prompt.
Senza argomenti si apre una finestra WinForms che contiene solo un WebView2: l'intera interfaccia (header, conversazione, composer) è HTML/CSS in `ui.html`, in stile claude.ai/ChatGPT. Con argomenti resta la CLI.
**Struttura della pagina**
- **Header**: logo e nome, pulsante **Nuova chat**.
- **Barra laterale** (sinistra, comprimibile): logo e nome, pulsante **Nuova chat**, elenco delle chat salvate (titolo = inizio del primo messaggio, data/ora dell'ultimo scambio; la chat aperta è evidenziata). Un clic su una chat la riapre; la ✕ (con conferma) la elimina. Elenco vuoto: "Nessuna chat salvata".
- **Conversazione**: messaggi alternati utente/Claude, aggiunti al DOM uno alla volta (nessun ricaricamento, nessuno sfarfallio). Le risposte sono renderizzate in markdown (Markdig, lato C#): titoli, liste, tabelle, citazioni, blocchi di codice. Sulle risposte il pulsante **Copia** copia il markdown; sui blocchi di codice copia il codice.
- **Schermata iniziale** (chat vuota): titolo, sottotitolo e quattro suggerimenti cliccabili che precompilano il campo.
- **Composer** (in basso, scheda arrotondata): textarea che cresce fino a ~8 righe, selettori **Modello** (Predefinito, Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5, "Altro ID…" con campo libero) ed **Effort** (Predefinito, low, medium, high, xhigh, max), pulsante Invia circolare con icona. `Invio` invia, `Maiusc+Invio` va a capo. Hint sulla tastiera sotto la scheda.
- Passati come `--model` / `--effort`; con "Predefinito" il flag viene omesso. Solo GUI.
- **Contesto**: `claude -p --session-id <guid>` al primo messaggio, `--resume <guid>` ai successivi. "Nuova chat" svuota la vista e genera un nuovo guid.
- **Cronologia**: dopo ogni scambio la chat è salvata in `%LOCALAPPDATA%\LlmRun\chats\<guid>.json` (guid = id di sessione di `claude`, titolo, data, flag `started`, messaggi con ruolo e testo). Riaprendo una chat si rimostra la conversazione e si prosegue con `--resume <guid>`, quindi `claude` ricorda il contesto. Il messaggio dell'utente viene salvato subito, prima della risposta (così non si perde se l'app si chiude mentre Claude risponde); la risposta si aggiunge appena arriva. Una chat nuova e vuota non viene salvata. **All'avvio l'app riapre automaticamente l'ultima conversazione** (la più recente); se non ce ne sono parte con la schermata iniziale. "Nuova chat" resta sempre disponibile. Eliminare una chat cancella solo il file JSON, non la sessione di `claude`. Non si può aprire/eliminare una chat mentre Claude sta rispondendo.
- **Limite noto**: la sessione di `claude` è legata alla cartella in cui gira (quella dell'exe): se si sposta l'exe, le vecchie chat si leggono ancora ma `--resume` non ritrova il contesto.
- La risposta compare a fine generazione (nessuno streaming); durante l'attesa un indicatore a puntini animati e Invia disabilitato. Se `claude` esce con errore, lo stderr appare come messaggio di errore.
- I link si aprono nel browser di sistema. L'HTML grezzo nel markdown è disabilitato.
- Finestra orizzontale con proporzioni 16:10: larga il 75% dell'area utile dello schermo (max 1440px), altezza di conseguenza (max 90% dell'area utile), centrata; minimo 900x600. Su un monitor Full HD 1920x1080 risulta circa 1440x900.
**Protocollo pagina ↔ C#** (`WebMessage` JSON). Pagina→C#: `{type:"send", text, model, effort}`, `{type:"reset"}`. C#→pagina: `{type:"ai", html, md}`, `{type:"err", text}`, `{type:"done"}`.
**Protocollo pagina ↔ C#** (`WebMessage` JSON). Pagina→C#: `{type:"send", text, model, effort}`, `{type:"reset"}`, `{type:"list"}`, `{type:"open", id}`, `{type:"delete", id}`. C#→pagina: `{type:"ai", html, md}`, `{type:"err", text}`, `{type:"done"}`, `{type:"chats", current, items:[{id,title,updated}]}`, `{type:"load", messages:[{role,text,html?}]}` (sostituisce la conversazione mostrata).
### Design (UI/UX)
Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "strumento per sviluppatori": slate + accento verde).
@@ -38,6 +40,7 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "
- **Token** (chiaro / scuro): sfondo `#FFFFFF` / `#0F172A`, superficie composer `#FFFFFF` / `#1E293B`, testo `#0F172A` / `#F8FAFC`, testo secondario `#475569` / `#94A3B8`, bordi `#CBD5E1` / `#334155`, accento `#15803D` / `#22C55E`, errore `#B91C1C` / `#F87171`. Contrasto testo ≥ 4.5:1.
- **Tipografia**: Segoe UI Variable (corpo 15.5px, interlinea 1.65), Cascadia Code/Consolas per il codice. Nessun font web (funziona offline).
- **Forma e movimento**: raggi 10–20px, ombra leggera sul composer, transizioni 150ms, comparsa dei messaggi 180ms; con `prefers-reduced-motion` tutto fermo.
- **Barra laterale**: sfondo `--muted`, 272px; voce attiva con barra d'accento a sinistra; ✕ visibile solo al passaggio/focus.
- **Accessibilità**: focus sempre visibile, `aria-label` sui pulsanti a sola icona, `role=log`/`aria-live` sulla conversazione, `role=status` sull'attesa, target ≥ 36px, nessuna emoji come icona.
- **Non incluso**: evidenziazione sintattica del codice (richiederebbe una libreria da incorporare).
@@ -48,7 +51,7 @@ Sorgente di verità per l'aspetto, derivato dalla skill `ui-ux-pro-max` (stile "
- Working dir: `AppContext.BaseDirectory`, non la cwd del chiamante.
## Fuori scope (MVP)
Streaming token-per-token nella GUI, annullamento di una risposta in corso, salvataggio/riapertura delle chat, config file, logging su file, test automatici, supporto Linux/macOS.
Streaming token-per-token nella GUI, annullamento di una risposta in corso, rinomina/ricerca delle chat, config file, logging su file, test automatici, supporto Linux/macOS.
## Criteri di accettazione
- [ ] Eseguito da un'altra cartella, `claude` parte nella cartella dell'exe (verificabile chiedendo "pwd").
+2 -1
View File
@@ -20,6 +20,7 @@ Dettagli dell'MVP in [MVP.md](MVP.md).
- [ ] CLI: stesse opzioni; inoltre `--allowedTools`, `--output-format`
## Fase 3 — Oltre
- [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, elenco chat salvate
- [x] GUI: cronologia chat (salvataggio JSON, barra laterale, riapertura con `--resume`, eliminazione)
- [ ] GUI: streaming della risposta (`--output-format stream-json`), stop, rinomina/ricerca chat
- [ ] Config file accanto all'exe
- [ ] Test automatici e CI
+65 -10
View File
@@ -21,19 +21,39 @@
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { display: flex; flex-direction: column; font: 15.5px/1.65 var(--font); background: var(--bg); color: var(--fg); overflow: hidden; }
body { display: flex; font: 15.5px/1.65 var(--font); background: var(--bg); color: var(--fg); overflow: hidden; }
button, select, textarea, input { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
/* header */
header { display: flex; align-items: center; justify-content: space-between; height: 54px; padding: 0 20px; border-bottom: 1px solid var(--border); flex: none; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: -.01em; }
/* barra laterale */
#side { width: 272px; flex: none; display: flex; flex-direction: column; background: var(--muted); border-right: 1px solid var(--border); transition: margin-left .2s; }
#side.closed { margin-left: -272px; }
.side-top { padding: 16px 14px 10px; display: flex; flex-direction: column; gap: 14px; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; letter-spacing: -.01em; padding: 0 4px; }
.logo { width: 28px; height: 28px; border-radius: 8px; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; }
.logo svg { width: 16px; height: 16px; fill: currentColor; stroke: none; }
.ghost { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--border); background: transparent; transition: background .15s, border-color .15s; }
.ghost:hover { background: var(--muted); }
.ghost:hover { background: var(--bg); }
#reset { justify-content: center; background: var(--bg); }
#reset:hover { border-color: var(--accent); }
.side-h { padding: 8px 18px 4px; font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--muted-fg); }
#list { flex: 1; overflow-y: auto; padding: 4px 8px 12px; }
.item { position: relative; border-radius: 10px; padding: 8px 38px 8px 12px; transition: background .15s; }
.item:hover { background: var(--bg); }
.item.active { background: var(--bg); box-shadow: inset 3px 0 0 var(--accent); }
.item .open { position: absolute; inset: 0; width: 100%; border: 0; background: transparent; border-radius: 10px; }
.item .t, .item .d { position: relative; display: block; pointer-events: none; }
.item .t { font-size: 14px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item .d { font-size: 12px; color: var(--muted-fg); }
.item .x { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 26px; height: 26px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: var(--muted-fg); opacity: 0; }
.item:hover .x, .item:focus-within .x { opacity: 1; }
.item .x:hover { background: var(--muted); color: var(--err); }
.item .x svg { width: 14px; height: 14px; }
.none { padding: 10px 14px; font-size: 13px; color: var(--muted-fg); }
#main { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; }
#toggle { position: absolute; top: 12px; left: 12px; z-index: 2; width: 36px; height: 36px; padding: 0; justify-content: center; }
/* conversazione */
#scroll { flex: 1; overflow-y: auto; scroll-behavior: smooth; }
@@ -106,10 +126,16 @@ kbd { font: 11.5px var(--mono); padding: 1px 5px; border: 1px solid var(--border
</style>
</head>
<body>
<header>
<div class="brand"><span class="logo"><svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg></span>LlmRun</div>
<button class="ghost" id="reset" aria-label="Nuova chat"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>Nuova chat</button>
</header>
<aside id="side" aria-label="Chat salvate">
<div class="side-top">
<div class="brand"><span class="logo"><svg viewBox="0 0 24 24"><path d="M12 2l2.4 6.6L21 11l-6.6 2.4L12 20l-2.4-6.6L3 11l6.6-2.4z"/></svg></span>LlmRun</div>
<button class="ghost" id="reset" aria-label="Nuova chat"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>Nuova chat</button>
</div>
<div class="side-h">Chat</div>
<div id="list"></div>
</aside>
<main id="main">
<button class="ghost" id="toggle" aria-label="Mostra o nascondi le chat" aria-expanded="true"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<div id="scroll">
<div id="empty">
@@ -145,6 +171,7 @@ kbd { font: 11.5px var(--mono); padding: 1px 5px; border: 1px solid var(--border
</div>
<div class="hint"><kbd>Invio</kbd> per inviare · <kbd>Maiusc</kbd>+<kbd>Invio</kbd> per andare a capo</div>
</footer>
</main>
<script>
const $ = s => document.querySelector(s);
@@ -202,7 +229,32 @@ input.addEventListener('input', grow);
input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); submit(); } });
send.onclick = submit;
$('#model').onchange = e => { const c = $('#custom'); c.hidden = e.target.value !== '__custom'; if (!c.hidden) c.focus(); };
$('#reset').onclick = () => { chat.replaceChildren(); chat.hidden = true; empty.hidden = false; busy = false; refresh(); post({ type: 'reset' }); input.focus(); };
function clearView() { chat.replaceChildren(); chat.hidden = true; empty.hidden = false; }
$('#reset').onclick = () => { if (busy) return; clearView(); refresh(); post({ type: 'reset' }); input.focus(); };
$('#toggle').onclick = () => { const c = $('#side').classList.toggle('closed'); $('#toggle').setAttribute('aria-expanded', String(!c)); };
const ICON_X = '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>';
function renderList(items, current) {
const list = $('#list');
list.replaceChildren();
if (!items.length) { list.append(el('<div class="none">Nessuna chat salvata</div>')); return; }
for (const c of items) {
const when = new Date(c.updated).toLocaleString('it-IT', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const row = el(`<div class="item${c.id === current ? ' active' : ''}"><button class="open" aria-label="Apri chat: ${esc(c.title)}"></button><span class="t">${esc(c.title)}</span><span class="d">${esc(when)}</span><button class="x" aria-label="Elimina chat">${ICON_X}</button></div>`);
row.querySelector('.open').onclick = () => { if (!busy && c.id !== current) post({ type: 'open', id: c.id }); };
row.querySelector('.x').onclick = () => { if (!busy && confirm('Eliminare questa chat?')) post({ type: 'delete', id: c.id }); };
list.append(row);
}
}
function load(messages) {
clearView();
for (const m of messages) {
if (m.role === 'user') addUser(m.text);
else if (m.role === 'ai') addAi(m.html, m.text);
else addErr(m.text);
}
bottom();
}
document.querySelectorAll('.chip').forEach(c => c.onclick = () => { input.value = c.dataset.p; grow(); input.focus(); input.setSelectionRange(input.value.length, input.value.length); });
host && host.addEventListener('message', e => {
@@ -210,8 +262,11 @@ host && host.addEventListener('message', e => {
if (m.type === 'ai') addAi(m.html, m.md);
else if (m.type === 'err') addErr(m.text);
else if (m.type === 'done') setBusy(false);
else if (m.type === 'chats') renderList(m.items, m.current);
else if (m.type === 'load') load(m.messages);
});
input.focus();
post({ type: 'list' });
</script>
</body>
</html>