agentacta 1.3.4 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,11 @@
4
4
  [![npm](https://img.shields.io/npm/v/agentacta)](https://www.npmjs.com/package/agentacta)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
 
7
- **Your AI agent does hundreds of things. Can you find them?**
7
+ **Your agent did 1000s of things today. Can you find the 1 that broke prod?**
8
8
 
9
- AgentActa is an audit trail and search engine for AI agent sessions. It indexes everything your agent did — every message, tool call, file edit, web search, and decision — into a fast, searchable local interface.
9
+ AgentActa is a local audit trail and search engine for AI agent sessions.
10
+
11
+ It indexes messages, tool calls, file edits, searches, and decisions into a fast UI you can query in seconds.
10
12
 
11
13
  One command. Zero config. Full visibility.
12
14
 
@@ -18,24 +20,25 @@ npx agentacta
18
20
  <img src="screenshots/demo.gif" alt="AgentActa demo" width="800">
19
21
  </p>
20
22
 
21
- ---
23
+ ## Why this exists
22
24
 
23
- ## Why
25
+ Agents move fast. Your memory of what happened doesn’t.
24
26
 
25
- AI agents are powerful. They write code, send emails, manage infrastructure, make decisions on your behalf. But when you need to know *what happened* — what was changed, when, and why you're digging through scattered logs or asking the agent to remember (it won't).
27
+ When you need to answer what changed, when, and why,” youre usually scraping logs, scrolling transcripts, or asking the same assistant that forgot 20 minutes ago.
26
28
 
27
- AgentActa gives you a single, searchable view of everything.
29
+ AgentActa gives you one place to inspect the full trail.
28
30
 
29
- ## What You Get
31
+ ## What you get
30
32
 
31
- 🔍 **Full-text search** across all messages, tool calls, and results
32
- 📋 **Session browser** with summaries, token breakdowns (input/output), and model info
33
- 📅 **Timeline view** everything that happened on any given day
34
- 📁 **File activity** — every file your agent touched, across all sessions
35
- 📊 **Stats** sessions, messages, tool usage, token counts
36
- **Live indexing** new sessions appear automatically via file watching
37
- 📱 **Mobile-friendly** responsive UI with bottom tab navigation
38
- 💡 **Smart suggestions** — quick search chips derived from your actual session data
33
+ - 🔍 Full-text search across messages, tool calls, and results
34
+ - 📋 Session browser with summaries, token breakdowns, and model info
35
+ - 📅 Timeline view with live updates for today
36
+ - 📁 File activity across all indexed sessions
37
+ - 🌗 Light and dark themes
38
+ - 📊 Stats for sessions, messages, tools, and tokens
39
+ - Live indexing via file watching
40
+ - 📱 Mobile-friendly UI
41
+ - 💡 Search suggestions based on real data
39
42
 
40
43
  ## Demo
41
44
 
@@ -51,62 +54,81 @@ https://github.com/mirajchokshi/agentacta/raw/main/screenshots/demo-final.mp4
51
54
  ![Stats](screenshots/stats.png)
52
55
  ![Search Results](screenshots/search-results.png)
53
56
 
54
- ## Quick Start
57
+ ## Quick start
55
58
 
56
59
  ```bash
57
- # Run directly (no install needed)
60
+ # run directly
58
61
  npx agentacta
59
62
 
60
- # Or install globally
63
+ # or install globally
61
64
  npm install -g agentacta
62
65
  agentacta
63
66
  ```
64
67
 
65
- Open `http://localhost:4003` in your browser.
68
+ Open: `http://localhost:4003`
66
69
 
67
- AgentActa automatically finds your sessions in:
70
+ Auto-detected session paths:
68
71
  - `~/.openclaw/agents/*/sessions/` (OpenClaw)
69
72
  - `~/.claude/projects/*/` (Claude Code)
70
73
  - `~/.codex/sessions/` (Codex CLI)
71
74
 
72
- Or point it at a custom path:
75
+ Custom path:
73
76
 
74
77
  ```bash
75
78
  AGENTACTA_SESSIONS_PATH=/path/to/sessions agentacta
76
79
  ```
77
80
 
78
- ## Features
81
+ ## Core features
79
82
 
80
83
  ### Search
81
- Full-text search powered by SQLite FTS5. Filter by message type (messages, tool calls, results) and role (user, assistant). Quick search suggestions are generated from your actual data — most-used tools, common topics, frequently touched files.
84
+
85
+ SQLite FTS5 full-text search with filters for message type (messages, tool calls, results) and role (user, assistant).
86
+
87
+ Suggestions come from your own dataset: top tools, common topics, frequently touched files.
82
88
 
83
89
  ### Sessions
84
- Browse all indexed sessions with auto-generated summaries, token breakdowns (output vs input), and model info. Sessions are automatically tagged by type — cron jobs, sub-agent tasks, and heartbeat sessions get distinct badges. Click into any session to see the full event history, most recent first.
90
+
91
+ Browse indexed sessions with auto-generated summaries, token splits (input/output), and model details. Click into any session to see the full event history.
92
+
93
+ Session types get tagged so noisy categories are easier to spot (cron, sub-agent, heartbeat).
85
94
 
86
95
  ### Timeline
87
- Pick a date, see everything that happened. Messages, tool invocations, file changes — most recent first.
96
+
97
+ Pick a date, see everything that happened, newest first. Today's view updates live as new events come in.
88
98
 
89
99
  ### File Activity
90
- See every file your agent read, wrote, or edited. Sort by most touched, most recent, or most sessions. Filter by extension, group by directory. Click any file to see which sessions touched it and what was done.
100
+
101
+ See what files were touched, how often, and by which sessions.
102
+
103
+ Sort by recency, frequency, or session count. Filter by extension. Group by directory. Click any file to see which sessions touched it.
91
104
 
92
105
  ### Export
93
- Download any session or search results as Markdown or JSON. Great for sharing, auditing, or archiving.
94
106
 
95
- ## How It Works
107
+ Export sessions or search results as Markdown or JSON.
96
108
 
97
- AgentActa reads JSONL session files (including OpenClaw, Claude Code, and Codex CLI formats), parses every message and tool call, and indexes them into a local SQLite database with FTS5 full-text search.
109
+ Useful for handoffs, incident writeups, and audit archives.
98
110
 
99
- The web UI is a single-page app served by a lightweight Node.js HTTP server. No frameworks, no build step, no external dependencies beyond `better-sqlite3`.
111
+ ## How it works
100
112
 
101
- ```
102
- Session JSONL files → SQLite + FTS5 index → HTTP API → Web UI
113
+ AgentActa parses JSONL session files (OpenClaw, Claude Code, Codex CLI), then indexes events into local SQLite with FTS5.
114
+
115
+ The UI is a single-page app served by a lightweight Node HTTP server.
116
+
117
+ No framework build pipeline. Minimal moving parts.
118
+
119
+ ```text
120
+ Session JSONL files -> SQLite + FTS5 index -> HTTP API -> Web UI
103
121
  ```
104
122
 
105
- Data never leaves your machine.
123
+ Everything stays on your machine.
106
124
 
107
125
  ## Configuration
108
126
 
109
- On first run, AgentActa creates a config file with sensible defaults at `~/.config/agentacta/config.json` (or `agentacta.config.json` in the current directory if it exists):
127
+ On first run, AgentActa creates:
128
+ - `~/.config/agentacta/config.json`
129
+ - or `agentacta.config.json` in current directory (if present)
130
+
131
+ Default config:
110
132
 
111
133
  ```json
112
134
  {
@@ -118,85 +140,82 @@ On first run, AgentActa creates a config file with sensible defaults at `~/.conf
118
140
  }
119
141
  ```
120
142
 
121
- ### Storage Modes
143
+ ### Storage modes
122
144
 
123
- - **`reference`** (default) — Lightweight index. Stores parsed events in SQLite but not the raw JSONL. Source files must remain on disk.
124
- - **`archive`** Full JSONL stored in SQLite. Sessions survive even if the original files are deleted. Uses more disk space.
145
+ - `reference` (default): index parsed events in SQLite, keep source JSONL on disk. Lightweight.
146
+ - `archive`: store full JSONL in SQLite. Sessions survive even if original files are deleted. Uses more disk.
125
147
 
126
- ### Environment Variables
148
+ ### Environment variables
127
149
 
128
150
  | Variable | Default | Description |
129
151
  |---|---|---|
130
152
  | `PORT` | `4003` | Server port |
131
- | `AGENTACTA_HOST` | `127.0.0.1` | Bind address (see [Security](#security)) |
132
- | `AGENTACTA_SESSIONS_PATH` | Auto-detected | Custom sessions directory |
133
- | `AGENTACTA_DB_PATH` | `./agentacta.db` | Database file location |
134
- | `AGENTACTA_STORAGE` | `reference` | Storage mode (`reference` or `archive`) |
135
- | `AGENTACTA_PROJECT_ALIASES_JSON` | unset | JSON object mapping inferred project names (e.g. `{"old-name":"new-name"}`) |
153
+ | `AGENTACTA_HOST` | `127.0.0.1` | Bind address |
154
+ | `AGENTACTA_SESSIONS_PATH` | auto-detected | Custom sessions directory |
155
+ | `AGENTACTA_DB_PATH` | `./agentacta.db` | Database path |
156
+ | `AGENTACTA_STORAGE` | `reference` | `reference` or `archive` |
157
+ | `AGENTACTA_PROJECT_ALIASES_JSON` | unset | Rename inferred project labels |
136
158
 
137
159
  ## API
138
160
 
139
- AgentActa exposes a JSON API for programmatic access — useful for integrating search into your agent's workflow.
140
-
141
161
  | Endpoint | Description |
142
162
  |---|---|
143
- | `GET /api/stats` | Overview: session count, messages, tools, tokens |
144
- | `GET /api/sessions` | List sessions with metadata and token breakdowns |
145
- | `GET /api/sessions/:id` | Full session with all events |
146
- | `GET /api/search?q=<query>` | Full-text search with type/role/date filters |
147
- | `GET /api/suggestions` | Data-driven search suggestions |
148
- | `GET /api/timeline?date=YYYY-MM-DD` | All events for a given day |
149
- | `GET /api/files` | All files touched across sessions |
150
- | `GET /api/export/session/:id?format=md` | Export session as Markdown or JSON |
163
+ | `GET /api/stats` | Session/message/tool/token totals |
164
+ | `GET /api/sessions` | Session list with metadata |
165
+ | `GET /api/sessions/:id` | Full session events |
166
+ | `GET /api/search?q=<query>` | Full-text search + filters |
167
+ | `GET /api/suggestions` | Search suggestions |
168
+ | `GET /api/timeline?date=YYYY-MM-DD` | Events for one day |
169
+ | `GET /api/files` | Touched-file inventory |
170
+ | `GET /api/export/session/:id?format=md` | Export one session |
171
+ | `GET /api/timeline/stream?after=<ts>` | SSE stream for live timeline updates |
172
+ | `POST /api/maintenance` | VACUUM + WAL checkpoint (returns size before/after) |
151
173
  | `GET /api/export/search?q=<query>&format=md` | Export search results |
152
174
 
153
- ### Agent Integration
154
-
155
- Your AI agent can query AgentActa for better recall:
175
+ Agent integration example:
156
176
 
157
177
  ```javascript
158
- const results = await fetch('http://localhost:4003/api/search?q=deployment+issue&limit=5');
159
- const data = await results.json();
160
- // Agent now has context from past sessions
178
+ const res = await fetch('http://localhost:4003/api/search?q=deployment+issue&limit=5');
179
+ const data = await res.json();
161
180
  ```
162
181
 
163
- ### Demo Mode
164
-
165
- Want to see what AgentActa looks like with data? Run with demo sessions:
182
+ ## Demo mode
166
183
 
167
184
  ```bash
168
- # Generate demo data and start in demo mode
185
+ # seed demo data + run
169
186
  npm run demo
170
187
 
171
- # Or separately:
188
+ # or split steps
172
189
  node scripts/seed-demo.js
173
190
  node index.js --demo
174
191
  ```
175
192
 
176
- This creates 7 realistic sessions simulating a developer building a weather app scaffolding, API integration, frontend, debugging, deployment, tests, and a sub-agent task.
193
+ Demo mode creates 7 realistic sessions (weather app build path: scaffolding, API, frontend, debugging, deployment, tests, sub-agent task).
177
194
 
178
195
  ## Security
179
196
 
180
- **AgentActa is a local tool.** It binds to `127.0.0.1` by default — only accessible from your machine.
197
+ AgentActa binds to `127.0.0.1` by default.
181
198
 
182
- To expose on your network (e.g., for Tailscale access):
199
+ If you expose it on a network, do it intentionally:
183
200
 
184
201
  ```bash
185
202
  AGENTACTA_HOST=0.0.0.0 agentacta
186
203
  ```
187
204
 
188
- ⚠️ **Important:** Agent session data can contain sensitive information file contents, API responses, personal messages, tool call arguments. If you expose AgentActa on a network, ensure it's a trusted one. There is no built-in authentication.
205
+ **Important:** Session data can contain sensitive content (file snippets, API payloads, personal messages, tool args). There is no built-in auth yet, so only expose on trusted networks.
189
206
 
190
- ## Tech Stack
207
+ ## Tech stack
191
208
 
192
- - **Node.js** — HTTP server (built-in `http`, no Express)
193
- - **better-sqlite3** Fast SQLite with FTS5 full-text search
194
- - **Vanilla HTML/CSS/JS** — No framework, no build step
195
- - **PWA** — Installable as a home screen app
209
+ - Node.js (built-in `http`)
210
+ - `better-sqlite3` + SQLite FTS5
211
+ - Vanilla HTML/CSS/JS
212
+ - PWA support
196
213
 
197
214
  ## Privacy
198
215
 
199
- All data stays local. AgentActa runs entirely on your machine — no cloud services, no telemetry, no external requests. Your agent history is yours.
216
+ No telemetry. No cloud sync. No external indexing service.
217
+
218
+ Your session history stays local.
200
219
 
201
220
  ## Compatibility
202
221
 
@@ -207,13 +226,15 @@ All data stays local. AgentActa runs entirely on your machine — no cloud servi
207
226
 
208
227
  ## Contributing
209
228
 
210
- PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup and guidelines. If you're adding support for a new agent format, add a parser in `indexer.js` and open a PR.
229
+ PRs welcome.
230
+
231
+ See [CONTRIBUTING.md](CONTRIBUTING.md). If you’re adding a new agent format, start in `indexer.js`.
211
232
 
212
- ## Etymology
233
+ ## Name
213
234
 
214
- *Acta* (Latin) "things done." In ancient Rome, the *acta diurna* were daily public records of official proceedings — senate decisions, military victories, births and deaths — posted in public spaces for all citizens to read.
235
+ *Acta* is Latin for things done.”
215
236
 
216
- AgentActa is the same idea: a complete, searchable record of everything your AI agent did.
237
+ That’s the job here: keep a readable record of what your agents actually did.
217
238
 
218
239
  ## License
219
240
 
@@ -221,4 +242,4 @@ MIT
221
242
 
222
243
  ---
223
244
 
224
- Built in Chicago by humans and agents working together.
245
+ Built in Chicago by humans and agents.
package/db.js CHANGED
@@ -54,6 +54,7 @@ function init(dbPath) {
54
54
  FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
55
55
  );
56
56
 
57
+ CREATE INDEX IF NOT EXISTS idx_sessions_start_time ON sessions(start_time DESC);
57
58
  CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
58
59
  CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
59
60
  CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
package/index.js CHANGED
@@ -428,13 +428,67 @@ const server = http.createServer((req, res) => {
428
428
  const date = query.date || (() => { const n = new Date(); return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,'0')}-${String(n.getDate()).padStart(2,'0')}`; })();
429
429
  const from = new Date(date + 'T00:00:00').toISOString();
430
430
  const to = new Date(date + 'T23:59:59.999').toISOString();
431
+ const limit = Math.min(parseInt(query.limit || '100', 10) || 100, 500);
432
+ const offset = Math.max(parseInt(query.offset || '0', 10) || 0, 0);
431
433
  const events = db.prepare(
432
434
  `SELECT e.*, s.summary as session_summary FROM events e
433
435
  JOIN sessions s ON s.id = e.session_id
434
436
  WHERE e.timestamp >= ? AND e.timestamp <= ?
435
- ORDER BY e.timestamp DESC`
436
- ).all(from, to);
437
- json(res, { date, events, total: events.length });
437
+ ORDER BY e.timestamp DESC
438
+ LIMIT ? OFFSET ?`
439
+ ).all(from, to, limit, offset);
440
+ const total = db.prepare(
441
+ `SELECT COUNT(*) as c FROM events e
442
+ WHERE e.timestamp >= ? AND e.timestamp <= ?`
443
+ ).get(from, to).c;
444
+ json(res, { date, events, total, limit, offset, hasMore: offset + events.length < total });
445
+ }
446
+ else if (pathname === '/api/timeline/stream') {
447
+ res.writeHead(200, {
448
+ 'Content-Type': 'text/event-stream',
449
+ 'Cache-Control': 'no-cache',
450
+ 'Connection': 'keep-alive',
451
+ 'X-Accel-Buffering': 'no'
452
+ });
453
+ res.write(': connected\n\n');
454
+
455
+ let lastTs = query.after || new Date().toISOString();
456
+
457
+ const onUpdate = () => {
458
+ try {
459
+ const rows = db.prepare(
460
+ `SELECT e.*, s.summary as session_summary FROM events e
461
+ JOIN sessions s ON s.id = e.session_id
462
+ WHERE e.timestamp > ?
463
+ ORDER BY e.timestamp ASC`
464
+ ).all(lastTs);
465
+ if (rows.length) {
466
+ lastTs = rows[rows.length - 1].timestamp;
467
+ res.write(`id: ${lastTs}\ndata: ${JSON.stringify(rows)}\n\n`);
468
+ }
469
+ } catch (err) {
470
+ console.error('Timeline SSE error:', err.message);
471
+ }
472
+ };
473
+
474
+ sseEmitter.on('session-update', onUpdate);
475
+
476
+ const ping = setInterval(() => {
477
+ try { res.write(': ping\n\n'); } catch {}
478
+ }, 30000);
479
+
480
+ req.on('close', () => {
481
+ sseEmitter.off('session-update', onUpdate);
482
+ clearInterval(ping);
483
+ });
484
+ }
485
+ else if (pathname === '/api/maintenance') {
486
+ if (req.method !== 'POST') return json(res, { error: 'Method not allowed' }, 405);
487
+ const sizeBefore = getDbSize();
488
+ db.pragma('wal_checkpoint(TRUNCATE)');
489
+ db.exec('VACUUM');
490
+ const sizeAfter = getDbSize();
491
+ json(res, { ok: true, sizeBefore, sizeAfter });
438
492
  }
439
493
  else if (pathname === '/api/files') {
440
494
  const limit = parseInt(query.limit) || 100;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentacta",
3
- "version": "1.3.4",
3
+ "version": "1.4.0",
4
4
  "description": "Audit trail and search engine for AI agent sessions",
5
5
  "main": "index.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -3,10 +3,31 @@ const $$ = (s, p = document) => [...p.querySelectorAll(s)];
3
3
  const content = $('#content');
4
4
  const API = '/api';
5
5
 
6
- async function api(path) {
6
+ const THEME_KEY = 'agentacta-theme';
7
+
8
+ function applyTheme(theme) {
9
+ document.documentElement.setAttribute('data-theme', theme);
10
+ const meta = document.querySelector('meta[name="theme-color"]');
11
+ if (meta) meta.setAttribute('content', theme === 'light' ? '#f5f7fb' : '#0a0e1a');
12
+ }
13
+
14
+ function initTheme() {
15
+ const saved = localStorage.getItem(THEME_KEY);
16
+ const theme = saved === 'dark' || saved === 'light' ? saved : 'light';
17
+ applyTheme(theme);
18
+ }
19
+
20
+ function toggleTheme() {
21
+ const current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
22
+ const next = current === 'light' ? 'dark' : 'light';
23
+ localStorage.setItem(THEME_KEY, next);
24
+ applyTheme(next);
25
+ }
26
+
27
+ async function api(path, options = {}) {
7
28
  let res;
8
29
  try {
9
- res = await fetch(API + path);
30
+ res = await fetch(API + path, options);
10
31
  } catch (err) {
11
32
  // Network error (server down, offline, etc.)
12
33
  return { _error: true, error: 'Network error' };
@@ -71,6 +92,26 @@ function escHtml(s) {
71
92
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
72
93
  }
73
94
 
95
+ function fmtToolName(name) {
96
+ if (!name) return '';
97
+ // MCP tools: mcp__provider__action → mcp_provider_action
98
+ const mcp = name.match(/^mcp__(.+?)__(.+)$/);
99
+ if (mcp) {
100
+ const provider = mcp[1].replace(/__/g, '_');
101
+ const action = mcp[2].replace(/__/g, '_');
102
+ return `mcp_${provider}_${action}`;
103
+ }
104
+ return name;
105
+ }
106
+
107
+ function fmtToolGroup(name) {
108
+ if (!name) return '';
109
+ // MCP tools: collapse to mcp_provider (no action)
110
+ const mcp = name.match(/^mcp__(.+?)__/);
111
+ if (mcp) return 'mcp_' + mcp[1].replace(/__/g, '_');
112
+ return name;
113
+ }
114
+
74
115
  function truncate(s, n = 200) {
75
116
  if (!s) return '';
76
117
  return s.length > n ? s.slice(0, n) + '\u2026' : s;
@@ -95,6 +136,48 @@ function transitionView() {
95
136
  content.classList.add('view-enter');
96
137
  }
97
138
 
139
+ function skeletonLine(width = '100%', height = '12px') {
140
+ return `<div class="skeleton-line" style="width:${width};height:${height}"></div>`;
141
+ }
142
+
143
+ function skeletonRows(count = 6, kind = 'event') {
144
+ if (kind === 'session') {
145
+ return Array.from({ length: count }).map(() => `
146
+ <div class="session-item skeleton-card">
147
+ <div class="skeleton-line" style="width:58%;height:12px"></div>
148
+ <div class="skeleton-line" style="width:90%;height:14px"></div>
149
+ <div class="skeleton-line" style="width:72%;height:14px"></div>
150
+ </div>
151
+ `).join('');
152
+ }
153
+ if (kind === 'stats') {
154
+ return Array.from({ length: count }).map(() => `
155
+ <div class="stat-card skeleton-card">
156
+ <div class="skeleton-line" style="width:44%;height:10px"></div>
157
+ <div class="skeleton-line" style="width:66%;height:28px;margin-top:8px"></div>
158
+ </div>
159
+ `).join('');
160
+ }
161
+ if (kind === 'file') {
162
+ return Array.from({ length: count }).map(() => `
163
+ <div class="file-item skeleton-card">
164
+ <div class="skeleton-line" style="width:62%;height:13px"></div>
165
+ <div class="skeleton-line" style="width:86%;height:12px;margin-top:10px"></div>
166
+ </div>
167
+ `).join('');
168
+ }
169
+ return Array.from({ length: count }).map(() => `
170
+ <div class="event-item skeleton-row">
171
+ <div class="skeleton-line" style="width:72px;height:10px"></div>
172
+ <div class="skeleton-line" style="width:60px;height:16px"></div>
173
+ <div class="event-body">
174
+ <div class="skeleton-line" style="width:82%;height:12px"></div>
175
+ <div class="skeleton-line" style="width:66%;height:12px;margin-top:8px"></div>
176
+ </div>
177
+ </div>
178
+ `).join('');
179
+ }
180
+
98
181
  // --- Hash routing ---
99
182
  window._navDepth = 0;
100
183
 
@@ -118,6 +201,7 @@ function updateNavActive(view) {
118
201
  function handleRoute() {
119
202
  const raw = (window.location.hash || '').slice(1) || 'search';
120
203
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
204
+ if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
121
205
 
122
206
  if (raw.startsWith('session/')) {
123
207
  const id = decodeURIComponent(raw.slice('session/'.length));
@@ -144,7 +228,7 @@ function renderEvent(ev) {
144
228
  let body = '';
145
229
 
146
230
  if (ev.type === 'tool_call') {
147
- body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
231
+ body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
148
232
  if (ev.tool_args) {
149
233
  try {
150
234
  const args = JSON.parse(ev.tool_args);
@@ -154,7 +238,7 @@ function renderEvent(ev) {
154
238
  }
155
239
  }
156
240
  } else if (ev.type === 'tool_result') {
157
- body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
241
+ body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
158
242
  if (ev.content) {
159
243
  body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
160
244
  }
@@ -178,7 +262,7 @@ function renderTimelineEvent(ev) {
178
262
  let body = '';
179
263
 
180
264
  if (ev.type === 'tool_call') {
181
- body = `<span class="tool-name">${escHtml(ev.tool_name)}</span>`;
265
+ body = `<span class="tool-name">${escHtml(fmtToolName(ev.tool_name))}</span>`;
182
266
  if (ev.tool_args) {
183
267
  try {
184
268
  const args = JSON.parse(ev.tool_args);
@@ -188,7 +272,7 @@ function renderTimelineEvent(ev) {
188
272
  }
189
273
  }
190
274
  } else if (ev.type === 'tool_result') {
191
- body = `<span class="tool-name">\u2192 ${escHtml(ev.tool_name)}</span>`;
275
+ body = `<span class="tool-name">\u2192 ${escHtml(fmtToolName(ev.tool_name))}</span>`;
192
276
  if (ev.content) {
193
277
  body += `<div class="tool-args">${escHtml(truncate(ev.content, 500))}</div>`;
194
278
  }
@@ -296,7 +380,12 @@ async function viewSearch(query = '') {
296
380
  <span class="filter-chip ${roleFilter==='user'?'active':''}" data-filter="role" data-val="user">User</span>
297
381
  <span class="filter-chip ${roleFilter==='assistant'?'active':''}" data-filter="role" data-val="assistant">Assistant</span>
298
382
  </div>
299
- <div id="results"></div>`;
383
+ <div id="results">
384
+ <div class="search-bar skeleton-card" style="margin-top:6px">
385
+ <div class="skeleton-line" style="height:16px;width:40%"></div>
386
+ </div>
387
+ ${skeletonRows(4, 'session')}
388
+ </div>`;
300
389
 
301
390
  content.innerHTML = html;
302
391
  transitionView();
@@ -325,7 +414,7 @@ async function viewSearch(query = '') {
325
414
 
326
415
  async function showSearchHome() {
327
416
  const el = $('#results');
328
- el.innerHTML = '<div class="loading">Loading</div>';
417
+ el.innerHTML = `${skeletonRows(4, 'session')}`;
329
418
 
330
419
  const stats = await api('/stats');
331
420
  const sessions = await api('/sessions?limit=5');
@@ -376,7 +465,7 @@ async function doSearch(q) {
376
465
  const el = $('#results');
377
466
  if (!q.trim()) { el.innerHTML = '<div class="empty"><h2>Type to search</h2><p>Search across all sessions, messages, and tool calls</p></div>'; return; }
378
467
 
379
- el.innerHTML = '<div class="loading">Searching</div>';
468
+ el.innerHTML = `${skeletonRows(6, 'event')}`;
380
469
 
381
470
  const type = window._searchType || '';
382
471
  const role = window._searchRole || '';
@@ -402,7 +491,7 @@ async function doSearch(q) {
402
491
  <div class="result-meta">
403
492
  <span class="event-badge ${badgeClass(r.type, r.role)}">${r.type === 'tool_call' ? 'tool' : r.role || r.type}</span>
404
493
  <span class="session-time">${fmtTime(r.timestamp)}</span>
405
- ${r.tool_name ? `<span class="tool-name">${escHtml(r.tool_name)}</span>` : ''}
494
+ ${r.tool_name ? `<span class="tool-name">${escHtml(fmtToolName(r.tool_name))}</span>` : ''}
406
495
  <span class="session-link" data-session="${r.session_id}">view session \u2192</span>
407
496
  </div>
408
497
  <div class="result-content">${escHtml(truncate(r.content || r.tool_args || r.tool_result || '', 400))}</div>
@@ -420,7 +509,8 @@ async function doSearch(q) {
420
509
 
421
510
  async function viewSessions() {
422
511
  window._currentSessionId = null;
423
- content.innerHTML = '<div class="loading">Loading</div>';
512
+ content.innerHTML = `<div class="page-title">Sessions</div>${skeletonRows(4, 'session')}`;
513
+ transitionView();
424
514
  const data = await api('/sessions?limit=200');
425
515
  if (data._error) {
426
516
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -442,6 +532,12 @@ async function viewSession(id) {
442
532
  window._currentSessionId = id;
443
533
  setHash('session/' + encodeURIComponent(id));
444
534
  window.scrollTo(0, 0);
535
+ content.innerHTML = `
536
+ <div class="back-btn">← Back</div>
537
+ <div class="page-title">Session</div>
538
+ ${skeletonRows(8, 'event')}
539
+ `;
540
+ transitionView();
445
541
  const data = await api(`/sessions/${id}`);
446
542
 
447
543
  if (data._error || data.error) { content.innerHTML = `<div class="empty"><h2>${escHtml(data.error || 'Unable to load')}</h2></div>`; return; }
@@ -674,34 +770,93 @@ async function viewTimeline(date) {
674
770
  date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
675
771
  }
676
772
  window._lastView = 'timeline';
773
+ window._timelineState = { date, limit: 100, offset: 0, hasMore: true, loading: false };
677
774
 
678
- let html = `<div class="page-title">Timeline</div>
775
+ content.innerHTML = `<div class="page-title">Timeline</div>
679
776
  <input type="date" class="date-input" id="dateInput" value="${date}">
680
- <div id="timelineContent"><div class="loading">Loading</div></div>`;
681
- content.innerHTML = html;
777
+ <div id="timelineContent">${skeletonRows(8, 'event')}</div>
778
+ <div id="timelineLoadMore" class="loading-more" style="display:none">Loading more…</div>`;
682
779
  transitionView();
683
780
 
684
- const data = await api(`/timeline?date=${date}`);
685
- if (data._error) {
686
- $('#timelineContent').innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
687
- return;
688
- }
689
781
  const el = $('#timelineContent');
782
+ const state = window._timelineState;
690
783
 
691
- if (!data.events.length) {
692
- el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
693
- } else {
694
- el.innerHTML = `<div class="timeline-events-wrap">
695
- <div class="timeline-line"></div>
696
- ${data.events.map(renderTimelineEvent).join('')}
697
- </div>`;
784
+ async function loadTimelinePage(append = false) {
785
+ if (state.loading || (!state.hasMore && append)) return;
786
+ state.loading = true;
787
+ if (append) $('#timelineLoadMore').style.display = 'block';
788
+
789
+ const data = await api(`/timeline?date=${state.date}&limit=${state.limit}&offset=${state.offset}`);
790
+ state.loading = false;
791
+ $('#timelineLoadMore').style.display = 'none';
792
+
793
+ if (data._error) {
794
+ if (!append) el.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
795
+ return;
796
+ }
797
+
798
+ state.hasMore = !!data.hasMore;
799
+ state.offset += (data.events || []).length;
800
+
801
+ if (!append) {
802
+ if (!data.events.length) {
803
+ el.innerHTML = '<div class="empty"><h2>No activity</h2><p>Nothing recorded on this day</p></div>';
804
+ return;
805
+ }
806
+ el.innerHTML = `<div class="timeline-events-wrap" id="timelineWrap"><div class="timeline-line"></div>${data.events.map(renderTimelineEvent).join('')}</div>`;
807
+ return;
808
+ }
809
+
810
+ const wrap = $('#timelineWrap');
811
+ if (wrap) {
812
+ wrap.insertAdjacentHTML('beforeend', data.events.map(renderTimelineEvent).join(''));
813
+ }
814
+ }
815
+
816
+ await loadTimelinePage(false);
817
+
818
+ if (window._timelineScrollHandler) window.removeEventListener('scroll', window._timelineScrollHandler);
819
+ window._timelineScrollHandler = () => {
820
+ const st = window._timelineState;
821
+ if (!st || st.loading || !st.hasMore) return;
822
+ const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
823
+ if (nearBottom) loadTimelinePage(true);
824
+ };
825
+ window.addEventListener('scroll', window._timelineScrollHandler, { passive: true });
826
+
827
+ // Live updates via SSE (only for today)
828
+ const today = new Date();
829
+ const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
830
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
831
+ if (date === todayStr) {
832
+ const sse = new EventSource(`/api/timeline/stream?after=${encodeURIComponent(new Date().toISOString())}`);
833
+ window._timelineSse = sse;
834
+ sse.onmessage = (evt) => {
835
+ try {
836
+ const rows = JSON.parse(evt.data);
837
+ const wrap = $('#timelineWrap');
838
+ if (wrap && rows.length) {
839
+ const html = rows.map(renderTimelineEvent).join('');
840
+ wrap.insertAdjacentHTML('afterbegin', html);
841
+ // Flash new events
842
+ rows.forEach(r => {
843
+ const el = wrap.querySelector(`[data-event-id="${r.id}"]`);
844
+ if (el) { el.classList.add('event-highlight'); setTimeout(() => el.classList.remove('event-highlight'), 2000); }
845
+ });
846
+ }
847
+ } catch {}
848
+ };
698
849
  }
699
850
 
700
- $('#dateInput').addEventListener('change', e => viewTimeline(e.target.value));
851
+ $('#dateInput').addEventListener('change', e => {
852
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
853
+ viewTimeline(e.target.value);
854
+ });
701
855
  }
702
856
 
703
857
  async function viewStats() {
704
- content.innerHTML = '<div class="loading">Loading</div>';
858
+ content.innerHTML = `<div class="page-title">Stats</div><div class="stat-grid">${skeletonRows(5, 'stats')}</div>`;
859
+ transitionView();
705
860
  const data = await api('/stats');
706
861
  if (data._error) {
707
862
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -713,14 +868,19 @@ async function viewStats() {
713
868
  <div class="stat-card accent-blue"><div class="label">Sessions</div><div class="value">${data.sessions}</div></div>
714
869
  <div class="stat-card accent-green"><div class="label">Messages</div><div class="value">${data.messages.toLocaleString()}</div></div>
715
870
  <div class="stat-card accent-amber"><div class="label">Tool Calls</div><div class="value">${data.toolCalls.toLocaleString()}</div></div>
716
- <div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${data.uniqueTools}</div></div>
871
+ <div class="stat-card accent-purple"><div class="label">Unique Tools</div><div class="value">${new Set((data.tools||[]).filter(t=>t).map(t=>fmtToolGroup(t))).size}</div></div>
717
872
  <div class="stat-card accent-teal"><div class="label">Total Tokens</div><div class="value">${(data.totalTokens || 0).toLocaleString()}</div></div>
718
873
  </div>
719
874
 
720
875
  <div class="section-label">Configuration</div>
721
- <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-xl)">
876
+ <div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:var(--space-md);margin-bottom:var(--space-md)">
722
877
  <div class="config-card"><div class="config-label">Storage Mode</div><div class="config-value">${escHtml(data.storageMode || 'reference')}</div></div>
723
- <div class="config-card"><div class="config-label">DB Size</div><div class="config-value">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
878
+ <div class="config-card"><div class="config-label">DB Size</div><div class="config-value" id="dbSizeValue">${escHtml(data.dbSize?.display || 'N/A')}</div></div>
879
+ </div>
880
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:var(--space-xl)">
881
+ <button class="export-btn" id="optimizeDbBtn">Optimize Database</button>
882
+ <span style="color:var(--text-tertiary);font-size:12px;line-height:1.5">Reclaims unused space and merges pending writes. Safe to run anytime, doesn't delete any data.</span>
883
+ <span id="optimizeDbStatus" style="color:var(--text-tertiary);font-size:12px"></span>
724
884
  </div>
725
885
 
726
886
  ${data.sessionDirs && data.sessionDirs.length ? (() => {
@@ -755,16 +915,35 @@ async function viewStats() {
755
915
  <div class="section-label">Date Range</div>
756
916
  <p style="color:var(--text-secondary);font-size:13px;margin-bottom:var(--space-xl)">${fmtDate(data.dateRange?.earliest)} \u2014 ${fmtDate(data.dateRange?.latest)}</p>
757
917
  <div class="section-label">Tools Used</div>
758
- <div class="tools-grid">${(data.tools||[]).filter(t => t).sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
918
+ <div class="tools-grid">${[...new Set((data.tools||[]).filter(t => t).map(t => fmtToolGroup(t)))].sort().map(t => `<span class="tool-chip">${escHtml(t)}</span>`).join('')}</div>
759
919
  `;
760
920
 
761
921
  content.innerHTML = html;
762
922
  transitionView();
923
+
924
+ const optimizeBtn = $('#optimizeDbBtn');
925
+ const optimizeStatus = $('#optimizeDbStatus');
926
+ if (optimizeBtn) {
927
+ optimizeBtn.addEventListener('click', async () => {
928
+ optimizeBtn.disabled = true;
929
+ optimizeStatus.textContent = 'Optimizing…';
930
+ const result = await api('/maintenance', { method: 'POST' });
931
+ if (result._error || !result.ok) {
932
+ optimizeStatus.textContent = `Failed: ${result.error || 'Unknown error'}`;
933
+ } else {
934
+ optimizeStatus.textContent = `${result.sizeBefore?.display || 'N/A'} → ${result.sizeAfter?.display || 'N/A'}`;
935
+ const dbSizeValue = $('#dbSizeValue');
936
+ if (dbSizeValue) dbSizeValue.textContent = result.sizeAfter?.display || 'N/A';
937
+ }
938
+ optimizeBtn.disabled = false;
939
+ });
940
+ }
763
941
  }
764
942
 
765
943
  async function viewFiles() {
766
944
  window._lastView = 'files';
767
- content.innerHTML = '<div class="loading">Loading</div>';
945
+ content.innerHTML = `<div class="page-title">Files</div>${skeletonRows(6, 'file')}`;
946
+ transitionView();
768
947
  const data = await api('/files?limit=500');
769
948
  if (data._error) {
770
949
  content.innerHTML = '<div class="empty"><h2>Unable to load</h2><p>Server unavailable. Pull to refresh or try again.</p></div>';
@@ -964,6 +1143,8 @@ window._lastView = 'sessions';
964
1143
  $$('.nav-item').forEach(item => {
965
1144
  item.addEventListener('click', () => {
966
1145
  if (window._sseCleanup) { window._sseCleanup(); window._sseCleanup = null; }
1146
+ if (window._timelineScrollHandler) { window.removeEventListener('scroll', window._timelineScrollHandler); window._timelineScrollHandler = null; }
1147
+ if (window._timelineSse) { window._timelineSse.close(); window._timelineSse = null; }
967
1148
  const view = item.dataset.view;
968
1149
  window._lastView = view;
969
1150
  updateNavActive(view);
@@ -976,6 +1157,9 @@ $$('.nav-item').forEach(item => {
976
1157
  });
977
1158
  });
978
1159
 
1160
+ initTheme();
1161
+ document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
1162
+ document.getElementById('theme-toggle-mobile')?.addEventListener('click', toggleTheme);
979
1163
  handleRoute();
980
1164
 
981
1165
  // Swipe right from left edge to go back
package/public/index.html CHANGED
@@ -21,6 +21,7 @@
21
21
  <nav class="sidebar">
22
22
  <div class="sidebar-header">
23
23
  <h1>Agent<span>Acta</span></h1>
24
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
24
25
  </div>
25
26
  <div class="nav-section">
26
27
  <div class="nav-item" data-view="sessions">
@@ -43,9 +44,12 @@
43
44
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>
44
45
  <span>Stats</span>
45
46
  </div>
47
+
46
48
  </div>
47
49
  </nav>
48
50
  <main class="main" id="content"></main>
51
+ <button class="theme-toggle-mobile" id="theme-toggle-mobile" title="Toggle theme" aria-label="Toggle theme"><svg class="theme-icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg><svg class="theme-icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
52
+
49
53
  </div>
50
54
  <script src="/app.js"></script>
51
55
  <script>
package/public/style.css CHANGED
@@ -71,6 +71,47 @@
71
71
 
72
72
  /* Layout */
73
73
  --sidebar-width: 240px;
74
+ --input-scheme: dark;
75
+ }
76
+
77
+ [data-theme="light"] {
78
+ --bg-base: #f5f7fb;
79
+ --bg-surface: #ffffff;
80
+ --bg-elevated: #f8fafc;
81
+ --bg-hover: #eef2f7;
82
+ --bg-active: #e8edf6;
83
+
84
+ --border-subtle: rgba(15, 23, 42, 0.06);
85
+ --border-default: rgba(15, 23, 42, 0.1);
86
+ --border-hover: rgba(15, 23, 42, 0.16);
87
+ --border-focus: rgba(36, 89, 214, 0.45);
88
+
89
+ --text-primary: #111827;
90
+ --text-secondary: #5b6678;
91
+ --text-tertiary: #8793a7;
92
+ --text-inverse: #ffffff;
93
+
94
+ --accent: #2459d6;
95
+ --accent-soft: rgba(36, 89, 214, 0.12);
96
+ --accent-medium: rgba(36, 89, 214, 0.2);
97
+
98
+ --green: #158f69;
99
+ --green-soft: rgba(21, 143, 105, 0.12);
100
+
101
+ --purple: #7d62eb;
102
+ --purple-soft: rgba(125, 98, 235, 0.12);
103
+
104
+ --amber: #b7791f;
105
+ --amber-soft: rgba(183, 121, 31, 0.12);
106
+
107
+ --red: #c24141;
108
+ --red-soft: rgba(194, 65, 65, 0.12);
109
+
110
+ --teal: #0f9f9a;
111
+ --teal-soft: rgba(15, 159, 154, 0.12);
112
+
113
+ --shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
114
+ --input-scheme: light;
74
115
  }
75
116
 
76
117
  /* ---- Reset ---- */
@@ -125,6 +166,10 @@ body {
125
166
 
126
167
  .sidebar-header {
127
168
  padding: var(--space-xl) var(--space-xl) var(--space-lg);
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: space-between;
172
+ gap: var(--space-sm);
128
173
  }
129
174
 
130
175
  .sidebar h1 {
@@ -141,6 +186,63 @@ body {
141
186
  background-clip: text;
142
187
  }
143
188
 
189
+ .theme-toggle {
190
+ width: 30px;
191
+ height: 30px;
192
+ border-radius: var(--radius-md);
193
+ border: 1px solid var(--border-default);
194
+ background: var(--bg-elevated);
195
+ color: var(--text-secondary);
196
+ display: inline-flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ cursor: pointer;
200
+ padding: 0;
201
+ }
202
+
203
+ .theme-toggle:hover {
204
+ background: var(--bg-hover);
205
+ color: var(--text-primary);
206
+ }
207
+
208
+ .theme-toggle svg,
209
+ .theme-toggle-mobile svg {
210
+ width: 16px;
211
+ height: 16px;
212
+ }
213
+
214
+ .theme-icon-moon { display: none; }
215
+ [data-theme="light"] .theme-icon-sun { display: none; }
216
+ [data-theme="light"] .theme-icon-moon { display: block; }
217
+
218
+ .theme-toggle-mobile {
219
+ display: none;
220
+ width: 32px;
221
+ height: 32px;
222
+ border-radius: var(--radius-md);
223
+ border: 1px solid var(--border-default);
224
+ background: var(--bg-surface);
225
+ color: var(--text-secondary);
226
+ align-items: center;
227
+ justify-content: center;
228
+ cursor: pointer;
229
+ padding: 0;
230
+ position: absolute;
231
+ top: calc(var(--space-xl) + env(safe-area-inset-top, 0px));
232
+ right: var(--space-lg);
233
+ z-index: 10;
234
+ }
235
+
236
+ .theme-toggle-mobile:hover {
237
+ background: var(--bg-hover);
238
+ color: var(--text-primary);
239
+ }
240
+
241
+ .theme-toggle-mobile svg {
242
+ width: 16px;
243
+ height: 16px;
244
+ }
245
+
144
246
  .nav-section {
145
247
  display: flex;
146
248
  flex-direction: column;
@@ -203,7 +305,7 @@ body {
203
305
  flex: 1;
204
306
  margin-left: var(--sidebar-width);
205
307
  padding: var(--space-2xl) var(--space-3xl);
206
- max-width: 1000px;
308
+ max-width: none;
207
309
  min-height: 100vh;
208
310
  }
209
311
 
@@ -655,6 +757,40 @@ mark {
655
757
  font-weight: 500;
656
758
  }
657
759
 
760
+ /* ---- Skeletons ---- */
761
+ .skeleton-card,
762
+ .skeleton-row {
763
+ border-color: var(--border-subtle);
764
+ }
765
+
766
+ .skeleton-line {
767
+ position: relative;
768
+ border-radius: var(--radius-sm);
769
+ background: var(--bg-elevated);
770
+ border: 1px solid var(--border-subtle);
771
+ overflow: hidden;
772
+ }
773
+
774
+ .skeleton-line::after {
775
+ content: '';
776
+ position: absolute;
777
+ inset: 0;
778
+ transform: translateX(-100%);
779
+ background: linear-gradient(90deg, transparent, var(--bg-hover), transparent);
780
+ animation: skeletonShimmer 1.4s ease-in-out infinite;
781
+ }
782
+
783
+ @keyframes skeletonShimmer {
784
+ 100% { transform: translateX(100%); }
785
+ }
786
+
787
+ .loading-more {
788
+ text-align: center;
789
+ color: var(--text-tertiary);
790
+ font-size: 12px;
791
+ padding: var(--space-md) 0;
792
+ }
793
+
658
794
  @keyframes pulse {
659
795
  0%, 100% { opacity: 0.4; }
660
796
  50% { opacity: 1; }
@@ -683,7 +819,7 @@ mark {
683
819
  outline: none;
684
820
  margin-bottom: var(--space-lg);
685
821
  transition: all var(--duration-normal) var(--ease-out);
686
- color-scheme: dark;
822
+ color-scheme: var(--input-scheme);
687
823
  }
688
824
 
689
825
  .date-input:hover {
@@ -1230,6 +1366,10 @@ mark {
1230
1366
 
1231
1367
  .sidebar-header { display: none; }
1232
1368
 
1369
+ .theme-toggle-mobile {
1370
+ display: flex;
1371
+ }
1372
+
1233
1373
  .nav-section {
1234
1374
  display: flex;
1235
1375
  flex-direction: row;