3 Commits

Author SHA1 Message Date
62e41bda66 merge(layout): intègre l'auto-réparation de l'onglet
actif stale

Co-Authored-By: Claude Opus 4.8
    <noreply@anthropic.com>
2026-06-25 16:23:40 +02:00
e916ecd95e fix(layout): auto-réparer l'onglet actif
stale

Le backend renvoie l'id actif autoritaire et le
    frontend l'adopte pour éviter de rejouer un layout
    disparu après overwrite externe.

Co-Authored-By: Claude Opus 4.8
    <noreply@anthropic.com>
2026-06-25 16:23:40 +02:00
137620daa3 chore(gitignore): détrack .ideai/layouts.json (état UI runtime machine-local)
layouts.json portait l'état UI runtime (active layout id + session ids),
reconstruit à l'exécution et last-writer-wins — même catégorie que
.ideai/live-state.json déjà ignoré. Versionné à tort, il était écrasé par
git au switch de branche → activeId périmé → "not found: layout X" → cellules
figées. On le détrack (git rm --cached, fichier conservé sur disque) et on
l'ajoute au .gitignore sous le bloc live-state. Le fix runtime (self-heal de
l'active layout) viendra dans un commit séparé sur cette branche.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:46:08 +02:00
16 changed files with 375 additions and 70 deletions

3
.gitignore vendored
View File

@ -43,6 +43,9 @@ frontend/coverage/
# Volatile agent live-state snapshot ("who is doing what right now", lot LS2): # Volatile agent live-state snapshot ("who is doing what right now", lot LS2):
# rebuilt at runtime, keyed last-writer-wins — not versioned (unlike .ideai/memory/). # rebuilt at runtime, keyed last-writer-wins — not versioned (unlike .ideai/memory/).
.ideai/live-state.json .ideai/live-state.json
# UI layout runtime state (active layout id + session ids) — machine-local,
# rebuilt at runtime, last-writer-wins — not versioned (same class as live-state.json).
/.ideai/layouts.json
# ─── Editors / OS ─────────────────────────────────────────────────────────── # ─── Editors / OS ───────────────────────────────────────────────────────────
.idea/ .idea/

View File

@ -1,19 +0,0 @@
{
"version": 1,
"activeId": "49c6b0fc-13c1-40f5-9464-d3248ab54597",
"layouts": [
{
"id": "49c6b0fc-13c1-40f5-9464-d3248ab54597",
"name": "Default",
"kind": "terminal",
"tree": {
"root": {
"type": "leaf",
"node": {
"id": "852f5128-5b98-4d36-9a18-e49a57fc4940"
}
}
}
}
]
}

View File

@ -19,12 +19,11 @@ use application::{
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime, ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput, MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
ReconcileLiveStateInput, ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput,
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput, ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput,
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput, SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput, UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
}; };
use domain::ports::PtyHandle; use domain::ports::PtyHandle;
@ -49,13 +48,13 @@ use crate::dto::{
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto,
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
WriteTerminalRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
}; };
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState; use crate::state::AppState;
@ -678,14 +677,16 @@ pub async fn delete_layout(
/// `set_active_layout` — switch the active named layout of a project. /// `set_active_layout` — switch the active named layout of a project.
/// ///
/// Self-healing: a stale `layout_id` does not error; the active layout is left
/// unchanged and the actually-active id is returned (authoritative).
///
/// # Errors /// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the /// Returns an [`ErrorDto`] (`INVALID` for malformed id) or a persistence error.
/// project or layout is unknown).
#[tauri::command] #[tauri::command]
pub async fn set_active_layout( pub async fn set_active_layout(
request: SetActiveLayoutRequestDto, request: SetActiveLayoutRequestDto,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), ErrorDto> { ) -> Result<SetActiveLayoutResultDto, ErrorDto> {
let project_id = parse_project_id(&request.project_id)?; let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?; let layout_id = parse_layout_id(&request.layout_id)?;
state state
@ -695,6 +696,7 @@ pub async fn set_active_layout(
layout_id, layout_id,
}) })
.await .await
.map(SetActiveLayoutResultDto::from)
.map_err(ErrorDto::from) .map_err(ErrorDto::from)
} }

View File

