Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a68b4c8
fix(timing): time a prompt as one run, Enter → last background task (…
clousty8 Sep 28, 2026
13df6e5
feat(titlebar): "Épuré" title bar — underlined tabs, bare glyphs, one…
Alex375 Sep 28, 2026
4c6b15d
Merge branch 'feat/titlebar-epure' into dev
Alex375 Sep 28, 2026
b731266
feat(conversation): resizable side panel + fix the artifact icon, rea…
Alex375 Sep 20, 2026
1613ef0
fix(artifacts): a TYPED artifact names no icon — fall back to its typ…
Alex375 Sep 20, 2026
30171cb
fix(conversation): make the artifact hand-off one movement; add a con…
Alex375 Sep 21, 2026
58aa5dd
perf(energy): stop idle wakeups and lighten the remote health probe (…
Alex375 Sep 28, 2026
2484eef
feat(conversation): floating side panel (design B) + an opt-in teleme…
Alex375 Sep 28, 2026
c07109c
perf(tosse): stop refetching the board every minute on SSE recycles (…
Alex375 Sep 28, 2026
a703133
perf(remote): lighter ssh keepalive, patient reconnects, no caffeinat…
Alex375 Sep 28, 2026
6909d6d
feat(conversation): side panel fits its content, animated as sections…
Alex375 Sep 28, 2026
b7ce44a
feat(telemetry): make the deck live — ms clocks, per-call timers, eas…
Alex375 Sep 28, 2026
acff529
docs(tosse): the SSE recycles came from Bun.serve's idle timeout, now…
Alex375 Sep 28, 2026
5ae9a8e
feat(telemetry): compact deck, long-calls-only board, cost + average …
Alex375 Sep 28, 2026
e42403b
perf(wake): skip the neural pipeline in silence, batch mic callbacks,…
Alex375 Sep 28, 2026
9c1aeec
perf(wake): drop the silence gate — keep batching, QoS and the no-key…
Alex375 Sep 28, 2026
b61b4da
feat(conversation): adjustable reading width + a real side margin (CR…
Alex375 Sep 28, 2026
fed9e22
feat(telemetry): token mix — last call and session, as stacked bars
Alex375 Sep 28, 2026
8262eb3
docs(remote): 10 s keepalive halves the traffic at rest (not a third)
Alex375 Sep 28, 2026
b55ed0d
feat(conversation): both new layout options are opt-in; drop the auto…
Alex375 Sep 28, 2026
8e7788a
Merge origin/dev into feat/side-panel-artifacts
Alex375 Sep 28, 2026
b9d6964
feat(sidebar): violet background-work wash on a blue review row (CRM …
Alex375 Sep 28, 2026
66a7956
feat(side-panel): a customizable stack of widgets — choose, order, fold
Alex375 Sep 28, 2026
a2f1387
fix(side-panel): review findings — one-commit history replay, keyboar…
Alex375 Sep 28, 2026
fc31938
Merge branch 'feat/side-panel-artifacts' into dev
Alex375 Sep 29, 2026
5696634
Merge remote-tracking branch 'origin/dev' into feat/side-panel-fit-he…
Alex375 Sep 29, 2026
7c81ad6
feat(side-panel): Essentials by default, a leaner Standard; Display →…
Alex375 Sep 29, 2026
0119df5
chore(release): v2.7.0
Alex375 Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ that section and uses it as the GitHub release description, which the app displa
as-is. The install instructions block (after the `<!-- gh-only -->` marker) is added
by `release.yml` and stays **only** on the GitHub page — it does not appear in the app.

## v2.7.0

- New: a **customizable conversation side panel** — choose its widgets (git status, stats with the total tokens of every agent, linked conversations, plan usage, the machine it runs on…), reorder and fold them, or start from a preset. It can be resized, and can fit its content.
- New: an opt-in **telemetry deck** in the side panel — live gauges, clocks and the token mix of the running agent.
- New: a cleaner **title bar** — underlined view tabs and one tray for voice, sound and Caffeinate.
- New: an adjustable **conversation width**, and Display settings sorted into clearer cards.
- Fix: the **turn duration** now times the whole run, background tasks included, instead of resetting.
- Fix: **artifact icons** show again and older versions are reachable; **less battery drain** at rest (idle wake-ups, remote servers, wake word).

## v2.6.0

- New: **per-tool permissions for MCP tools** — Allow / Ask / Block each tool, and choose where it applies (this conversation, the repository, or everywhere). Extensions now live under Settings → Claude Code.
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Encapsulation stricte (cf. « Patterns ») : un module = un service, swappable s
- **id stable** (UUID PK persistée) ≠ **handle live** (`session-N`, mémoire, non persisté) : front keyé par id stable en LECTURE, handle résolu à l'envoi.
- Spawn **paresseux** (rien au démarrage ; historique lu du transcript ; `--resume` si `sessionId`). Teardown **sans orphelins** (`process_group(0)`, `kill(-pid)`, échelle EOF→SIGTERM→SIGKILL, kill-all borné au quit).
- **Rewind / Fork** (`history.rs`) : rewind TRONQUE le transcript on-disk (destructif), fork COPIE sous nouveau `session_id`, toujours à une frontière de **prompt humain** (jamais de `tool_use` orphelin), puis re-spawn `--resume`. ⚠️ Toute mutation du transcript exige un arrêt **SYNCHRONE** (`SessionHandle::shutdown_and_wait()`, PAS `shutdown()`), sinon course contre un writer vivant → corruption. ⚠️ Tours live = id synthétique (`user_N`) absent du disque → ciblage par TEXTE (`prompt_match_key`, miroir Rust/TS) + index d'occurrence. Codex : voie native par turn id (`thread/fork{lastTurnId}`).
- **Sessions REMOTE (SSH) — sobriété** (audit batterie 28/09) : keepalive `ServerAliveInterval=10` ×3 par conversation (coupure vue en ~30 s — décision : ne pas redescendre à 5 s, c'était 2× plus de réveils radio au repos). Reconnexion : backoff 1 s doublé, plafond 30 s pendant 10 échecs puis ≤ 5 min (`next_reconnect_delay`), court-circuité par `reconnect_remote_sessions` (front : `online` / focus / visibilité quand un lien est down, `MachineHealthHost`) et par un envoi pendant la coupure (`SessionCommand::ReconnectNow`). Santé des serveurs : la sonde ambiante est `machine_reachability` (`ssh … true`, même classification/raison que `diagnose` via `unreachable_after`) — le diagnostic complet reste réservé au panneau Réglages ; ⚠️ JAMAIS de sonde vers une machine à laquelle une session est attachée (`attachedMachineIds` : son lien prouve déjà la joignabilité, filé via `recordReachability`). Caffeinate (Light) ne compte que les agents LOCAUX (`localConversations`) : un agent distant continue sous `flightdeckd` pendant que le Mac dort. Piste ouverte : une connexion ssh par serveur (`ControlMaster`, tâche ed7ec860).

**Worktrees**
- Outils natifs `EnterWorktree`/`ExitWorktree` interceptés dans `useGlobalSessionEvents`. Convention `.claude/worktrees/<branche>`. cwd non figé ; association conv↔worktree par longest-prefix.
Expand Down Expand Up @@ -333,8 +334,7 @@ L'app HÉBERGE deux serveurs MCP. Module `src-tauri/src/appmcp/` (hub, router JS
Agent vocal in-app + déclenchement mains-libres, 100 % local pour le réveil. **OpenAI strictement optionnel** : sans clé l'app marche, la clé ne déverrouille que le vocal. Front `src/voice/` + Rust `src-tauri/src/voice/` (SEUL détenteur de la clé OpenAI) + `src-tauri/src/wake/` (wake word local).
- **Voix (OpenAI Realtime, WebRTC/GA)** : `voice/realtime.ts` = session hors React (comme `termManager` : les composants rendent l'état de `voiceStore`, ne possèdent pas la connexion). Modèle 2 couches : **mode armé** (session ouverte, micro FERMÉ, exchange nul → reste up pendant le travail) ↔ **micro** ouvert/fermé DANS la session. Rust forge des **secrets éphémères** (`/v1/realtime/client_secrets`) → le webview ne voit jamais la clé (Keychain « Flight Deck OpenAI » suffixé par identité de build). Outils = sous-ensemble du catalogue appmcp (`VOICE_TOOL_NAMES`, dont `get_pending_request`/`answer_request` pour répondre aux questions/permissions par la voix) + `end_call`. **Annonces proactives** = mêmes events settled que les notifs OS (`announce.ts`, file plafonnée, drain séquentiel, jamais par-dessus une réponse active). Garde coût : auto-close sur silence RÉEL (réarmé sur l'activité vocale VAD, jamais mid-monologue). Un refus micro sur une annonce dégrade en « speak-only » (ne détruit pas la session). Réglages → Control carte **« Ground Control »** : clé, annonces, PTT (⌘ droit / ⌘⇧V), seuil VAD, « Answer by voice » (opt-in ON), wake word.
- **Wake word** (`wake/`, openWakeWord + Silero VAD via `ort`/ONNX statique, modèles bundlés `include_bytes!`) : phrases `alexa`/`hey_jarvis` (pré-entraînées, défaut) + **« Ground Control »** (custom, entraîné LOCALEMENT via macOS `say`, sans cloud). Pipeline en détection continue → `WakeWordEvent` → `VoiceHost` ouvre le micro.
- ⚠️ Verrous release V2 encore ouverts : `getUserMedia` en WKWebView à confirmer ; robustesse du wake sur voix humaine réelle (accent FR) non prouvée ; follow-ups CPU (re-gate duty-cycle) / faux positifs du wake. Détail complet : mémoire `voice-agent-realtime-design` (+ `wake-word-custom-phrase-training`, `flightdeck-v2-crm-structure`).

- ⚠️ Verrous release V2 encore ouverts : `getUserMedia` en WKWebView à confirmer ; robustesse du wake sur voix humaine réelle (accent FR) non prouvée ; follow-ups CPU (re-gate duty-cycle — tenté puis RETIRÉ le 28/09 : son rattrapage au début de la parole est invérifiable sans vrai micro ; en place : callbacks micro regroupés par pas de 80 ms, worker QoS UTILITY, wake word EN PAUSE sans clé OpenAI) / faux positifs du wake. Détail complet : mémoire `voice-agent-realtime-design` (+ `wake-word-custom-phrase-training`, `flightdeck-v2-crm-structure`).

## [GENERATED] Associated Project Contexts

Expand Down Expand Up @@ -460,4 +460,4 @@ Alexandre Josien et Armand Mounsi, deux ingénieurs informatique freelances trav
## Ressources techniques
- Abonnement Max Claude Code
- Clé API OpenAI
- Hébergement Railway
- Hébergement Railway
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tosse-code",
"version": "2.6.0",
"version": "2.7.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tosse-code"
version = "2.6.0"
version = "2.7.0"
description = "Flight Deck — desktop app to pilot Claude Code"
authors = ["Tosse"]
edition = "2021"
Expand Down Expand Up @@ -129,8 +129,9 @@ specta-typescript = "=0.0.9"
# agent's microphone (see `wake/mod.rs`). 100% on-device: NO network, NO cloud,
# the captured audio never leaves the Mac. `cpal` captures the default input
# device; `ort` (ONNX Runtime) runs the openWakeWord pipeline (melspectrogram →
# shared speech embedding → per-phrase classifier), gated by a Silero VAD so the
# neural nets run ONLY on speech (the battery guard). All five ONNX models are
# shared speech embedding → per-phrase classifier) plus a Silero VAD, which vetoes
# a fire unless it heard speech. The whole pipeline runs on every 80 ms step while
# the wake word is on — no silence-based battery guard. All six ONNX models are
# bundled into the binary (`include_bytes!` from `assets/wake/`), so the feature
# works offline on first launch — no download, no external asset host. `ort` is an
# RC, pinned EXACTLY like the specta/tauri-specta RCs above.
Expand Down
134 changes: 126 additions & 8 deletions src-tauri/src/bootstrap/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2015,12 +2015,7 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>)
// The `Err` is deliberately discarded — it embeds the raw offending value (see
// `validate_ssh_user`/`validate_address_value`'s own docs) and this reason
// string is user-facing. Same discipline as `TransportError::InvalidRemoteTarget`.
return ServerDiagnosis {
state: DiagnosisState::Failed {
reason: "this server's saved connection details are not valid — remove and re-add it".to_string(),
},
..ServerDiagnosis::unreachable()
};
return invalid_connection_details();
}
cmd.arg(diagnose_script());
// A wedged remote shell (stuck lock, hung `flightdeckd`) must not hang this
Expand All @@ -2032,6 +2027,29 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>)
Ok(Ok(out)) if out.status.success() => {
parse_diagnosis(&String::from_utf8_lossy(&out.stdout), true)
}
failed => unreachable_after(&machine.host, failed).await,
}
}

/// The diagnosis of a machine whose saved `user`/`host` no longer pass validation —
/// shared by [`diagnose`] and [`probe_reachability`], so both name it in the same words.
fn invalid_connection_details() -> ServerDiagnosis {
ServerDiagnosis {
state: DiagnosisState::Failed {
reason: "this server's saved connection details are not valid — remove and re-add it".to_string(),
},
..ServerDiagnosis::unreachable()
}
}

/// Classify an ssh round trip that did NOT succeed — shared by [`diagnose`] and
/// [`probe_reachability`], so the ambient probe says WHY in exactly the words the full
/// diagnosis in the server panel uses.
async fn unreachable_after(
host: &str,
failed: Result<std::io::Result<std::process::Output>, tokio::time::error::Elapsed>,
) -> ServerDiagnosis {
match failed {
// ssh itself failed (a non-zero exit — OpenSSH's own convention for exit 255,
// though anything non-zero here means the same thing for THIS script, which
// never returns non-zero on its own). Thread the stderr this used to discard
Expand All @@ -2042,7 +2060,7 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>)
String::from_utf8_lossy(&out.stderr).lines().map(str::to_string).collect();
let issue = ssh_link::classify_transport_close(out.status.code(), &stderr_lines)
.unwrap_or(SshLinkIssue::Unreachable);
let tailscale_off = tailscale_off_locally_if_relevant(&machine.host).await;
let tailscale_off = tailscale_off_locally_if_relevant(host).await;
ServerDiagnosis::unreachable_with(issue, tailscale_off)
}
// No `Output` at all: the round trip either couldn't even be spawned, or the
Expand All @@ -2051,12 +2069,65 @@ pub(crate) async fn diagnose(machine: &MachineRecord, known_hosts: Option<&str>)
// state (a wedged/hung remote shell on a tailnet host is exactly the shape
// this clause exists for).
Ok(Err(_)) | Err(_) => {
let tailscale_off = tailscale_off_locally_if_relevant(&machine.host).await;
let tailscale_off = tailscale_off_locally_if_relevant(host).await;
ServerDiagnosis::unreachable_with(SshLinkIssue::Unreachable, tailscale_off)
}
}
}

