| 1 | //! Session schema — both the permissive input type (`RawEvent`) we parse |
| 2 | //! out of Claude Code's JSONL files, and the frontend-facing output types |
| 3 | //! (`Project`, `SessionSummary`, `SessionDetail`, `Message`, `ContentBlock`) |
| 4 | //! that get sent over the Tauri IPC bridge. |
| 5 | //! |
| 6 | //! The frontend types are exported to `src/lib/ipc/generated/` via ts-rs |
| 7 | //! during `cargo test` runs. The generated files are committed so CI can |
| 8 | //! diff them against the Rust definitions and fail on drift. |
| 9 | |
| 10 | use chrono::{DateTime, Utc}; |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | use ts_rs::TS; |
| 13 | |
| 14 | // ============================================================================ |
| 15 | // Input side — permissive parse of a single JSONL line. |
| 16 | // ============================================================================ |
| 17 | |
| 18 | /// Catch-all event struct. Every top-level key Claude Code has been |
| 19 | /// observed emitting is `Option<T>` so we never fail parse on schema |
| 20 | /// drift. Type-specific payloads (`message`, `attachment`) are kept as |
| 21 | /// `serde_json::Value` and decoded lazily by the reader. |
| 22 | #[derive(Debug, Clone, Deserialize)] |
| 23 | #[serde(rename_all = "camelCase")] |
| 24 | pub struct RawEvent { |
| 25 | #[serde(rename = "type")] |
| 26 | pub kind: String, |
| 27 | |
| 28 | #[serde(default)] |
| 29 | pub session_id: Option<String>, |
| 30 | #[serde(default)] |
| 31 | pub uuid: Option<String>, |
| 32 | #[serde(default)] |
| 33 | pub parent_uuid: Option<String>, |
| 34 | #[serde(default)] |
| 35 | pub timestamp: Option<DateTime<Utc>>, |
| 36 | #[serde(default)] |
| 37 | pub cwd: Option<String>, |
| 38 | #[serde(default)] |
| 39 | pub git_branch: Option<String>, |
| 40 | #[serde(default)] |
| 41 | pub version: Option<String>, |
| 42 | #[serde(default)] |
| 43 | pub is_sidechain: Option<bool>, |
| 44 | #[serde(default)] |
| 45 | pub is_meta: Option<bool>, |
| 46 | #[serde(default)] |
| 47 | pub slug: Option<String>, |
| 48 | /// Which client produced this session — `"cli"`, `"claude-vscode"`, |
| 49 | /// `"sdk-ts"`, etc. Present on most events that carry `cwd`. |
| 50 | #[serde(default)] |
| 51 | pub entrypoint: Option<String>, |
| 52 | |
| 53 | // Session-level metadata events: |
| 54 | #[serde(default)] |
| 55 | pub custom_title: Option<String>, |
| 56 | /// `ai-title` events carry a generated title in an `aiTitle` field. |
| 57 | #[serde(default, rename = "aiTitle")] |
| 58 | pub ai_title: Option<String>, |
| 59 | #[serde(default)] |
| 60 | pub agent_name: Option<String>, |
| 61 | #[serde(default)] |
| 62 | pub permission_mode: Option<String>, |
| 63 | |
| 64 | // Payloads: |
| 65 | #[serde(default)] |
| 66 | pub message: Option<serde_json::Value>, |
| 67 | #[serde(default)] |
| 68 | pub attachment: Option<serde_json::Value>, |
| 69 | |
| 70 | // System event: |
| 71 | #[serde(default)] |
| 72 | pub subtype: Option<String>, |
| 73 | } |
| 74 | |
| 75 | // ============================================================================ |
| 76 | // Output side — what the frontend sees. |
| 77 | // ============================================================================ |
| 78 | |
| 79 | /// A logical project — one or more encoded-cwd directories collapsed |
| 80 | /// together via git-root detection, path-prefix fallback, or (for |
| 81 | /// observer sessions) category membership. |
| 82 | #[derive(Debug, Clone, Serialize, Deserialize, TS)] |
| 83 | #[serde(rename_all = "camelCase")] |
| 84 | #[ts(export)] |
| 85 | pub struct Project { |
| 86 | /// Stable key — the git root path, longest common cwd prefix, or |
| 87 | /// (when nothing else is available) a single encoded dir name. |
| 88 | pub id: String, |
| 89 | /// Canonical working directory for this project. |
| 90 | pub cwd: String, |
| 91 | /// Basename of `cwd` for rendering. |
| 92 | pub display_name: String, |
| 93 | pub session_count: u32, |
| 94 | /// Most recent `lastActivityAt` across all sessions in the project. |
| 95 | pub last_activity: Option<DateTime<Utc>>, |
| 96 | /// Sidebar grouping — see [`ProjectCategory`]. |
| 97 | pub category: ProjectCategory, |
| 98 | /// Every encoded `~/.claude/projects/` directory that contributed |
| 99 | /// sessions to this project. Useful for debugging unexpected merges. |
| 100 | pub source_dirs: Vec<String>, |
| 101 | /// All session summaries that belong to this project, sorted newest |
| 102 | /// first. Each session carries its own `projectId` which points at |
| 103 | /// the **encoded source dir** where the jsonl physically lives, |
| 104 | /// distinct from `Project::id` (the merged logical key). |
| 105 | pub sessions: Vec<SessionSummary>, |
| 106 | } |
| 107 | |
| 108 | /// Sidebar grouping for a [`Project`]. |
| 109 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq, Hash)] |
| 110 | #[serde(rename_all = "snake_case")] |
| 111 | #[ts(export)] |
| 112 | pub enum ProjectCategory { |
| 113 | /// Normal user project — shown at the top of the sidebar. |
| 114 | Regular, |
| 115 | /// claude-mem observer sessions, collapsed by default. |
| 116 | Observer, |
| 117 | /// Reconstructed from `~/.claude/history.jsonl` — project has |
| 118 | /// prompt-level history but no on-disk transcripts. Rendered in |
| 119 | /// a distinct collapsed section at the bottom of the sidebar. |
| 120 | Archive, |
| 121 | } |
| 122 | |
| 123 | /// Where a session came from. `Disk` sessions have a real `.jsonl` |
| 124 | /// file under `~/.claude/projects/`; `Archive` sessions are |
| 125 | /// synthesized from `~/.claude/history.jsonl` prompt-input records |
| 126 | /// and only carry the user's own prompts (no assistant responses). |
| 127 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq)] |
| 128 | #[serde(rename_all = "snake_case")] |
| 129 | #[ts(export)] |
| 130 | pub enum SessionSource { |
| 131 | Disk, |
| 132 | Archive, |
| 133 | } |
| 134 | |
| 135 | impl Default for SessionSource { |
| 136 | fn default() -> Self { |
| 137 | Self::Disk |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | /// Cheap metadata for the TOC sidebar — never loads the full file. |
| 142 | #[derive(Debug, Clone, Serialize, Deserialize, TS)] |
| 143 | #[serde(rename_all = "camelCase")] |
| 144 | #[ts(export)] |
| 145 | pub struct SessionSummary { |
| 146 | /// UUID — the JSONL filename minus the extension. |
| 147 | pub id: String, |
| 148 | /// The project's encoded-dir id. |
| 149 | pub project_id: String, |
| 150 | /// customTitle > slug > first real user message (truncated) > "(untitled)" |
| 151 | pub title: String, |
| 152 | /// First event carrying a timestamp. |
| 153 | pub started_at: Option<DateTime<Utc>>, |
| 154 | /// Last event carrying a timestamp, or filesystem mtime as fallback. |
| 155 | pub last_activity_at: Option<DateTime<Utc>>, |
| 156 | /// First assistant message's `message.model`, if observed. |
| 157 | pub model: Option<String>, |
| 158 | /// Count of events that render as rows in the viewer timeline. |
| 159 | /// Excludes session-level metadata events (`permission-mode`, |
| 160 | /// `file-history-snapshot`, etc.) and sidechain subagent |
| 161 | /// events. Matches `read_session`'s `messages.len()`. |
| 162 | pub message_count: u32, |
| 163 | /// Count of actual human-typed prompts. A `user` event whose |
| 164 | /// `content` is a plain string, or an array that does NOT |
| 165 | /// contain a `tool_result` block, counts as a prompt. |
| 166 | /// Tool-result return events (which Claude Code writes as |
| 167 | /// `user` events after every `tool_use`) are excluded so the |
| 168 | /// sidebar reflects turns the human actually drove. |
| 169 | #[serde(default)] |
| 170 | pub prompt_count: u32, |
| 171 | pub git_branch: Option<String>, |
| 172 | pub version: Option<String>, |
| 173 | pub slug: Option<String>, |
| 174 | /// Verified working directory from the first event that carried a |
| 175 | /// `cwd` field. Projects use this as the authoritative cwd when |
| 176 | /// aggregating. |
| 177 | pub cwd: Option<String>, |
| 178 | /// Explicit title set via a `custom-title` event inside the jsonl |
| 179 | /// (Claude Code's `/title` slash command). When this is `Some`, |
| 180 | /// the command layer must **not** override `title` with any |
| 181 | /// external source — the user set it deliberately. |
| 182 | pub custom_title: Option<String>, |
| 183 | /// Which client wrote this session. Common values observed on disk: |
| 184 | /// `"cli"` (terminal Claude Code), `"claude-vscode"` (the VS Code |
| 185 | /// extension), `"sdk-ts"` (the claude-mem observer agent or any |
| 186 | /// other Agent SDK caller). |
| 187 | pub entrypoint: Option<String>, |
| 188 | /// Disk-backed or archive-reconstructed. Determines which |
| 189 | /// dispatch path [`commands::read_session`] takes. |
| 190 | #[serde(default)] |
| 191 | pub source: SessionSource, |
| 192 | } |
| 193 | |
| 194 | /// Full viewer payload — summary + fully decoded message timeline. |
| 195 | #[derive(Debug, Clone, Serialize, Deserialize, TS)] |
| 196 | #[serde(rename_all = "camelCase")] |
| 197 | #[ts(export)] |
| 198 | pub struct SessionDetail { |
| 199 | pub summary: SessionSummary, |
| 200 | pub messages: Vec<Message>, |
| 201 | } |
| 202 | |
| 203 | /// One entry in the viewer timeline. Tagged on `kind` so the TypeScript |
| 204 | /// side can discriminate exhaustively. |
| 205 | #[derive(Debug, Clone, Serialize, Deserialize, TS)] |
| 206 | #[serde(tag = "kind", rename_all = "snake_case", rename_all_fields = "camelCase")] |
| 207 | #[ts(export)] |
| 208 | pub enum Message { |
| 209 | User { |
| 210 | id: String, |
| 211 | at: DateTime<Utc>, |
| 212 | text: String, |
| 213 | /// `true` when this "user" event is actually a tool-result wrapper |
| 214 | /// injected by the agent loop, not something the human typed. |
| 215 | is_meta: bool, |
| 216 | }, |
| 217 | Assistant { |
| 218 | id: String, |
| 219 | at: DateTime<Utc>, |
| 220 | model: Option<String>, |
| 221 | blocks: Vec<ContentBlock>, |
| 222 | stop_reason: Option<String>, |
| 223 | usage: Option<Usage>, |
| 224 | /// v1 seam: when v1's chat pane streams a response into the same |
| 225 | /// viewer, it sets `status: "streaming"` on the in-flight message. |
| 226 | /// v0 never sets this. |
| 227 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 228 | #[ts(optional)] |
| 229 | status: Option<StreamStatus>, |
| 230 | }, |
| 231 | System { |
| 232 | id: String, |
| 233 | at: DateTime<Utc>, |
| 234 | text: String, |
| 235 | subtype: Option<String>, |
| 236 | }, |
| 237 | Attachment { |
| 238 | id: String, |
| 239 | at: DateTime<Utc>, |
| 240 | attachment_type: String, |
| 241 | hook_name: Option<String>, |
| 242 | text: String, |
| 243 | }, |
| 244 | /// Forward-compat escape hatch — any `type` we don't yet model is |
| 245 | /// rendered as a raw card in the viewer instead of crashing it. |
| 246 | Unknown { |
| 247 | id: String, |
| 248 | at: Option<DateTime<Utc>>, |
| 249 | raw_type: String, |
| 250 | raw: serde_json::Value, |
| 251 | }, |
| 252 | } |
| 253 | |
| 254 | /// Content block inside `Message::Assistant`. Matches the Anthropic |
| 255 | /// Messages API block shape the CLI pipes through. |
| 256 | #[derive(Debug, Clone, Serialize, Deserialize, TS)] |
| 257 | #[serde(tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase")] |
| 258 | #[ts(export)] |
| 259 | pub enum ContentBlock { |
| 260 | Text { |
| 261 | text: String, |
| 262 | }, |
| 263 | Thinking { |
| 264 | text: String, |
| 265 | }, |
| 266 | ToolUse { |
| 267 | id: String, |
| 268 | name: String, |
| 269 | input: serde_json::Value, |
| 270 | }, |
| 271 | ToolResult { |
| 272 | tool_use_id: String, |
| 273 | content: String, |
| 274 | is_error: bool, |
| 275 | }, |
| 276 | } |
| 277 | |
| 278 | /// Token accounting passed through from the Anthropic API response. |
| 279 | /// Fields are snake_case (unlike the rest of the frontend schema) because |
| 280 | /// they're verbatim from the API payload — renaming would require an |
| 281 | /// intermediate parse step that has no benefit. |
| 282 | #[derive(Debug, Clone, Serialize, Deserialize, TS, Default)] |
| 283 | #[ts(export)] |
| 284 | pub struct Usage { |
| 285 | #[serde(default)] |
| 286 | pub input_tokens: u32, |
| 287 | #[serde(default)] |
| 288 | pub output_tokens: u32, |
| 289 | #[serde(default)] |
| 290 | pub cache_creation_input_tokens: u32, |
| 291 | #[serde(default)] |
| 292 | pub cache_read_input_tokens: u32, |
| 293 | } |
| 294 | |
| 295 | /// v1 seam: streaming state for an in-flight assistant message. |
| 296 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq)] |
| 297 | #[serde(rename_all = "snake_case")] |
| 298 | #[ts(export)] |
| 299 | pub enum StreamStatus { |
| 300 | Streaming, |
| 301 | Complete, |
| 302 | Error, |
| 303 | } |
| 304 | |
| 305 | /// Permission-gating mode passed to `claude --permission-mode <mode>`. |
| 306 | /// The CLI accepts more values (`default`, `dontAsk`) but we only |
| 307 | /// expose the four that make sense for a GUI chat flow. `Auto` is |
| 308 | /// the default: non-dangerous tools run without prompting, dangerous |
| 309 | /// tools (Bash/Edit/Write) block and fail the turn. |
| 310 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq)] |
| 311 | #[serde(rename_all = "camelCase")] |
| 312 | #[ts(export)] |
| 313 | pub enum PermissionMode { |
| 314 | Auto, |
| 315 | AcceptEdits, |
| 316 | BypassPermissions, |
| 317 | Plan, |
| 318 | } |
| 319 | |
| 320 | impl PermissionMode { |
| 321 | /// The exact string the `claude` CLI expects after |
| 322 | /// `--permission-mode`. Verified against `claude --help`. |
| 323 | pub fn as_cli(self) -> &'static str { |
| 324 | match self { |
| 325 | PermissionMode::Auto => "auto", |
| 326 | PermissionMode::AcceptEdits => "acceptEdits", |
| 327 | PermissionMode::BypassPermissions => "bypassPermissions", |
| 328 | PermissionMode::Plan => "plan", |
| 329 | } |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | impl Default for PermissionMode { |
| 334 | fn default() -> Self { |
| 335 | Self::Auto |
| 336 | } |
| 337 | } |
| 338 |