@ -390,7 +390,7 @@ pub fn parse_session_id(raw: &str) -> Result<SessionId, ErrorDto> {
use application::{ use application::{
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput, CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
LoadLayoutOutput, MutateLayoutOutput, LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput,
}; };
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId}; use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId};
@ -700,6 +700,23 @@ pub struct DeleteLayoutRequestDto {
pub layout_id: String, pub layout_id: String,
} }
/// Response DTO for `set_active_layout`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetActiveLayoutResultDto {
/// The layout actually activated — equals the requested id when valid, else
/// the unchanged current active id (self-healing fallback). Authoritative.
pub active_id: String,
}
impl From<SetActiveLayoutOutput> for SetActiveLayoutResultDto {
fn from(out: SetActiveLayoutOutput) -> Self {
Self {
active_id: out.active_id.to_string(),
}
}
}
/// Request DTO for `set_active_layout`. /// Request DTO for `set_active_layout`.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@ -297,6 +297,15 @@ pub struct SetActiveLayoutInput {
pub layout_id: LayoutId, pub layout_id: LayoutId,
} }
/// Output of [`SetActiveLayout::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetActiveLayoutOutput {
/// The id of the layout that was **actually** made active. Equals the
/// requested id when it exists; otherwise the unchanged current active id
/// (self-healing fallback). Authoritative for the frontend (invariant I4).
pub active_id: LayoutId,
}
/// Switches the active layout of a project. /// Switches the active layout of a project.
pub struct SetActiveLayout { pub struct SetActiveLayout {
store: Arc<dyn ProjectStore>, store: Arc<dyn ProjectStore>,
@ -317,20 +326,30 @@ impl SetActiveLayout {
/// Sets the active layout. /// Sets the active layout.
/// ///
/// A stale requested id (e.g. an `activeId` left over after git overwrote
/// `layouts.json`) must **not** freeze the workspace: instead of hard-
/// erroring, it degrades silently to the current active layout (invariant
/// I3). The returned [`SetActiveLayoutOutput::active_id`] is the id that was
/// actually activated and is authoritative for the frontend (invariant I4).
///
/// # Errors /// # Errors
/// [`AppError::NotFound`] if the project or layout is unknown. /// - [`AppError::FileSystem`] on persistence failure,
pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> { /// - [`AppError::Store`] on registry I/O failure.
pub async fn execute(
&self,
input: SetActiveLayoutInput,
) -> Result<SetActiveLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?; let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
if doc.find(input.layout_id).is_none() { // Self-heal: keep the requested id when valid, else fall back to the
return Err(AppError::NotFound(format!("layout {}", input.layout_id))); // (always-valid, I2) current active id rather than erroring.
} let active_id = doc.resolve_existing_id(Some(input.layout_id));
doc.active_id = input.layout_id; doc.active_id = active_id;
persist_doc(self.fs.as_ref(), &project, &doc).await?; persist_doc(self.fs.as_ref(), &project, &doc).await?;
self.events.publish(DomainEvent::LayoutChanged { self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project_id, project_id: input.project_id,
}); });
Ok(()) Ok(SetActiveLayoutOutput { active_id })
} }
} }

View File

@ -29,7 +29,7 @@ mod usecases;
pub use management::{ pub use management::{
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput, CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout, DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
}; };
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput}; pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
pub use snapshot::{ pub use snapshot::{

View File

@ -77,6 +77,22 @@ impl LayoutsDoc {
id.unwrap_or(self.active_id) id.unwrap_or(self.active_id)
} }
/// Resolves a requested view id to a **valid** layout id, never failing.
///
/// Returns `id` when it is present in `layouts`; otherwise falls back to
/// `active_id` (guaranteed valid by invariant I2 / [`Self::is_valid`]).
/// A `None` request (the current view) also resolves to `active_id`. This
/// is the self-healing path: a stale `active_id` written by an external tool
/// (e.g. git overwriting `layouts.json`) degrades silently instead of
/// hard-erroring and freezing the workspace (invariant I3).
#[must_use]
pub fn resolve_existing_id(&self, id: Option<LayoutId>) -> LayoutId {
match id {
Some(id) if self.find(id).is_some() => id,
_ => self.active_id,
}
}
/// Finds a layout by id. /// Finds a layout by id.
#[must_use] #[must_use]
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> { pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
@ -177,3 +193,55 @@ pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<Layou
persist_doc(fs, project, &doc).await?; persist_doc(fs, project, &doc).await?;
Ok(doc) Ok(doc)
} }
#[cfg(test)]
mod tests {
use super::*;
/// Builds a doc with two layouts; the first one is active.
fn two_layout_doc() -> (LayoutsDoc, LayoutId, LayoutId) {
let active = LayoutId::new_random();
let other = LayoutId::new_random();
let doc = LayoutsDoc {
version: LAYOUTS_VERSION,
active_id: active,
layouts: vec![
NamedLayout {
id: active,
name: "Default".to_owned(),
kind: LayoutKind::Terminal,
tree: default_tree(),
},
NamedLayout {
id: other,
name: "Second".to_owned(),
kind: LayoutKind::Terminal,
tree: default_tree(),
},
],
};
(doc, active, other)
}
#[test]
fn resolve_existing_id_returns_present_id() {
let (doc, _active, other) = two_layout_doc();
// A present, non-active id is returned verbatim.
assert_eq!(doc.resolve_existing_id(Some(other)), other);
}
#[test]
fn resolve_existing_id_falls_back_to_active_when_absent() {
let (doc, active, _other) = two_layout_doc();
// A stale id absent from `layouts` degrades to the valid active id.
let stale = LayoutId::new_random();
assert_eq!(doc.resolve_existing_id(Some(stale)), active);
}
#[test]
fn resolve_existing_id_none_resolves_to_active() {
let (doc, active, _other) = two_layout_doc();
// The "current view" request resolves to the active id.
assert_eq!(doc.resolve_existing_id(None), active);
}
}