/// The one fact the AMBIENT machine-health probe needs: can this Mac reach the server
/// right now, and if not, why. See [`probe_reachability`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct MachineReachability {
pub reachable: bool,
/// The diagnosis's own wording for why, when unreachable — the SAME string a full
/// [`diagnose`] would put in [`DiagnosisState::Failed`]. `None` when reachable.
pub reason: Option<String>,
}

impl MachineReachability {
fn reached() -> Self {
Self { reachable: true, reason: None }
}
}

impl From<&ServerDiagnosis> for MachineReachability {
fn from(d: &ServerDiagnosis) -> Self {
let reason = match &d.state {
DiagnosisState::Failed { reason } if !d.reachable => Some(reason.clone()),
_ => None,
};
Self { reachable: d.reachable, reason }
}
}

/// "Can I talk to this machine?" — the question the sidebar mark, the Flight Deck lane
/// header and the composer band answer, and NOTHING more.
///
/// ⚠️ Why not [`diagnose`]: the ambient loop used to run the full diagnosis every 90 s
/// per server, i.e. a fresh ssh handshake PLUS a dozen server-side commands — `systemctl`,
/// `loginctl`, `flightdeckd --version`/`status`, and `claude --version` + `claude auth
/// status`, two Node.js start-ups on the server — to paint a glyph that reads
/// `reachable` and nothing else (see `store/machineHealth.ts`). The server panel still
/// runs [`diagnose`]: it is the one surface that shows the rest.
///
/// Same ssh options, same timeout, same failure classification as [`diagnose`] (shared
/// through [`unreachable_after`]), so a verdict never depends on which probe produced it.
pub(crate) async fn probe_reachability(machine: &MachineRecord, known_hosts: Option<&str>) -> MachineReachability {
let mut cmd = crate::ipc::commands::keyed_ssh_options(machine.port, machine.identity_file.as_deref(), known_hosts);
cmd.arg("-T");
if crate::ipc::commands::push_ssh_destination(&mut cmd, &machine.user, &machine.host).is_err() {
return MachineReachability::from(&invalid_connection_details());
}
// `true`, not an empty command: ssh with no command opens an interactive login
// shell and waits on it. Exit 0 means ssh connected, authenticated and ran it.
cmd.arg("true");
match tokio::time::timeout(SSH_ROUND_TRIP_TIMEOUT, cmd.output()).await {
Ok(Ok(out)) if out.status.success() => MachineReachability::reached(),
failed => MachineReachability::from(&unreachable_after(&machine.host, failed).await),
}
}

