diff --git a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift
index 6c5f1fc..bbb2b31 100644
--- a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift
+++ b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift
@@ -37,6 +37,9 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
/// Время последнего обновления.
public let updatedAt: Date
+ /// Время остановки сессии. nil если сессия ещё не остановлена.
+ public let stoppedAt: Date?
+
enum CodingKeys: String, CodingKey {
case id
case projectId = "project_id"
@@ -49,6 +52,7 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
case errorReason = "error_reason"
case createdAt = "created_at"
case updatedAt = "updated_at"
+ case stoppedAt = "stopped_at"
}
public init(
@@ -62,7 +66,8 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
workDir: String = "",
errorReason: String? = nil,
createdAt: Date,
- updatedAt: Date
+ updatedAt: Date,
+ stoppedAt: Date? = nil
) {
self.id = id
self.projectId = projectId
@@ -75,5 +80,6 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl
self.errorReason = errorReason
self.createdAt = createdAt
self.updatedAt = updatedAt
+ self.stoppedAt = stoppedAt
}
}
diff --git a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift
index 7ba2249..7b0ab1b 100644
--- a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift
+++ b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift
@@ -15,7 +15,7 @@ public struct RemoteTerminal: Codable, Equatable, Hashable, Identifiable, Sendab
public let profile: String
/// Текущее состояние терминала.
- public let state: String
+ public let state: TerminalState
/// Время создания терминала.
public let createdAt: Date
@@ -36,7 +36,7 @@ public struct RemoteTerminal: Codable, Equatable, Hashable, Identifiable, Sendab
id: String,
sessionId: String,
profile: String,
- state: String,
+ state: TerminalState,
createdAt: Date,
updatedAt: Date
) {
diff --git a/MacApp/Packages/SharedModels/Sources/SharedModels/TerminalState.swift b/MacApp/Packages/SharedModels/Sources/SharedModels/TerminalState.swift
new file mode 100644
index 0000000..44ff1c4
--- /dev/null
+++ b/MacApp/Packages/SharedModels/Sources/SharedModels/TerminalState.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+// MARK: - TerminalState
+
+/// Состояние PTY-терминала внутри сессии.
+public enum TerminalState: String, Codable, Equatable, Hashable, Sendable {
+ /// Терминал запускается.
+ case starting
+
+ /// Терминал готов к вводу.
+ case running
+
+ /// Терминал остановлен.
+ case stopped
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let rawValue = try container.decode(String.self)
+ self = TerminalState(rawValue: rawValue) ?? .stopped
+ }
+}
diff --git a/MacApp/RelayTests/DomainModelAlignmentTests.swift b/MacApp/RelayTests/DomainModelAlignmentTests.swift
new file mode 100644
index 0000000..2108523
--- /dev/null
+++ b/MacApp/RelayTests/DomainModelAlignmentTests.swift
@@ -0,0 +1,193 @@
+import Foundation
+import SharedModels
+import Testing
+
+// Tests for domain model alignment changes introduced in PR #289:
+// - D-13: RemoteSession.stoppedAt (was missing; server already sent stopped_at)
+// - D-15: TerminalState enum (was untyped String in RemoteTerminal)
+
+@Suite("Domain Model Alignment")
+struct DomainModelAlignmentTests {
+
+ private let decoder: JSONDecoder = {
+ let d = JSONDecoder()
+ d.dateDecodingStrategy = .iso8601
+ return d
+ }()
+
+ private let encoder: JSONEncoder = {
+ let e = JSONEncoder()
+ e.dateEncodingStrategy = .iso8601
+ return e
+ }()
+
+ // MARK: - RemoteSession.stoppedAt (D-13)
+
+ @Test("stoppedAt: decodes stopped_at from JSON")
+ func stoppedAtDecodesFromJSON() throws {
+ let json = Data("""
+ {
+ "id": "sess-d13-1",
+ "project_id": "proj-001",
+ "branch": "main",
+ "image": "ubuntu:24.04",
+ "state": "stopped",
+ "work_dir": "/data/sessions/sess-d13-1",
+ "stopped_at": "2026-04-14T15:00:00Z",
+ "created_at": "2026-04-14T10:00:00Z",
+ "updated_at": "2026-04-14T15:00:00Z"
+ }
+ """.utf8)
+
+ let session = try decoder.decode(RemoteSession.self, from: json)
+
+ #expect(session.state == .stopped)
+ #expect(session.stoppedAt != nil)
+ }
+
+ @Test("stoppedAt: nil when field absent in JSON")
+ func stoppedAtNilWhenAbsent() throws {
+ let json = Data("""
+ {
+ "id": "sess-d13-2",
+ "project_id": "proj-001",
+ "branch": "main",
+ "image": "ubuntu:24.04",
+ "state": "running",
+ "work_dir": "/data/sessions/sess-d13-2",
+ "created_at": "2026-04-14T10:00:00Z",
+ "updated_at": "2026-04-14T10:00:00Z"
+ }
+ """.utf8)
+
+ let session = try decoder.decode(RemoteSession.self, from: json)
+
+ #expect(session.state == .running)
+ #expect(session.stoppedAt == nil)
+ }
+
+ @Test("stoppedAt: encode/decode roundtrip preserves value")
+ func stoppedAtRoundtrip() throws {
+ let stopDate = Date(timeIntervalSince1970: 1_744_639_200)
+ let original = RemoteSession(
+ id: "sess-d13-3",
+ projectId: "proj-001",
+ branch: "main",
+ state: .stopped,
+ createdAt: Date(timeIntervalSince1970: 0),
+ updatedAt: stopDate,
+ stoppedAt: stopDate
+ )
+
+ let encoded = try encoder.encode(original)
+ let decoded = try decoder.decode(RemoteSession.self, from: encoded)
+
+ #expect(decoded.stoppedAt != nil)
+ // Compare seconds to avoid sub-second ISO-8601 precision loss
+ #expect(decoded.stoppedAt?.timeIntervalSince1970 == stopDate.timeIntervalSince1970)
+ }
+
+ @Test("stoppedAt: nil when session is running — roundtrip")
+ func stoppedAtNilForRunningRoundtrip() throws {
+ let original = RemoteSession(
+ id: "sess-d13-4",
+ projectId: "proj-001",
+ branch: "main",
+ state: .running,
+ createdAt: Date(timeIntervalSince1970: 0),
+ updatedAt: Date(timeIntervalSince1970: 60)
+ )
+
+ let encoded = try encoder.encode(original)
+ let decoded = try decoder.decode(RemoteSession.self, from: encoded)
+
+ #expect(decoded.stoppedAt == nil)
+ }
+
+ // MARK: - TerminalState (D-15)
+
+ @Test("TerminalState: all three values decode correctly")
+ func terminalStateAllValues() throws {
+ let json = Data("""
+ { "states": ["starting", "running", "stopped"] }
+ """.utf8)
+
+ struct Wrapper: Decodable { let states: [TerminalState] }
+ let result = try decoder.decode(Wrapper.self, from: json)
+
+ #expect(result.states == [.starting, .running, .stopped])
+ }
+
+ @Test("TerminalState: unknown value falls back to .stopped without throw")
+ func terminalStateUnknownFallback() throws {
+ let json = Data("""
+ { "states": ["paused", "zombie", ""] }
+ """.utf8)
+
+ struct Wrapper: Decodable { let states: [TerminalState] }
+ let result = try decoder.decode(Wrapper.self, from: json)
+
+ #expect(result.states.allSatisfy { $0 == .stopped })
+ }
+
+ // MARK: - RemoteTerminal (D-2 / D-15)
+
+ @Test("RemoteTerminal: decodes all fields including typed state")
+ func remoteTerminalDecodes() throws {
+ let json = Data("""
+ {
+ "id": "term-001",
+ "session_id": "sess-001",
+ "profile": "claude-code",
+ "state": "running",
+ "created_at": "2026-04-13T10:00:00Z",
+ "updated_at": "2026-04-13T10:30:00Z"
+ }
+ """.utf8)
+
+ let terminal = try decoder.decode(RemoteTerminal.self, from: json)
+
+ #expect(terminal.id == "term-001")
+ #expect(terminal.sessionId == "sess-001")
+ #expect(terminal.profile == "claude-code")
+ #expect(terminal.state == .running)
+ }
+
+ @Test("RemoteTerminal: sessionId FK is preserved")
+ func remoteTerminalSessionIdFK() throws {
+ let json = Data("""
+ {
+ "id": "term-002",
+ "session_id": "sess-abc",
+ "profile": "shell",
+ "state": "stopped",
+ "created_at": "2026-04-13T10:00:00Z",
+ "updated_at": "2026-04-13T11:00:00Z"
+ }
+ """.utf8)
+
+ let terminal = try decoder.decode(RemoteTerminal.self, from: json)
+
+ #expect(terminal.sessionId == "sess-abc")
+ #expect(terminal.state == .stopped)
+ }
+
+ @Test("RemoteTerminal: encode/decode roundtrip preserves state")
+ func remoteTerminalRoundtrip() throws {
+ let original = RemoteTerminal(
+ id: "term-003",
+ sessionId: "sess-003",
+ profile: "default",
+ state: .starting,
+ createdAt: Date(timeIntervalSince1970: 0),
+ updatedAt: Date(timeIntervalSince1970: 60)
+ )
+
+ let encoded = try encoder.encode(original)
+ let decoded = try decoder.decode(RemoteTerminal.self, from: encoded)
+
+ #expect(decoded.id == original.id)
+ #expect(decoded.sessionId == original.sessionId)
+ #expect(decoded.state == original.state)
+ }
+}
diff --git a/docs/architecture/domain-model.md b/docs/architecture/domain-model.md
index d28e956..6a079cd 100644
--- a/docs/architecture/domain-model.md
+++ b/docs/architecture/domain-model.md
@@ -232,3 +232,6 @@ Session.image (explicit override)
| D-10 | `Project.default_image: String?` в image resolution chain (§5) | Добавлен в DB, struct, REST, Swift. Chain: Session → Project → config. | ✅ Закрыт |
| D-11 | `Server.default_image: String?` (клиентская сущность) | `ServerCredential.defaultImage` добавлен. | ✅ Закрыт |
| D-12 | `Session.work_dir: Path` (персистентное) | Добавлен в DB, struct, REST, Swift. | ✅ Закрыт |
+| D-13 | `Session.stopped_at: Timestamp?` в Swift-модели | `stoppedAt: Date?` добавлен в `RemoteSession` (CodingKey `"stopped_at"`). Сервер уже отдавал поле. | ✅ Закрыт |
+| D-14 | `Profile.id: String` отсутствует в REST и Swift | `ProfileResponse` и `SessionProfile` идентифицируют профиль по `name`. Сервер профили не персистирует — хранятся в конфиге. Для MVP приемлемо; добавить `id` при введении серверной персистенции профилей. | 🔵 MVP: по имени |
+| D-15 | `Terminal.state` — нетипизированная строка | `TerminalState` enum добавлен (`starting/running/stopped`), `RemoteTerminal.state: TerminalState`. | ✅ Закрыт |
diff --git a/docs/testplans/domain-model-compliance-test-plan.md b/docs/testplans/domain-model-compliance-test-plan.md
new file mode 100644
index 0000000..3e6327c
--- /dev/null
+++ b/docs/testplans/domain-model-compliance-test-plan.md
@@ -0,0 +1,260 @@
+---
+type: test-plan
+slug: domain-model-compliance
+---
+
+# Test Plan — Domain Model Compliance
+
+**Feature:** Соответствие Swift-моделей утверждённой доменной модели
+**Spec:** `docs/architecture/domain-model.md` v1.0 (статус: утверждена)
+**Scope:** `SharedModels` package + `ServerCredential`
+**Created:** 2026-04-24
+
+**Lightweight template applied** — нет UI-поверхности; TCs используют Given/When/Then.
+
+---
+
+## Findings
+
+Существующие тесты (`CloudEntitiesTests.swift`) покрывают декодирование `ProjectState`, `SessionState`, `SessionProfile`, `RemoteProject`, `RemoteSession`. Пробелы:
+
+1. `RemoteTerminal` и `TerminalState` — нет ни одного теста (D-15 закрыт кодом, не тестами)
+2. `RemoteSession.stoppedAt` — нет (D-13 закрыт кодом, не тестами)
+3. `ServerCredential.defaultImage` — нет
+4. JSON с `stopped_at` → `stoppedAt: Date?` — не проверено
+5. Image resolution chain §5 — не протестирована как поведение (только структуры по отдельности)
+6. Lifecycle-инварианты (`stopped_at` = nil при `running`, non-nil при `stopped`) — не проверены
+
+---
+
+## Risk Areas
+
+| Риск | Уровень |
+|------|---------|
+| Новые поля (`stoppedAt`) молча теряются если CodingKey неверен | High |
+| `TerminalState` unknown fallback → неверный UI | Medium |
+| Image resolution chain — неверный приоритет (Project > Server вместо Session > Project > Server) | High |
+| `ServerCredential.defaultImage` не попадает в цепочку | Medium |
+| Lifecycle-инварианты не соблюдаются при смене состояния | Low |
+
+---
+
+## Test Cases
+
+### TC-1 — RemoteTerminal: decode из REST-JSON
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Preconditions** | `TerminalState` enum присутствует в `SharedModels` |
+| **Scenario** | **Given** JSON с полями `id`, `session_id`, `profile`, `state: "running"`, `created_at`, `updated_at` **When** декодируется как `RemoteTerminal` **Then** все поля разобраны корректно, `state == .running` |
+| **Source** | Spec §2 Terminal |
+
+---
+
+### TC-2 — TerminalState: все значения декодируются
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Preconditions** | — |
+| **Scenario** | **Given** массив строк `["starting", "running", "stopped"]` **When** декодируется как `[TerminalState]` **Then** возвращает `.starting`, `.running`, `.stopped` |
+| **Source** | Spec §2 Terminal, `TerminalState.swift` |
+
+---
+
+### TC-3 — TerminalState: unknown → fallback .stopped
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Preconditions** | — |
+| **Scenario** | **Given** строка `"paused"` (не существует в enum) **When** декодируется как `TerminalState` **Then** возвращает `.stopped`, без throw |
+| **Source** | `TerminalState.swift` — safe fallback декодер |
+
+---
+
+### TC-4 — RemoteTerminal: encode/decode roundtrip
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Preconditions** | — |
+| **Scenario** | **Given** `RemoteTerminal` с `state: .running` **When** encode → decode **Then** все поля сохранены, включая `state` |
+| **Source** | Spec §2 Terminal |
+
+---
+
+### TC-5 — RemoteSession.stoppedAt: decode с полем stopped_at
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Preconditions** | `RemoteSession` имеет поле `stoppedAt: Date?` (D-13) |
+| **Scenario** | **Given** JSON сессии с `"state": "stopped"` и `"stopped_at": "2026-04-14T15:00:00Z"` **When** декодируется как `RemoteSession` **Then** `session.stoppedAt != nil`, значение соответствует ISO-8601 строке |
+| **Source** | Spec §2 Session, D-13 |
+
+---
+
+### TC-6 — RemoteSession.stoppedAt: null для running-сессии
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Preconditions** | — |
+| **Scenario** | **Given** JSON сессии с `"state": "running"` и без поля `stopped_at` **When** декодируется как `RemoteSession` **Then** `session.stoppedAt == nil` |
+| **Source** | Spec §2 Session lifecycle |
+
+---
+
+### TC-7 — RemoteSession.stoppedAt: encode/decode roundtrip
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Preconditions** | — |
+| **Scenario** | **Given** `RemoteSession` с `stoppedAt: Date(...)` **When** encode → decode **Then** `stoppedAt` сохраняется |
+| **Source** | D-13 |
+
+---
+
+### TC-8 — ServerCredential.defaultImage: encode/decode roundtrip
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Preconditions** | — |
+| **Scenario** | **Given** `ServerCredential` с `defaultImage: "ubuntu:24.04"` **When** encode → decode **Then** `defaultImage == "ubuntu:24.04"` |
+| **Source** | Spec §2 Server, D-11 |
+
+---
+
+### TC-9 — ServerCredential.defaultImage: nil по умолчанию
+
+| | |
+|---|---|
+| **Priority** | P2 Medium |
+| **Tier** | Feature |
+| **Preconditions** | — |
+| **Scenario** | **Given** `ServerCredential` создан без `defaultImage` **When** читается `defaultImage` **Then** `== nil` |
+| **Source** | Spec §2 Server |
+
+---
+
+### TC-10 — Image resolution: Session.image имеет приоритет над Project
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Preconditions** | Логика resolution chain реализована в UI/ViewModel |
+| **Scenario** | **Given** `RemoteSession.image = "custom:latest"`, `RemoteProject.defaultImage = "ubuntu:24.04"`, `ServerCredential.defaultImage = "alpine:latest"` **When** вычисляется образ для отображения **Then** результат `"custom:latest"` (Session wins) |
+| **Source** | Spec §5 |
+
+---
+
+### TC-11 — Image resolution: Project.defaultImage, когда Session.image = ""
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Preconditions** | Логика resolution chain реализована |
+| **Scenario** | **Given** `RemoteSession.image = ""`, `RemoteProject.defaultImage = "node:20"`, `ServerCredential.defaultImage = "alpine:latest"` **When** вычисляется образ **Then** результат `"node:20"` (Project wins над Server) |
+| **Source** | Spec §5 |
+
+---
+
+### TC-12 — Image resolution: ServerCredential.defaultImage как последний fallback
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Preconditions** | Логика resolution chain реализована |
+| **Scenario** | **Given** `RemoteSession.image = ""`, `RemoteProject.defaultImage = nil`, `ServerCredential.defaultImage = "alpine:latest"` **When** вычисляется образ **Then** результат `"alpine:latest"` |
+| **Source** | Spec §5 |
+
+---
+
+### TC-13 — RemoteSession.projectId: FK присутствует
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Regression |
+| **Preconditions** | — |
+| **Scenario** | **Given** JSON сессии с `"project_id": "proj-abc"` **When** декодируется **Then** `session.projectId == "proj-abc"` |
+| **Source** | Spec §3, D-6, D-7 |
+
+---
+
+### TC-14 — RemoteTerminal.sessionId: FK присутствует
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Regression |
+| **Preconditions** | — |
+| **Scenario** | **Given** JSON терминала с `"session_id": "sess-abc"` **When** декодируется **Then** `terminal.sessionId == "sess-abc"` |
+| **Source** | Spec §3, D-2 |
+
+---
+
+### TC-15 — SessionProfile: не содержит поле image
+
+| | |
+|---|---|
+| **Priority** | P2 Medium |
+| **Tier** | Regression |
+| **Preconditions** | — |
+| **Scenario** | **Given** JSON профиля с лишним полем `"image": "ubuntu:24.04"` **When** декодируется как `SessionProfile` **Then** нет ошибки, поле молча игнорируется; объект содержит только `name`, `initCommand`, `envVars` |
+| **Source** | Spec §2 Profile, D-3 — image убран из профиля |
+
+---
+
+## Edge Cases & Negative Scenarios
+
+| TC | Сценарий | Ожидаемый результат |
+|----|----------|---------------------|
+| TC-E1 | `RemoteSession` JSON без опциональных полей (`container_id`, `stopped_at`, `error_reason`, `profile`) | Декодирование успешно, все nil |
+| TC-E2 | `TerminalState` для каждого из трёх значений в цикле | Нет неожиданных fallback-ов |
+| TC-E3 | `RemoteProject.state = "failed"` + `clone_error` присутствует | Оба поля декодируются |
+| TC-E4 | `RemoteSession` с `state: "stopped"` и `stopped_at` одновременно — roundtrip | Данные не теряются при encode/decode |
+
+---
+
+## Coverage Matrix
+
+| Сущность | Decode | Encode | Roundtrip | Lifecycle | FK |
+|----------|--------|--------|-----------|-----------|-----|
+| `ProjectState` | ✅ есть | — | — | — | — |
+| `SessionState` | ✅ есть | — | — | — | — |
+| `TerminalState` | ❌ **TC-2,3** | — | ❌ **TC-4** | — | — |
+| `SessionProfile` | ✅ есть | ✅ есть | ✅ есть | — | — |
+| `RemoteProject` | ✅ есть | ✅ есть | ✅ есть | — | — |
+| `RemoteSession` | ✅ частично | ✅ есть | ✅ есть | ❌ **TC-6** | ❌ **TC-13** |
+| `RemoteSession.stoppedAt` | ❌ **TC-5** | — | ❌ **TC-7** | ❌ **TC-6** | — |
+| `RemoteTerminal` | ❌ **TC-1** | — | ❌ **TC-4** | — | ❌ **TC-14** |
+| `ServerCredential.defaultImage` | — | — | ❌ **TC-8,9** | — | — |
+| Image resolution chain §5 | — | — | — | ❌ **TC-10,11,12** | — |
+
+---
+
+## Suggested Automation Candidates
+
+Все TC подходят для автоматизации через Swift Testing (`@Test`/`@Suite`) в `CloudEntitiesTests.swift`:
+
+- **TC-1..4** → новый `@Suite("Remote Terminal")` в `CloudEntitiesTests`
+- **TC-5..7** → расширение `// MARK: - RemoteSession` в том же файле
+- **TC-8..9** → новый `@Suite("ServerCredential")` в `RemoteTerminalTests/ServerCredentialTests.swift`
+- **TC-10..12** → отдельный тест если resolution chain станет функцией; пока `[inferred from code]`
+- **TC-13..15** → `@Suite("Domain Model FK")` regression suite
diff --git a/docs/testplans/terminal-tab-switching-test-plan.md b/docs/testplans/terminal-tab-switching-test-plan.md
new file mode 100644
index 0000000..b2deca4
--- /dev/null
+++ b/docs/testplans/terminal-tab-switching-test-plan.md
@@ -0,0 +1,437 @@
+---
+type: test-plan
+slug: terminal-tab-switching
+---
+
+# Test Plan — Terminal Tab Switching
+
+**Feature:** Переключение терминальных вкладок при смене сессии / worktree / workspace
+**Spec:** `docs/architecture/domain-model.md` §2–4, `docs/testplans/11-client-ui.md`, `docs/testplans/03-session-lifecycle.md`
+**Created:** 2026-04-24
+
+---
+
+## Findings
+
+Существующие тест-планы (`11-client-ui.md`) имеют TC-11-08..TC-11-10 — базовые сценарии открытия/закрытия вкладок. Но нет TC на:
+
+1. Переключение активной вкладки при выборе worktree в sidebar
+2. Поведение default-вкладки при смене проекта
+3. Изоляцию вкладок между worktrees: вкладка одного worktree не влияет на другой
+4. Remote: выбор сессии → открытие нужного терминала
+5. Состояния сессии, при которых "Open Terminal" недоступен
+6. Multiple terminals 1:N внутри одной сессии (вкладки независимы)
+
+---
+
+## Доменные инварианты (из spec)
+
+Из `domain-model.md` §2–4:
+
+- **Session : Terminal = 1 : N** — каждый терминал независимый PTY внутри одной сессии
+- **Worktree ↔ Session** — каждый worktree имеет свой `work_dir`; сессии между local и remote не переносятся
+- **Local**: контейнера нет, PTY-процессы на хосте, `work_dir = git worktree`
+- **Remote**: Session = 1 Docker-контейнер; терминалы = `docker exec`
+- **Закрытие вкладки** ≠ остановка сессии (detach, не terminate) — TC-11-09
+
+---
+
+## Risk Areas
+
+| Риск | Уровень |
+|------|---------|
+| При смене worktree открывается терминал в неверной директории | High |
+| Вкладки разных worktrees влияют друг на друга | High |
+| "Open Terminal" доступен на stopped/failed сессии | Medium |
+| После закрытия последней вкладки сессия не остаётся running | High |
+| Default-вкладка не переключается при открытии другого проекта | Medium |
+| Множественные табы одной сессии не изолированы | Medium |
+
+---
+
+## Test Cases
+
+### TC-1 — Открытие проекта создаёт default вкладку
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Local |
+| **Preconditions** | Relay.app запущен; локальный git-репозиторий доступен |
+| **Steps** | 1. File → Open Project (или drag-and-drop директории репозитория)
2. Проверить вкладки |
+| **Expected** | Создаётся ровно одна вкладка; title содержит имя проекта; вкладка активна; worktree sidebar показывает main worktree |
+| **Fail** | Нет вкладки; вкладка не активна; неправильная директория |
+| **Source** | domain-model.md §4 Local, 11-client-ui.md |
+
+---
+
+### TC-2 — Смена worktree → новая Claude-сессия запускается в директории выбранного worktree
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Local |
+| **Preconditions** | Проект открыт; в репозитории есть 2+ worktrees |
+| **Steps** | 1. Sidebar: выбрать worktree `feature/auth` (не main)
2. Нажать "New Claude Session"
3. Проверить рабочую директорию в новой вкладке |
+| **Expected** | Новая вкладка открылась; `pwd` в терминале = путь к worktree `feature/auth`; предыдущая вкладка осталась открытой |
+| **Fail** | Новая вкладка открылась в директории другого worktree; предыдущая вкладка закрылась |
+| **Source** | domain-model.md §4 Local |
+
+---
+
+### TC-3 — Вкладки разных worktrees изолированы
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Feature |
+| **Mode** | Local |
+| **Preconditions** | Два worktrees: `main`, `feature/auth` — каждый имеет открытую вкладку |
+| **Steps** | 1. Перейти на вкладку `main`; выполнить команду `touch /tmp/test-main`
2. Переключиться на вкладку `feature/auth`
3. Проверить, что команда не повлияла на состояние второй вкладки |
+| **Expected** | Каждая вкладка — независимый PTY в своей директории; команды в одном не влияют на другой |
+| **Fail** | Вывод команды появился в обоих терминалах; PTY-процессы разделены |
+| **Source** | domain-model.md §2 Terminal, §4 Local |
+
+---
+
+### TC-4 — Закрытие вкладки не убивает PTY-сессию
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Local |
+| **Preconditions** | Открыта вкладка с активным Claude Code агентом |
+| **Steps** | 1. Запустить долгую команду в терминале
2. Закрыть вкладку (Cmd+W)
3. Открыть новую вкладку к той же сессии (если возможно)
4. Либо проверить через Activity Monitor, что процесс жив |
+| **Expected** | Вкладка закрывается; PTY-процесс продолжает работу (detach) |
+| **Fail** | PTY-процесс убит; сессия перешла в `stopped`/`failed` |
+| **Source** | domain-model.md §2 Terminal, 11-client-ui.md TC-11-09 |
+
+---
+
+### TC-5 — Remote: "Open Terminal" создаёт новую вкладку для running-сессии
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Remote |
+| **Preconditions** | Подключён к серверу; сессия в состоянии `running` |
+| **Steps** | 1. Session list → выбрать running сессию
2. Нажать "Open Terminal"
3. Проверить вкладки |
+| **Expected** | Новая terminal-вкладка открылась; title содержит имя сессии/проекта; shell prompt в контейнере доступен |
+| **Fail** | Вкладка не открылась; нет prompt; вкладка открылась не для той сессии |
+| **Source** | domain-model.md §4 Remote, 11-client-ui.md TC-11-08 |
+
+---
+
+### TC-6 — Remote: "Open Terminal" недоступен для stopped-сессии
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | Сессия в состоянии `stopped` |
+| **Steps** | 1. Session list → выбрать stopped сессию
2. Проверить наличие кнопки "Open Terminal" |
+| **Expected** | "Open Terminal" недоступна (disabled или отсутствует); доступны только Resume / Delete |
+| **Fail** | "Open Terminal" доступна для stopped сессии; нажатие вызывает ошибку без UX |
+| **Source** | domain-model.md §2 SessionState lifecycle |
+
+---
+
+### TC-7 — Remote: два "Open Terminal" на одной сессии — два независимых PTY
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | Сессия `running` |
+| **Steps** | 1. "Open Terminal" → вкладка T1
2. Вернуться в session list → "Open Terminal" снова → вкладка T2
3. В T1 запустить `echo "hello from T1"`
4. Проверить T2 |
+| **Expected** | T1 и T2 — отдельные PTY (docker exec) внутри одного контейнера; вывод T1 не появляется в T2 |
+| **Fail** | T1 и T2 разделяют stdin/stdout; закрытие T1 убивает T2 |
+| **Source** | domain-model.md §2 Terminal (1:N), 11-client-ui.md TC-11-10 |
+
+---
+
+### TC-8 — Remote: закрытие terminal-вкладки не останавливает сессию
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Remote |
+| **Preconditions** | Одна terminal-вкладка открыта для remote-сессии |
+| **Steps** | 1. Закрыть terminal-вкладку
2. Проверить статус сессии в session list |
+| **Expected** | Вкладка закрыта; сессия остаётся `running`; контейнер жив |
+| **Fail** | Сессия перешла в `stopped`; контейнер убит |
+| **Source** | domain-model.md §2 Session lifecycle, 11-client-ui.md TC-11-09 |
+
+---
+
+### TC-9 — Local: переключение между существующими вкладками
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Local |
+| **Preconditions** | Открыто 3 вкладки для разных worktrees |
+| **Steps** | 1. Кликнуть вкладку T1 → Cmd+1
2. Кликнуть вкладку T3 → Cmd+3
3. Cmd+[ / Cmd+] для перехода между соседними |
+| **Expected** | Каждое переключение активирует нужный PTY; контент вкладки соответствует её директории; focus в терминале |
+| **Fail** | Неверная вкладка активирована; контент перемешан; фокус потерян |
+| **Source** | domain-model.md §4 Local |
+
+---
+
+### TC-10 — Remote: "Open Terminal" недоступен для failed-сессии
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | Сессия в состоянии `failed` |
+| **Steps** | 1. Session list → выбрать failed сессию
2. Проверить доступные действия |
+| **Expected** | "Open Terminal" недоступна; доступны Retry / Delete; показан `error_reason` |
+| **Fail** | "Open Terminal" активна; нет отображения причины ошибки |
+| **Source** | domain-model.md §2 SessionState, 11-client-ui.md |
+
+---
+
+### TC-11 — Local: смена выбранного worktree обновляет директорию для следующей сессии
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Local |
+| **Preconditions** | Проект с 3+ worktrees; sidebar загружен |
+| **Steps** | 1. Sidebar: выбрать worktree A → New Shell Tab → проверить `pwd`
2. Sidebar: выбрать worktree B → New Shell Tab → проверить `pwd` |
+| **Expected** | Первый shell открылся в директории A; второй — в директории B; обе вкладки существуют |
+| **Fail** | Оба shell открылись в одной директории; второй переиспользовал вкладку первого |
+| **Source** | domain-model.md §2 Session.work_dir |
+
+---
+
+### TC-12 — Remote: выбор сессии с открытым терминалом переключает активную вкладку
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Remote |
+| **Preconditions** | Открыты терминалы для двух сессий SA и SB; активна вкладка SA |
+| **Steps** | 1. Session list → выбрать SB (у которой уже открыт терминал)
2. Проверить активную вкладку |
+| **Expected** | Активная вкладка переключается на терминал SB; вкладка SA остаётся открытой; содержимое SB-терминала отображается |
+| **Fail** | Активная вкладка не изменилась; открылась новая дублирующая вкладка для SB |
+| **Source** | domain-model.md §2 Session → Terminal, §3 отношения |
+
+---
+
+### TC-13 — Remote: выбор сессии без открытого терминала не переключает вкладки автоматически
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | Одна terminal-вкладка открыта для SA; SB — running, терминал не открывался |
+| **Steps** | 1. Session list → выбрать SB
2. Проверить вкладки и активный терминал |
+| **Expected** | Вкладка SA остаётся активной; для SB не открывается терминал автоматически; доступна кнопка "Open Terminal" для SB |
+| **Fail** | Автоматически открылся терминал SB; вкладка SA потеряла фокус |
+| **Source** | domain-model.md §2 Terminal — создаётся явным действием, не автоматически |
+
+---
+
+### TC-14 — Local: переключение вкладки обновляет активный worktree в sidebar
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Local |
+| **Preconditions** | Открыты вкладки для worktrees A и B; в sidebar выбран A |
+| **Steps** | 1. Переключиться на вкладку B (клик или Cmd+2)
2. Проверить выделение в worktree sidebar |
+| **Expected** | Sidebar переключается на worktree B; выделение соответствует активной вкладке |
+| **Fail** | Sidebar остался на A; sidebar и вкладки рассинхронизированы |
+| **Source** | domain-model.md §4 Local — tab = session = worktree |
+
+---
+
+### TC-15 — Remote: переход к другому проекту не закрывает уже открытые terminal-вкладки
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | Открыты терминалы Project A (сессия SA) и Project B (сессия SB) |
+| **Steps** | 1. Navigation: перейти к Project B в sidebar
2. Проверить вкладки для SA |
+| **Expected** | Terminal-вкладки SA остаются открытыми; переключение проектов в sidebar не закрывает существующие терминалы |
+| **Fail** | Вкладки SA закрылись при переходе к Project B |
+| **Source** | domain-model.md §4 Гибридный режим |
+
+
+---
+
+## Terminal State Persistence on Session Switch
+
+> Источник: `relay-cloud-architecture.md` §VT parsing, `04-terminal-io.md` TC-04-12.
+> Сервер хранит VT state через `vt_parser.rs` (vte crate): screen buffer + scrollback до `scrollback_lines` строк; при attach клиент получает `AttachResponse.screen_snapshot` (до `snapshot_max_lines = 500` строк).
+> Локальный PTY остаётся живым при detach — клиент просто теряет ссылку на процесс.
+
+---
+
+### TC-16 — Remote: переключение от сессии и обратно — scrollback сохранён
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Remote |
+| **Preconditions** | Открыты вкладки SA и SB; в SA выведен уникальный текст |
+| **Steps** | 1. В вкладке SA выполнить `echo "MARKER-12345"`
2. Переключиться на вкладку SB; поработать в ней
3. Переключиться обратно на SA |
+| **Expected** | Scrollback SA содержит `MARKER-12345`; визуальное состояние экрана восстановлено из VT snapshot; курсор на правильной позиции |
+| **Fail** | Экран SA пустой после возврата; scrollback потерян; курсор сброшен |
+| **Source** | relay-cloud-architecture.md §VT snapshot, TC-04-12 |
+
+---
+
+### TC-17 — Remote: процесс продолжал выводить пока вкладка была не активна — вывод накоплен
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | В SA запущена команда с периодическим выводом (например, `ping localhost`) |
+| **Steps** | 1. SA: запустить `ping localhost`
2. Переключиться на SB на 5с
3. Переключиться обратно на SA |
+| **Expected** | Scrollback SA содержит строки ping, выведенные пока вкладка была неактивна; вывод непрерывный; процесс не прерывался |
+| **Fail** | Вывод прервался на момент переключения; строки пропущены; процесс завис |
+| **Source** | relay-cloud-architecture.md §Connection resilience, vt_parser.rs — буфер сохраняет вывод |
+
+---
+
+### TC-18 — Remote: закрытие и повторное открытие terminal-вкладки одной сессии — VT snapshot
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Remote |
+| **Preconditions** | Сессия SA, открыт терминал; выведен текст |
+| **Steps** | 1. В SA вывести `echo "STATE-BEFORE"`
2. Закрыть вкладку SA (Cmd+W)
3. Session list → SA → "Open Terminal" снова |
+| **Expected** | Новая вкладка SA показывает экран из VT snapshot (последние ≤500 строк); `STATE-BEFORE` виден; процесс в сессии не прерывался |
+| **Fail** | Вкладка открылась с пустым экраном; scrollback не загружен; сессия перезапустилась |
+| **Source** | relay-cloud-architecture.md §VT snapshot, TC-04-12 |
+
+---
+
+### TC-19 — Remote: две сессии — VT state независимы, не смешиваются
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | SA и SB — разные running сессии, каждая с открытым терминалом |
+| **Steps** | 1. SA: `echo "FROM-SESSION-A"`
2. SB: `echo "FROM-SESSION-B"`
3. Переключиться SA → SB → SA несколько раз |
+| **Expected** | Scrollback SA содержит только `FROM-SESSION-A`; scrollback SB — только `FROM-SESSION-B`; никакого смешения |
+| **Fail** | Вывод одной сессии появился в scrollback другой; состояния перепутаны |
+| **Source** | domain-model.md §3 Session → Terminal независимость |
+
+---
+
+### TC-20 — Local: переключение вкладок — PTY-процессы независимы, состояние сохранено
+
+| | |
+|---|---|
+| **Priority** | P0 Critical |
+| **Tier** | Smoke |
+| **Mode** | Local |
+| **Preconditions** | Открыты вкладки для двух worktrees |
+| **Steps** | 1. Вкладка A: запустить `watch date` (непрерывный вывод)
2. Переключиться на вкладку B; поработать 5с
3. Переключиться обратно на A |
+| **Expected** | `watch date` продолжал работать; вывод за время отсутствия накоплен; вкладка B не показывает вывод `watch date` |
+| **Fail** | `watch date` остановился; вывод пропал; вывод появился в B |
+| **Source** | domain-model.md §4 Local — PTY на хосте, нет детача |
+
+---
+
+### TC-21 — Local: CWD каждой вкладки сохранён при переключении
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Local |
+| **Preconditions** | Вкладка A — worktree `main`; вкладка B — worktree `feature/auth` |
+| **Steps** | 1. Вкладка A: `cd src && pwd` → запомнить путь
2. Переключиться на B; `cd tests && pwd`
3. Переключиться обратно на A: `pwd` |
+| **Expected** | A показывает `.../main/src`; B показывает `.../feature/auth/tests`; каждый PTY сохраняет свой CWD независимо |
+| **Fail** | CWD сбросился до корня при переключении; CWD одной вкладки скопировался в другую |
+| **Source** | domain-model.md §2 Session.work_dir, §4 Local |
+
+---
+
+### TC-22 — Remote: повторное подключение после потери сети — VT state восстановлен
+
+| | |
+|---|---|
+| **Priority** | P1 High |
+| **Tier** | Feature |
+| **Mode** | Remote |
+| **Preconditions** | SA с открытым терминалом; стабильное соединение |
+| **Steps** | 1. SA: `echo "BEFORE-DISCONNECT"`
2. Отключить сеть на 5с
3. Включить сеть; ждать reconnect
4. Переключиться на SA-вкладку |
+| **Expected** | После reconnect экран восстановлен из VT snapshot; `BEFORE-DISCONNECT` виден; reconnect произошёл автоматически (exponential backoff) |
+| **Fail** | Экран пустой; нет auto-reconnect; сессия перешла в `failed` из-за потери сети |
+| **Source** | relay-cloud-architecture.md §Connection resilience, TC-04-12 |
+
+---
+
+## Edge Cases & Negative Scenarios
+
+| TC | Сценарий | Ожидаемый результат |
+|----|----------|---------------------|
+| TC-E1 | "Open Terminal" при отсутствии сети (remote offline) | Ошибка подключения с понятным сообщением; вкладка не открывается в broken-состоянии |
+| TC-E2 | Закрытие единственной вкладки (local, default tab) | Default-вкладка не удаляется (защита); или создаётся замена автоматически |
+| TC-E3 | Быстрое переключение между worktrees (< 1с) | Нет race condition; активируется последний выбранный |
+| TC-E4 | Remote сессия перешла в `failed` пока открыта её terminal-вкладка | Вкладка показывает disconnect-состояние или ошибку; не зависает |
+| TC-E5 | Worktree удалён из git пока открыта его вкладка | Sidebar обновляется; вкладка может остаться или показать ошибку пути |
+
+---
+
+## Coverage Matrix
+
+| Сценарий | Покрыт в 11-client-ui.md | Новый TC |
+|----------|--------------------------|----------|
+| Открытие terminal-вкладки (running remote) | TC-11-08 | — |
+| Закрытие вкладки → сессия running | TC-11-09 | TC-4, TC-8 |
+| Множественные вкладки одной сессии | TC-11-10 | TC-7 |
+| Выбор worktree → директория новой сессии | ❌ | **TC-2, TC-11** |
+| Изоляция PTY между worktrees | ❌ | **TC-3** |
+| Open Terminal на stopped сессии | ❌ | **TC-6, TC-10** |
+| Переключение существующих вкладок | ❌ | **TC-9** |
+| Выбор сессии с терминалом → переключает вкладку | ❌ | **TC-12** |
+| Выбор сессии без терминала → не открывает автоматически | ❌ | **TC-13** |
+| Переключение вкладки → sidebar синхронизируется | ❌ | **TC-14** |
+| Смена проекта не закрывает чужие вкладки | ❌ | **TC-15** |
+| Default-вкладка при открытии проекта | ❌ | **TC-1** |
+| Race condition при быстрой смене worktree | ❌ | **TC-E3** |
+| Remote scrollback сохранён при switch away/back | TC-04-12 (gRPC) | **TC-16** |
+| Remote: накопленный вывод при неактивной вкладке | ❌ | **TC-17** |
+| Remote: закрыть/открыть вкладку → VT snapshot | TC-04-12 (gRPC) | **TC-18** |
+| Remote: VT state двух сессий не смешивается | ❌ | **TC-19** |
+| Local: PTY работает пока вкладка неактивна | ❌ | **TC-20** |
+| Local: CWD каждой вкладки независим | ❌ | **TC-21** |
+| Remote: auto-reconnect → VT state восстановлен | ❌ | **TC-22** |
+
+---
+
+## Suggested Automation Candidates
+
+- **TC-1, TC-4, TC-8** — автоматизируемы через `xcodebuild test` (TCA TestStore на `MainFeature`): проверить `state.tabs` после соответствующих actions
+- **TC-2, TC-11** — TCA TestStore: `selectWorktree` → `newClaudeSession` → assert `tabs.last.workingDirectory`
+- **TC-7** — TCA TestStore: два `.openTerminal(session)` → assert `tabs.count == 2`
+- **TC-3, TC-5, TC-6, TC-9, TC-10, TC-12** — ручное тестирование на запущенном приложении (требуют живого runner или PTY)