View File

@ -169,7 +169,10 @@ impl LoadLayout {
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> { pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?; let project = self.store.load_project(input.project_id).await?;
let doc = resolve_doc(self.fs.as_ref(), &project).await?; let doc = resolve_doc(self.fs.as_ref(), &project).await?;
let id = doc.resolve_id(input.layout_id); // Self-healing (I3): a stale requested id degrades to the valid active
// id instead of hard-erroring. `find` is now infallible in practice;
// the `ok_or` is kept as a purely defensive guard.
let id = doc.resolve_existing_id(input.layout_id);
let named = doc let named = doc
.find(id) .find(id)
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?; .ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
@ -229,7 +232,10 @@ impl MutateLayout {
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> { pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
let project = self.store.load_project(input.project_id).await?; let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?; let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let id = doc.resolve_id(input.layout_id); // Self-healing (I3): a stale requested id degrades to the valid active
// id instead of hard-erroring. `find_mut` is now infallible in
// practice; the `ok_or` is kept as a purely defensive guard.
let id = doc.resolve_existing_id(input.layout_id);
let named = doc let named = doc
.find_mut(id) .find_mut(id)

View File

@ -31,8 +31,7 @@ pub mod window;
pub mod workstate; pub mod workstate;
pub use agent::{ pub use agent::{
drain_with_readiness, drain_with_readiness_outcome, drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
reference_profile_id, reference_profiles,
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile, selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
@ -73,8 +72,8 @@ pub use layout::{
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput, ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts, MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput, ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
SnapshotRunningAgentsOutput, LAYOUTS_FILE, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
}; };
pub use memory::{ pub use memory::{
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput, CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,

View File

@ -338,21 +338,24 @@ async fn load_tolerates_corrupt_json_with_default() {
} }
#[tokio::test] #[tokio::test]
async fn load_unknown_layout_id_is_not_found() { async fn load_unknown_layout_id_self_heals_to_active() {
let store = FakeStore::default(); let store = FakeStore::default();
let fs = FakeFs::default(); let fs = FakeFs::default();
let id = register_project(&store, pid(5)).await; let id = register_project(&store, pid(5)).await;
seed_layouts(&fs, lid(1), &single_leaf(nid(1))); seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
// A stale requested id (e.g. left over after git overwrote layouts.json)
// must NOT hard-error and freeze the workspace; it degrades silently to the
// valid active layout (invariant I3), whose id is authoritative (I4).
let load = LoadLayout::new(Arc::new(store), Arc::new(fs)); let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
let err = load let out = load
.execute(LoadLayoutInput { .execute(LoadLayoutInput {
project_id: id, project_id: id,
layout_id: Some(lid(999)), layout_id: Some(lid(999)),
}) })
.await .await
.expect_err("unknown layout id rejected"); .expect("stale layout id must self-heal, not error");
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert_eq!(out.layout_id, lid(1));
} }
#[tokio::test] #[tokio::test]
@ -937,13 +940,15 @@ async fn set_active_layout_switches_and_load_follows() {
.unwrap(); .unwrap();
// Switch back to the Default layout. // Switch back to the Default layout.
SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus)) let set = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(SetActiveLayoutInput { .execute(SetActiveLayoutInput {
project_id: pid(35), project_id: pid(35),
layout_id: lid(1), layout_id: lid(1),
}) })
.await .await
.unwrap(); .unwrap();
// Output echoes the actually-activated id (I4).
assert_eq!(set.active_id, lid(1));
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs)) let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
.execute(LoadLayoutInput { .execute(LoadLayoutInput {
@ -955,3 +960,31 @@ async fn set_active_layout_switches_and_load_follows() {
assert_eq!(loaded.layout_id, lid(1)); assert_eq!(loaded.layout_id, lid(1));
assert_ne!(loaded.layout_id, created.layout_id); assert_ne!(loaded.layout_id, created.layout_id);
} }
#[tokio::test]
async fn set_active_layout_stale_id_self_heals_to_current_active() {
// Seeded env: a single "Default" layout, active = lid(1).
let (store, fs, bus) = mgmt_env(pid(36)).await;
// Targeting a stale id (e.g. an activeId left over after git overwrote
// layouts.json) must NOT error and must NOT freeze the workspace (I3):
// it degrades to the current active id and returns it (I4).
let out = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
.execute(SetActiveLayoutInput {
project_id: pid(36),
layout_id: lid(999),
})
.await
.expect("stale set_active must self-heal, not error");
assert_eq!(out.active_id, lid(1));
// And a subsequent load of the current view resolves to that valid id.
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
.execute(LoadLayoutInput {
project_id: pid(36),
layout_id: None,
})
.await
.unwrap();
assert_eq!(loaded.layout_id, lid(1));
}

View File

@ -48,8 +48,8 @@ export class TauriLayoutGateway implements LayoutGateway {
}); });
} }
setActiveLayout(projectId: string, layoutId: string): Promise<void> { setActiveLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> {
return invoke<void>("set_active_layout", { return invoke<{ activeId: string }>("set_active_layout", {
request: { projectId, layoutId }, request: { projectId, layoutId },
}); });
} }

View File

@ -846,11 +846,17 @@ export class MockLayoutGateway implements LayoutGateway {
return { activeId: ps.activeId }; return { activeId: ps.activeId };
} }
async setActiveLayout(projectId: string, layoutId: string): Promise<void> { async setActiveLayout(
projectId: string,
layoutId: string,
): Promise<{ activeId: string }> {
const ps = this.getProjectLayouts(projectId); const ps = this.getProjectLayouts(projectId);
// Self-heal (I4): only adopt the requested id when it exists; otherwise keep
// the current active id. Either way the returned id is authoritative.
if (ps.layouts.some((l) => l.id === layoutId)) { if (ps.layouts.some((l) => l.id === layoutId)) {
ps.activeId = layoutId; ps.activeId = layoutId;
} }
return { activeId: ps.activeId };
} }
} }

