feat(terminals): reprise de conversation par cellule + fix ordre d'écriture

Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).

- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
  (persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
  (statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
  avant le Resume auto, jamais sur le chemin reattach

fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents

fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:27:08 +02:00
parent d11eaaa8c0
commit 3ed0f6b45f
61 changed files with 5098 additions and 98 deletions

View File

@ -0,0 +1,364 @@
//! [`ClaudeTranscriptInspector`] — a best-effort [`SessionInspector`] adapter
//! (CONTEXT §T6) for the **Claude Code** CLI.
//!
//! Claude Code records each conversation as a JSONL transcript under the user's
//! home directory:
//!
//! ```text
//! <home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl
//! ```
//!
//! where `<encoded-cwd>` is the agent's working directory with its path
//! separators flattened to `-` (see [`encode_cwd`]). Each line of the `.jsonl`
//! is one JSON message object.
//!
//! This adapter is the **only** place that knows the Claude transcript shape:
//! it reads the file through the injected [`FileSystem`] port, extracts a
//! best-effort `last_topic` (the last `user` message text) and `token_count`
//! (the cumulative `usage` input+output tokens of `assistant` messages), and
//! returns the CLI-agnostic [`ConversationDetails`]. No Claude-specific type
//! ever leaves this module. Anything it cannot parse is skipped, never fatal.
use std::sync::Arc;
use async_trait::async_trait;
use serde::Deserialize;
use domain::ports::{
ConversationDetails, FileSystem, FsError, InspectError, RemotePath, SessionInspector,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::ProjectPath;
/// Max byte length of the extracted `last_topic` before it is truncated.
///
/// Kept small: the topic only labels a resume popup, it is not the message.
const TOPIC_MAX_LEN: usize = 120;
/// Reads Claude Code conversation transcripts.
///
/// Composes a [`FileSystem`] port (so it stays Tauri- and OS-agnostic and is
/// trivially testable) plus the base directory that stands in for the user's
/// home (`<home>/.claude/projects/`). The composition root passes the real
/// `$HOME`; tests pass a temp directory via [`with_base_dir`](Self::with_base_dir).
#[derive(Clone)]
pub struct ClaudeTranscriptInspector {
fs: Arc<dyn FileSystem>,
/// Directory that plays the role of the user's home directory; the adapter
/// looks under `<home>/.claude/projects/`.
home_dir: String,
}
impl ClaudeTranscriptInspector {
/// Builds the inspector from an injected [`FileSystem`] and the user's home
/// directory (resolved by the composition root, e.g. from `$HOME`).
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
Self {
fs,
home_dir: home_dir.into(),
}
}
/// Test/seam constructor: identical to [`new`](Self::new) but named to make
/// the injected base directory explicit at call sites (tests point it at a
/// temp `~/.claude/projects/` fixture).
#[must_use]
pub fn with_base_dir(fs: Arc<dyn FileSystem>, home_dir: impl Into<String>) -> Self {
Self::new(fs, home_dir)
}
/// `<home>/.claude/projects/<encoded-cwd>/<conversation-id>.jsonl`.
fn transcript_path(&self, conversation_id: &str, cwd: &ProjectPath) -> RemotePath {
let base = self.home_dir.trim_end_matches(['/', '\\']);
let encoded = encode_cwd(cwd.as_str());
RemotePath::new(format!(
"{base}/.claude/projects/{encoded}/{conversation_id}.jsonl"
))
}
}
/// Encodes a cwd the way Claude Code names its per-project transcript folder.
///
/// **Assumption (documented, isolated, and unit-tested):** Claude flattens the
/// absolute cwd into a single directory segment by replacing each path
/// separator (`/` or `\`) — and the leading separator — with `-`. So
/// `/home/anthony/Documents/Projects/IdeA` becomes
/// `-home-anthony-Documents-Projects-IdeA`. We keep this convention in one
/// small, tested function so that if Claude's exact encoding differs in some
/// edge case, only this seam needs to change. We do not attempt to encode `.`
/// or other characters, as the common case (clean absolute project paths) is
/// covered and over-encoding would risk pointing at the wrong folder.
fn encode_cwd(cwd: &str) -> String {
cwd.chars()
.map(|c| if c == '/' || c == '\\' { '-' } else { c })
.collect()
}
/// One transcript line, as far as we care about it. Everything is optional so
/// that an unexpected/partial line never fails deserialization of the fields we
/// *can* read; truly unparseable lines are skipped by the caller.
#[derive(Debug, Deserialize)]
struct TranscriptLine {
#[serde(default)]
role: Option<String>,
/// Some Claude transcript variants nest the chat turn under `message`.
#[serde(default)]
message: Option<InnerMessage>,
#[serde(default)]
content: Option<Content>,
#[serde(default)]
usage: Option<Usage>,
}
/// The nested `{ role, content, usage }` object some transcript shapes use.
#[derive(Debug, Deserialize)]
struct InnerMessage {
#[serde(default)]
role: Option<String>,
#[serde(default)]
content: Option<Content>,
#[serde(default)]
usage: Option<Usage>,
}
/// Message content is either a plain string or an array of typed blocks.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Content {
/// `"content": "hello"`.
Text(String),
/// `"content": [{ "type": "text", "text": "hello" }, ...]`.
Blocks(Vec<ContentBlock>),
}
impl Content {
/// Flattens the content to plain text (concatenating text blocks).
fn to_text(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Blocks(blocks) => blocks
.iter()
.filter_map(|b| b.text.as_deref())
.collect::<Vec<_>>()
.join(" "),
}
}
}
/// A single content block; we only care about text blocks.
#[derive(Debug, Deserialize)]
struct ContentBlock {
#[serde(default)]
text: Option<String>,
}
/// Token usage as Claude reports it on assistant turns.
#[derive(Debug, Deserialize)]
struct Usage {
#[serde(default)]
input_tokens: Option<u64>,
#[serde(default)]
output_tokens: Option<u64>,
}
impl TranscriptLine {
/// The effective role, looking at the top level then the nested message.
fn effective_role(&self) -> Option<&str> {
self.role
.as_deref()
.or_else(|| self.message.as_ref().and_then(|m| m.role.as_deref()))
}
/// The effective content, top level then nested.
fn effective_content(&self) -> Option<&Content> {
self.content
.as_ref()
.or_else(|| self.message.as_ref().and_then(|m| m.content.as_ref()))
}
/// The effective usage, top level then nested.
fn effective_usage(&self) -> Option<&Usage> {
self.usage
.as_ref()
.or_else(|| self.message.as_ref().and_then(|m| m.usage.as_ref()))
}
}
/// Parses an already-read transcript body (the JSONL bytes) into best-effort
/// [`ConversationDetails`]. Pulled out of the async `details` so it is a pure,
/// directly unit-testable function. Malformed lines are silently skipped.
fn parse_transcript(body: &[u8]) -> ConversationDetails {
let text = String::from_utf8_lossy(body);
let mut last_topic: Option<String> = None;
let mut token_total: u64 = 0;
let mut saw_usage = false;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
// Malformed line → skip, never fatal.
let Ok(parsed) = serde_json::from_str::<TranscriptLine>(line) else {
continue;
};
let role = parsed.effective_role();
// last_topic = the last user message's text (best-effort).
if role == Some("user") {
if let Some(content) = parsed.effective_content() {
let t = content.to_text();
let t = t.trim();
if !t.is_empty() {
last_topic = Some(truncate_topic(t));
}
}
}
// token_count = cumulative usage across assistant turns (best-effort).
if let Some(usage) = parsed.effective_usage() {
let input = usage.input_tokens.unwrap_or(0);
let output = usage.output_tokens.unwrap_or(0);
if input != 0 || output != 0 {
saw_usage = true;
token_total = token_total
.saturating_add(input)
.saturating_add(output);
}
}
}
ConversationDetails {
last_topic,
token_count: saw_usage.then_some(token_total),
}
}
/// Truncates a topic to [`TOPIC_MAX_LEN`] bytes on a char boundary, appending an
/// ellipsis when cut.
fn truncate_topic(s: &str) -> String {
if s.len() <= TOPIC_MAX_LEN {
return s.to_owned();
}
let mut end = TOPIC_MAX_LEN;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}", &s[..end])
}
#[async_trait]
impl SessionInspector for ClaudeTranscriptInspector {
fn supports(&self, profile: &AgentProfile) -> bool {
// Recognise Claude by its conventional context file `CLAUDE.md`,
// mirroring the detection in `application::agent::lifecycle`.
matches!(
&profile.context_injection,
ContextInjection::ConventionFile { target }
if target
.rsplit(['/', '\\'])
.next()
.unwrap_or(target)
.eq_ignore_ascii_case("CLAUDE.md")
)
}
async fn details(
&self,
_profile: &AgentProfile,
conversation_id: &str,
cwd: &ProjectPath,
) -> Result<ConversationDetails, InspectError> {
let path = self.transcript_path(conversation_id, cwd);
match self.fs.read(&path).await {
Ok(bytes) => Ok(parse_transcript(&bytes)),
Err(FsError::NotFound(_)) => Err(InspectError::NotFound),
Err(e) => Err(InspectError::Read(e.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_cwd_flattens_separators_to_dash() {
assert_eq!(
encode_cwd("/home/anthony/Documents/Projects/IdeA"),
"-home-anthony-Documents-Projects-IdeA"
);
assert_eq!(encode_cwd("/a/b"), "-a-b");
// Windows-style separators flatten too.
assert_eq!(encode_cwd("C:\\Users\\me"), "C:-Users-me");
}
#[test]
fn parse_extracts_last_user_topic_and_token_sum() {
let body = concat!(
r#"{"role":"user","content":"first question"}"#,
"\n",
r#"{"role":"assistant","content":"hi","usage":{"input_tokens":10,"output_tokens":5}}"#,
"\n",
r#"{"role":"user","content":"second question"}"#,
"\n",
r#"{"role":"assistant","content":"there","usage":{"input_tokens":20,"output_tokens":7}}"#,
"\n",
);
let d = parse_transcript(body.as_bytes());
assert_eq!(d.last_topic.as_deref(), Some("second question"));
assert_eq!(d.token_count, Some(42));
}
#[test]
fn parse_skips_malformed_lines_without_panicking() {
let body = concat!(
r#"{"role":"user","content":"valid one"}"#,
"\n",
"this is not json at all",
"\n",
r#"{"role":"assistant","usage":{"input_tokens":3,"output_tokens":4}}"#,
"\n",
"{ broken json",
"\n",
);
let d = parse_transcript(body.as_bytes());
assert_eq!(d.last_topic.as_deref(), Some("valid one"));
assert_eq!(d.token_count, Some(7));
}
#[test]
fn parse_handles_nested_message_and_block_content() {
let body = concat!(
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"nested topic"}]}}"#,
"\n",
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":100,"output_tokens":50}}}"#,
"\n",
);
let d = parse_transcript(body.as_bytes());
assert_eq!(d.last_topic.as_deref(), Some("nested topic"));
assert_eq!(d.token_count, Some(150));
}
#[test]
fn parse_returns_none_when_no_topic_or_usage() {
let body = concat!(
r#"{"role":"assistant","content":"no usage here"}"#,
"\n",
);
let d = parse_transcript(body.as_bytes());
assert_eq!(d.last_topic, None);
assert_eq!(d.token_count, None);
}
#[test]
fn truncate_topic_cuts_long_text() {
let long = "x".repeat(TOPIC_MAX_LEN + 50);
let t = truncate_topic(&long);
assert!(t.ends_with('…'));
assert!(t.len() <= TOPIC_MAX_LEN + 4);
}
}

View File

@ -0,0 +1,12 @@
//! Best-effort conversation-transcript inspectors (CONTEXT §T6).
//!
//! These adapters implement the **optional** [`domain::ports::SessionInspector`]
//! port: they read a CLI's on-disk transcript to enrich a resume popup (last
//! topic, token count). Each CLI's transcript format stays fully encapsulated in
//! its adapter; only the CLI-agnostic [`domain::ports::ConversationDetails`]
//! crosses the port boundary. Nothing here is wired as a hard dependency — a
//! missing or failing inspector must never block a resume.
mod claude;
pub use claude::ClaudeTranscriptInspector;