/// [`tailscale::local_status`], gated on [`tailscale::host_looks_like_tailnet`] first
/// so a LAN/public server's diagnosis never pays for the subprocess at all — folds
/// into the one signal [`ServerDiagnosis::tailscale_off_locally`] ever carries:
Expand Down Expand Up @@ -2106,6 +2177,16 @@ pub async fn machine_diagnose(app: tauri::AppHandle, machine_id: String) -> Resu
Ok(with_bundled_version(&app, diagnose(&machine, known_hosts.as_deref()).await))
}

/// The ambient health probe — see [`probe_reachability`] for why it is not
/// [`machine_diagnose`].
#[tauri::command]
#[specta::specta]
pub async fn machine_reachability(app: tauri::AppHandle, machine_id: String) -> Result<MachineReachability, String> {
let machine = machine_by_id(&app, &machine_id)?;
let known_hosts = known_hosts_path(&app);
Ok(probe_reachability(&machine, known_hosts.as_deref()).await)
}

// ============================================================================
// repair
// ============================================================================
Expand Down Expand Up @@ -2967,6 +3048,43 @@ mod tests {
assert_eq!(not_true.state, DiagnosisState::Failed { reason: "could not reach the server".to_string() });
}

/// The ambient probe must say WHY in the full diagnosis's own words — the mark's
/// tooltip and the server panel can never disagree on the reason.
#[test]
fn reachability_carries_the_diagnosis_reason_verbatim() {
for (issue, tailscale_off) in [
(SshLinkIssue::KeyRefused, None),
(SshLinkIssue::HostKeyChanged, None),
(SshLinkIssue::Unreachable, None),
(SshLinkIssue::Unreachable, Some(true)),
] {
let d = ServerDiagnosis::unreachable_with(issue, tailscale_off);
let DiagnosisState::Failed { reason } = &d.state else { panic!("unreachable is Failed") };
assert_eq!(
MachineReachability::from(&d),
MachineReachability { reachable: false, reason: Some(reason.clone()) },
);
}
let invalid = MachineReachability::from(&invalid_connection_details());
assert!(!invalid.reachable);
assert_eq!(
invalid.reason.as_deref(),
Some("this server's saved connection details are not valid — remove and re-add it"),
);
assert_eq!(MachineReachability::reached(), MachineReachability { reachable: true, reason: None });
}