View File

@ -117,14 +117,25 @@ describe("MockLayoutGateway", () => {
expect(activeId).toBe(newActive); expect(activeId).toBe(newActive);
}); });
it("setActiveLayout switches the active layout", async () => { it("setActiveLayout switches the active layout and returns the authoritative id", async () => {
const gw = new MockLayoutGateway(); const gw = new MockLayoutGateway();
const { layoutId } = await gw.createLayout("p1", "Alt"); const { layoutId } = await gw.createLayout("p1", "Alt");
await gw.setActiveLayout("p1", layoutId); const result = await gw.setActiveLayout("p1", layoutId);
expect(result.activeId).toBe(layoutId);
const { activeId } = await gw.listLayouts("p1"); const { activeId } = await gw.listLayouts("p1");
expect(activeId).toBe(layoutId); expect(activeId).toBe(layoutId);
}); });
it("setActiveLayout self-heals: a stale id keeps (and returns) the current active id", async () => {
const gw = new MockLayoutGateway();
const { activeId: initial } = await gw.listLayouts("p1");
const result = await gw.setActiveLayout("p1", "ghost-layout-id");
// The requested id does not exist → active id is unchanged and returned.
expect(result.activeId).toBe(initial);
const { activeId } = await gw.listLayouts("p1");
expect(activeId).toBe(initial);
});
it("loadLayout with a specific layoutId loads that layout's tree", async () => { it("loadLayout with a specific layoutId loads that layout's tree", async () => {
const gw = new MockLayoutGateway(); const gw = new MockLayoutGateway();
const { layoutId } = await gw.createLayout("p1", "Named"); const { layoutId } = await gw.createLayout("p1", "Named");

View File

@ -6,7 +6,12 @@ import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"; import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock"; import {
MockLayoutGateway,
MockAgentGateway,
MockTerminalGateway,
MockSystemGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { LayoutTabs } from "./LayoutTabs"; import { LayoutTabs } from "./LayoutTabs";
@ -14,11 +19,13 @@ function renderTabs(
layout: MockLayoutGateway, layout: MockLayoutGateway,
projectId = "p1", projectId = "p1",
onActiveLayoutChange = vi.fn(), onActiveLayoutChange = vi.fn(),
system?: MockSystemGateway,
) { ) {
const gateways = { const gateways = {
layout, layout,
terminal: new MockTerminalGateway(), terminal: new MockTerminalGateway(),
agent: new MockAgentGateway(), agent: new MockAgentGateway(),
...(system ? { system } : {}),
} as unknown as Gateways; } as unknown as Gateways;
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
@ -133,4 +140,83 @@ describe("LayoutTabs", () => {
expect(screen.queryByText("Second")).toBeNull(); expect(screen.queryByText("Second")).toBeNull();
}); });
}); });
// ── Active-layout self-heal (feature/layouts-active-self-heal) ──────────────
it("switching tabs adopts the backend's authoritative active id", async () => {
const layout = new MockLayoutGateway();
const { layoutId: betaId } = await layout.createLayout("p1", "Beta");
const onActiveLayoutChange = vi.fn();
renderTabs(layout, "p1", onActiveLayoutChange);
await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy());
await act(async () => {
fireEvent.click(screen.getByText("Beta"));
});
await waitFor(() => {
expect(onActiveLayoutChange).toHaveBeenCalledWith(
expect.objectContaining({ id: betaId, name: "Beta" }),
);
});
});
it("on layoutChanged it re-lists and purges a vanished active id (self-heal)", async () => {
const layout = new MockLayoutGateway();
const system = new MockSystemGateway();
const { layoutId: betaId } = await layout.createLayout("p1", "Beta");
const onActiveLayoutChange = vi.fn();
renderTabs(layout, "p1", onActiveLayoutChange, system);
await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy());
// Make Beta the active layout from the UI.
await act(async () => {
fireEvent.click(screen.getByText("Beta"));
});
await waitFor(() =>
expect(onActiveLayoutChange).toHaveBeenCalledWith(
expect.objectContaining({ id: betaId }),
),
);
// Simulate an EXTERNAL overwrite of layouts.json: Beta is removed and the
// backend active id falls back to Default — done straight on the gateway,
// bypassing the hook, so the hook still locally remembers the (now stale) id.
const { layouts } = await layout.listLayouts("p1");
const defaultId = layouts.find((l) => l.name === "Default")!.id;
await layout.deleteLayout("p1", betaId);
onActiveLayoutChange.mockClear();
// The backend notifies the change → the hook re-lists and purges the stale id.
await act(async () => {
system.emit({ type: "layoutChanged", projectId: "p1" });
});
await waitFor(() => {
expect(screen.queryByText("Beta")).toBeNull();
expect(onActiveLayoutChange).toHaveBeenCalledWith(
expect.objectContaining({ id: defaultId, name: "Default" }),
);
});
});
it("ignores layoutChanged events for another project", async () => {
const layout = new MockLayoutGateway();
const system = new MockSystemGateway();
await layout.createLayout("p1", "Beta");
renderTabs(layout, "p1", vi.fn(), system);
await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy());
// An event for a different project must not trigger a re-list/purge here.
await act(async () => {
system.emit({ type: "layoutChanged", projectId: "other-project" });
});
// Both tabs are still present (no spurious reconciliation).
expect(screen.getByText("Beta")).toBeTruthy();
expect(screen.getByText("Default")).toBeTruthy();
});
}); });