/// A REACHABLE server whose daemon is stopped is `Failed` too — but it must never
/// read as unreachable through the ambient probe's shape (same prudence as
/// `store/machineHealth.ts`, which only ever looks at `reachable`).
#[test]
fn reachability_of_a_reachable_failed_diagnosis_has_no_reason() {
let mut d = ServerDiagnosis::unreachable();
d.reachable = true;
d.state = DiagnosisState::Failed { reason: "flightdeckd is not running".to_string() };
assert_eq!(MachineReachability::from(&d), MachineReachability { reachable: true, reason: None });
}

#[test]
fn plain_unreachable_delegates_to_unreachable_with() {
let d = ServerDiagnosis::unreachable();
Expand Down
5 changes: 4 additions & 1 deletion src-tauri/src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,10 @@ fn parse_worktree_list(porcelain: &str) -> Vec<WorktreeInfo> {
/// Status of a single worktree: dirtiness (from `git status --porcelain`) and
/// the ahead/behind count against the branch's upstream (`None` when unset).
pub fn worktree_status(worktree_path: &str) -> Result<WorktreeStatus, GitError> {
let porcelain = run_git(worktree_path, &["status", "--porcelain"])?;
// ⚠️ `--no-optional-locks` (top-level, before the subcommand): a read-only status must
// not take `.git/index.lock` from under the agent's own `git add` / `git commit` — see
// `status::STATUS_ARGS`.
let porcelain = run_git(worktree_path, &["--no-optional-locks", "status", "--porcelain"])?;
let mut status = WorktreeStatus::default();
for line in porcelain.lines() {
if line.is_empty() {
Expand Down
Loading
Loading