View File

@ -36,13 +36,42 @@ function describe(e: unknown): string {
return String(e); return String(e);
} }
/** True when `e` is the backend `NOT_FOUND` refusal (a {@link GatewayError}). */
function isNotFound(e: unknown): boolean {
return (
typeof e === "object" &&
e !== null &&
"code" in e &&
(e as { code: unknown }).code === "NOT_FOUND"
);
}
export function useLayouts(projectId: string | null): LayoutsViewModel { export function useLayouts(projectId: string | null): LayoutsViewModel {
const { layout: gateway } = useGateways(); const { layout: gateway, system } = useGateways();
const [layouts, setLayouts] = useState<LayoutInfo[]>([]); const [layouts, setLayouts] = useState<LayoutInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Re-list the layouts and reconcile the locally-remembered active id against
// the backend's authoritative set (invariants I3/I4). Any local active id that
// is absent from the freshly-listed layouts is *purged* — we never replay a
// vanished id (the bug after git overwrites `layouts.json`); we fall back to
// the active id the backend reports. The shared self-heal used on mount, on
// `layoutChanged`, and after a NOT_FOUND from an explicit op (rename/delete).
const refresh = useCallback(async () => {
if (!projectId || !gateway) return;
try {
const { layouts: list, activeId: aid } = await gateway.listLayouts(projectId);
setLayouts(list);
setActiveId((prev) =>
prev && list.some((l) => l.id === prev) ? prev : aid,
);
} catch (e) {
setError(describe(e));
}
}, [gateway, projectId]);
// Load the list on mount and whenever projectId changes. // Load the list on mount and whenever projectId changes.
useEffect(() => { useEffect(() => {
if (!projectId || !gateway) { if (!projectId || !gateway) {
@ -71,14 +100,40 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
}; };
}, [gateway, projectId]); }, [gateway, projectId]);
// Re-sync on external layout changes (e.g. git overwriting `layouts.json`,
// reboot reconciliation): the backend emits `layoutChanged`. We re-list and
// purge any stale local active id so the tab bar never replays a vanished id.
useEffect(() => {
if (!projectId || !system) return;
let cancelled = false;
let unsubscribe: (() => void) | undefined;
void system
.onDomainEvent((event) => {
if (event.type === "layoutChanged" && event.projectId === projectId) {
void refresh();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system, projectId, refresh]);
const setActive = useCallback( const setActive = useCallback(
async (layoutId: string) => { async (layoutId: string) => {
if (!projectId || !gateway) return; if (!projectId || !gateway) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
await gateway.setActiveLayout(projectId, layoutId); // I4: the backend's returned id is authoritative — adopt it instead of
setActiveId(layoutId); // re-imposing the requested id (which may be stale). Code defensively in
// case an older backend still returns void: fall back to the requested id.
const result = await gateway.setActiveLayout(projectId, layoutId);
setActiveId(result?.activeId ?? layoutId);
} catch (e) { } catch (e) {
setError(describe(e)); setError(describe(e));
} finally { } finally {
@ -119,12 +174,19 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
prev.map((l) => (l.id === layoutId ? { ...l, name } : l)), prev.map((l) => (l.id === layoutId ? { ...l, name } : l)),
); );
} catch (e) { } catch (e) {
setError(describe(e)); // NOT_FOUND on an explicit op is legitimate (the layout vanished, e.g.
// an external overwrite) and must NOT freeze the UI: re-list and adopt
// the backend's active id instead of surfacing a hard error.
if (isNotFound(e)) {
await refresh();
} else {
setError(describe(e));
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, },
[gateway, projectId], [gateway, projectId, refresh],
); );
const deleteLayout = useCallback( const deleteLayout = useCallback(
@ -141,12 +203,18 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
setLayouts((prev) => prev.filter((l) => l.id !== layoutId)); setLayouts((prev) => prev.filter((l) => l.id !== layoutId));
setActiveId(newActiveId); setActiveId(newActiveId);
} catch (e) { } catch (e) {
setError(describe(e)); // NOT_FOUND means the layout was already gone (e.g. external overwrite):
// treat it as non-fatal — re-list and adopt the backend's active id.
if (isNotFound(e)) {
await refresh();
} else {
setError(describe(e));
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, },
[gateway, projectId, layouts.length], [gateway, projectId, layouts.length, refresh],
); );
return { layouts, activeId, busy, error, setActive, create, rename, deleteLayout }; return { layouts, activeId, busy, error, setActive, create, rename, deleteLayout };

View File

@ -375,8 +375,14 @@ export interface LayoutGateway {
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>; renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
/** Deletes a layout; returns the new active layout id. */ /** Deletes a layout; returns the new active layout id. */
deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>; deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>;
/** Sets the active layout for a project. */ /**
setActiveLayout(projectId: string, layoutId: string): Promise<void>; * Sets the active layout for a project. Returns the layout id the backend
* *actually* activated — authoritative (invariant I4): it equals the requested
* id when valid, else the unchanged current active id (self-healing fallback
* when the requested id was stale, e.g. after an external overwrite of
* `layouts.json`). Callers must adopt this id rather than the one they asked for.
*/
setActiveLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>;
} }
/** Git: status/commit/checkout/… (L8). */ /** Git: status/commit/checkout/… (L8). */