feat(app): add Electron desktop UI and evolve memory/retrieval layer
Introduce an Electron app with session browser, memory list, and usage views (vanilla JS + Vue scaffolding). On the data layer: add content_type and is_meta to messages for transcript control-plane filtering, introduce FTS5-backed memory recall with safe tokenization, support memory archival via forget() through the renamed --attune runtime, and expose anchors on memory records.
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
plans/
|
plans/
|
||||||
.skillopt-backups
|
.skillopt-backups
|
||||||
tests/
|
tests/
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -91,7 +91,14 @@ Reads the JSON result, answers you in natural language
|
|||||||
|
|
||||||
When a retrieval produces a memory worth keeping, the agent proposes a markdown
|
When a retrieval produces a memory worth keeping, the agent proposes a markdown
|
||||||
memory file. After user approval, it registers that file with the narrow
|
memory file. After user approval, it registers that file with the narrow
|
||||||
`runtime.mjs --remember <script>` runtime, which exposes only `remember()`.
|
`runtime.mjs --attune <script>` runtime, which exposes only memory mutation
|
||||||
|
helpers such as `remember()` and `forget()`.
|
||||||
|
|
||||||
|
Memory is a synthesis cache, not a replacement for raw sessions. The agent can
|
||||||
|
decide whether to use, ignore, or verify a memory during an answer. Persistent
|
||||||
|
changes still require human approval, but explicit corrections count: if you say
|
||||||
|
a memory is wrong, outdated, or should be replaced, the agent can archive or
|
||||||
|
update the exact matching record without a second confirmation.
|
||||||
|
|
||||||
**The core idea: don't make humans browse, tag, or organize sessions.**
|
**The core idea: don't make humans browse, tag, or organize sessions.**
|
||||||
Don't invent a rigid query DSL either.
|
Don't invent a rigid query DSL either.
|
||||||
@@ -104,7 +111,7 @@ references only when the question needs them:
|
|||||||
|
|
||||||
**Core primitives** — the main CodeAct surface:
|
**Core primitives** — the main CodeAct surface:
|
||||||
|
|
||||||
- `search(text)` — FTS5 full-text search, returns matches with surrounding context
|
- `search(text)` — FTS5 full-text search, returns matches with surrounding context plus message `content_type` and `is_meta`
|
||||||
- `context(uuid)` — full story around a message (parent chain, subagent/workflow metadata)
|
- `context(uuid)` — full story around a message (parent chain, subagent/workflow metadata)
|
||||||
- `sql(query, ...params)` — read-only SQL for structured queries
|
- `sql(query, ...params)` — read-only SQL for structured queries
|
||||||
|
|
||||||
@@ -132,9 +139,9 @@ schema stay out of the first prompt until the agent needs them.
|
|||||||
| **Subagents** | `subagents/agent-<id>.jsonl` | Agent type, description, full conversation |
|
| **Subagents** | `subagents/agent-<id>.jsonl` | Agent type, description, full conversation |
|
||||||
| **Workflows** | `workflows/wf_<runId>.json` | Script, structured result, agent count |
|
| **Workflows** | `workflows/wf_<runId>.json` | Script, structured result, agent count |
|
||||||
| **Workflow agents** | `subagents/workflows/wf_<runId>/` | Per-agent transcripts linked to workflow |
|
| **Workflow agents** | `subagents/workflows/wf_<runId>/` | Per-agent transcripts linked to workflow |
|
||||||
| **Memories** | markdown files registered by the agent after user approval | Prior conclusions linked to source sessions/messages |
|
| **Memories** | markdown files registered by the agent after user approval | Prior conclusions linked to source sessions/messages and optional anchors |
|
||||||
|
|
||||||
Full-text search via FTS5 covers message text across every layer, while the SQLite tables preserve the structure agents need for investigation.
|
Full-text search via FTS5 covers message text across every session layer and ranked memory recall over registered memory summaries, while the SQLite tables preserve the structure agents need for investigation.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ Custom query:
|
|||||||
3. Parse JSON stdout and answer with concise evidence.
|
3. Parse JSON stdout and answer with concise evidence.
|
||||||
|
|
||||||
The query file runs inside `(async () => { ... })()`. Use `return` to emit JSON.
|
The query file runs inside `(async () => { ... })()`. Use `return` to emit JSON.
|
||||||
Query scripts are read-only: `remember()` is not available, and `sql()` only
|
Query scripts are read-only: `remember()` and `forget()` are not available, and
|
||||||
accepts read-only SELECT/WITH queries.
|
`sql()` only accepts read-only SELECT/WITH queries.
|
||||||
|
|
||||||
## Default First Pass
|
## Default First Pass
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ messages.
|
|||||||
Returns:
|
Returns:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
[{ message: { uuid, text, role, timestamp, model, cwd },
|
[{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd },
|
||||||
session: { id, title, project, started_at },
|
session: { id, title, project, started_at },
|
||||||
rank,
|
rank,
|
||||||
context }]
|
context }]
|
||||||
@@ -105,7 +105,22 @@ Returns:
|
|||||||
timestamp. It is not the parent chain. Use `context(uuid)` or `trace(uuid)` for
|
timestamp. It is not the parent chain. Use `context(uuid)` or `trace(uuid)` for
|
||||||
causal/parent-chain context.
|
causal/parent-chain context.
|
||||||
|
|
||||||
Opts: `{ limit, sessionId, project, after, before, cwd }`.
|
Use `message.content_type` to keep evidence boundaries intact:
|
||||||
|
`text` is user/assistant visible language, `thinking` is trace/debug material,
|
||||||
|
`tool_use` marks a tool-call message whose details live in `tool_calls`, and
|
||||||
|
`tool_result` marks a tool-result message whose details live in `tool_results`.
|
||||||
|
`unknown` is a conservative fallback. Do not treat `thinking` as a user-visible
|
||||||
|
assistant conclusion. Real user input is `type='user'` plus `content_type='text'`;
|
||||||
|
do not invent a separate `user_message` content type.
|
||||||
|
|
||||||
|
Use `message.is_meta` to separate transcript control-plane material from
|
||||||
|
conversation evidence. `is_meta=1` marks injected caveats, command envelopes, or
|
||||||
|
other messages that entered the transcript as user-role content but should not
|
||||||
|
be treated as the user's request by default. `search()` and `thread()` omit meta
|
||||||
|
messages unless `includeMeta: true` is passed; `context()` and `trace()` preserve
|
||||||
|
the original chain and expose `is_meta` on rows.
|
||||||
|
|
||||||
|
Opts: `{ limit, sessionId, project, after, before, cwd, includeMeta }`.
|
||||||
|
|
||||||
`project` is a SQL `LIKE` filter over `sessions.project`, not an exact project
|
`project` is a SQL `LIKE` filter over `sessions.project`, not an exact project
|
||||||
identity. Results are already ordered by FTS5 rank; lower rank sorts earlier.
|
identity. Results are already ordered by FTS5 rank; lower rank sorts earlier.
|
||||||
@@ -159,9 +174,9 @@ tiny sample before relying on less common filters.
|
|||||||
- `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows.
|
- `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows.
|
||||||
- `failures(opts?)` -- failed tool results with tool/session context, newest first.
|
- `failures(opts?)` -- failed tool results with tool/session context, newest first.
|
||||||
- `trace(uuid)` -- parent chain from root to message.
|
- `trace(uuid)` -- parent chain from root to message.
|
||||||
- `thread(sessionId)` -- full session messages; last resort only.
|
- `thread(sessionId, opts?)` -- session messages ordered by timestamp, omitting meta messages by default. Pass `{ includeMeta: true }` when investigating injected context or command envelopes.
|
||||||
- `raw(uuid, opts?)` -- windowed access to the original JSONL line.
|
- `raw(uuid, opts?)` -- windowed access to the original JSONL line.
|
||||||
- `memories(opts?)` -- recall memory layer, newest first. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. `query` filters summary/path by English terms. Returns registered memory records (id, path, summary, project, session_id, created_at). Read the file at `path` for full content.
|
- `memories(opts?)` -- recall memory layer. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. Without `query`, returns active memory records newest first. With `query`, searches `summary`/`path` through safe FTS5 tokenization and returns `rank`; lower rank sorts earlier. Records may include nullable JSON `anchors` for explicit recall surfaces such as files. Read the file at `path` for full content.
|
||||||
|
|
||||||
## Retrieval Contract
|
## Retrieval Contract
|
||||||
|
|
||||||
@@ -173,7 +188,8 @@ Keep queries scoped, bounded, and structural.
|
|||||||
- Plan Before Probe: for conclusion, broad history, failure investigation, or file evolution, write a bounded retrieval script instead of spending turns on intermediate results.
|
- Plan Before Probe: for conclusion, broad history, failure investigation, or file evolution, write a bounded retrieval script instead of spending turns on intermediate results.
|
||||||
- Structure Before Text: compute counts, joins, grouping, dedupe, and projection in SQL or JS; keep runtime JSON compact, ideally under 10k-12k chars for synthesis tasks.
|
- Structure Before Text: compute counts, joins, grouping, dedupe, and projection in SQL or JS; keep runtime JSON compact, ideally under 10k-12k chars for synthesis tasks.
|
||||||
- Evidence Before Conclusion: return compact evidence with stable IDs (`session_id`, `uuid`, `tool_call_id`, `run_id`, `agent_id`) and short snippets, then synthesize in the final answer.
|
- Evidence Before Conclusion: return compact evidence with stable IDs (`session_id`, `uuid`, `tool_call_id`, `run_id`, `agent_id`) and short snippets, then synthesize in the final answer.
|
||||||
- Persist Durable Conclusions: after answering, if retrieval produced a durable conclusion that future sessions are likely to reuse and `memories()` does not already cover it, explicitly offer to write a memory. Keep the offer brief. Do not write the markdown file or run `--remember` until the user approves.
|
- Exclude Meta By Default: `is_meta=1` rows are injected/control-plane transcript material. Helpers hide them by default; raw SQL for ordinary conversation evidence should include `COALESCE(m.is_meta,0)=0` unless meta rows are the investigation target.
|
||||||
|
- Persist Durable Conclusions: after answering, if retrieval produced a durable conclusion that future sessions are likely to reuse and `memories()` does not already cover it, explicitly offer to write a memory. Keep the offer brief. Do not write the markdown file or run `--attune` until the user approves.
|
||||||
|
|
||||||
If field, context, ordering, FTS, or helper semantics affect the query, read
|
If field, context, ordering, FTS, or helper semantics affect the query, read
|
||||||
`references/retrieval-semantics.md` before coding. If a query errors, read
|
`references/retrieval-semantics.md` before coding. If a query errors, read
|
||||||
@@ -196,9 +212,13 @@ obvious CJK text in memory queries and summaries as a guardrail.
|
|||||||
|
|
||||||
**Recall:** query `memories({ query: 'English topic terms', project: '...' })`
|
**Recall:** query `memories({ query: 'English topic terms', project: '...' })`
|
||||||
to find prior conclusions relevant to the current task. Translate non-English
|
to find prior conclusions relevant to the current task. Translate non-English
|
||||||
user requests into concise English query terms before calling `memories()`. Like
|
user requests into concise English query terms before calling `memories()`.
|
||||||
other list helpers, passing a string is treated as `sessionId`, and passing a
|
Memory recall uses safe FTS5 tokenization over `summary` and `path`, so
|
||||||
number is treated as `limit`. Read the file at `path` for full content.
|
hyphens/punctuation are tokenized instead of causing raw `MATCH` syntax errors.
|
||||||
|
Like other list helpers, passing a string is treated as `sessionId`, and passing
|
||||||
|
a number is treated as `limit`. Read the file at `path` for full content.
|
||||||
|
`memories()` returns active memories only. An archived memory is
|
||||||
|
management/audit data, not recall data.
|
||||||
|
|
||||||
Good memory candidates include design decisions, project conventions, abandoned
|
Good memory candidates include design decisions, project conventions, abandoned
|
||||||
alternatives, repeated failure causes, workflow patterns, and conclusions
|
alternatives, repeated failure causes, workflow patterns, and conclusions
|
||||||
@@ -206,6 +226,14 @@ synthesized across multiple raw evidence points. Do not propose memory for
|
|||||||
one-off lookups, uncertain findings, or conclusions already covered by existing
|
one-off lookups, uncertain findings, or conclusions already covered by existing
|
||||||
memories.
|
memories.
|
||||||
|
|
||||||
|
**Mutation approvals:** judging whether to use a memory in the current answer is
|
||||||
|
an agent decision and does not require approval. Persistent memory changes do.
|
||||||
|
If the user explicitly says a memory is wrong, outdated, should be forgotten, or
|
||||||
|
should now say something else, that request is the approval to archive or update
|
||||||
|
the exact matching memory. Do not ask for a second confirmation unless multiple
|
||||||
|
memories could match. If you notice a possible conflict yourself, explain it
|
||||||
|
briefly and ask before changing memory state.
|
||||||
|
|
||||||
**Writing memories:** after a retrieval produces a conclusion worth persisting,
|
**Writing memories:** after a retrieval produces a conclusion worth persisting,
|
||||||
propose writing a memory file. The user must approve. Flow:
|
propose writing a memory file. The user must approve. Flow:
|
||||||
|
|
||||||
@@ -218,6 +246,7 @@ return remember({
|
|||||||
session_id: 'current-session-id',
|
session_id: 'current-session-id',
|
||||||
message_start: 'uuid-of-first-relevant-msg',
|
message_start: 'uuid-of-first-relevant-msg',
|
||||||
message_end: 'uuid-of-last-relevant-msg',
|
message_end: 'uuid-of-last-relevant-msg',
|
||||||
|
anchors: [{ kind: 'file', path: 'src/path/to/file.ts' }],
|
||||||
summary: 'Detailed summary: what was decided, why, what alternatives were considered, and what constraints drove the choice.'
|
summary: 'Detailed summary: what was decided, why, what alternatives were considered, and what constraints drove the choice.'
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -225,17 +254,21 @@ return remember({
|
|||||||
Run the registration script with:
|
Run the registration script with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node $SKILL_DIR/scripts/runtime.mjs --remember /tmp/register-memory.mjs
|
node $SKILL_DIR/scripts/runtime.mjs --attune /tmp/register-memory.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
`--remember` exposes only `remember()`. It does not expose `search()`, `sql()`,
|
`--attune` exposes only memory mutation helpers: `remember()` and `forget()`.
|
||||||
`memories()`, or other retrieval helpers. If you need source IDs, find them
|
It does not expose `search()`, `sql()`, `memories()`, or other retrieval
|
||||||
first with a normal `--query` script.
|
helpers. If you need source IDs or memory IDs, find them first with a normal
|
||||||
|
`--query` script.
|
||||||
|
|
||||||
`remember()` validates that `path` already exists and points to a file. Relative
|
`remember()` validates that `path` already exists and points to a file. Relative
|
||||||
paths are resolved against the source session's `project_path` when
|
paths are resolved against the source session's `project_path` when
|
||||||
`session_id` is provided, then stored as normalized absolute paths. Prefer
|
`session_id` is provided, then stored as normalized absolute paths. Prefer
|
||||||
project-relative paths such as `.obelisk/memories/...` plus `session_id`.
|
project-relative paths such as `.obelisk/memories/...` plus `session_id`.
|
||||||
|
Optional `anchors` must be an array of objects and is stored as nullable JSON
|
||||||
|
text. Use it only for explicit recall surfaces, such as files associated with
|
||||||
|
the memory.
|
||||||
|
|
||||||
`summary` must be English and detailed enough that `memories()` results alone
|
`summary` must be English and detailed enough that `memories()` results alone
|
||||||
can judge relevance without reading the file. Include the decision, the
|
can judge relevance without reading the file. Include the decision, the
|
||||||
@@ -244,7 +277,29 @@ reasoning, and the key constraints — not just a title.
|
|||||||
The `message_start`/`message_end` range marks where in the conversation this
|
The `message_start`/`message_end` range marks where in the conversation this
|
||||||
conclusion was drawn. Use it later to trace back to the original evidence.
|
conclusion was drawn. Use it later to trace back to the original evidence.
|
||||||
|
|
||||||
Memory records survive index rebuilds. They are never auto-deleted.
|
**Forgetting memories:** if the user says a memory is outdated, wrong, or should
|
||||||
|
be forgotten, use normal recall first to identify the exact memory ID. If there
|
||||||
|
is exactly one clear candidate, the user's request is approval to archive it. If
|
||||||
|
multiple memories could match, ask which one to forget. Then run an `--attune`
|
||||||
|
script:
|
||||||
|
|
||||||
|
```js
|
||||||
|
return forget({
|
||||||
|
id: 'mem-id-to-delete',
|
||||||
|
reason: 'Outdated by newer project guidance.',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`forget()` archives the memory record by setting `deleted_at` and
|
||||||
|
`deleted_reason`. It removes the record from active recall but does not delete
|
||||||
|
the markdown file. Memory records survive index rebuilds and are never changed
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
**Updating memories:** updating memory is one user-approved operation:
|
||||||
|
archive the old memory with `forget()`, then write and register a replacement
|
||||||
|
markdown memory with `remember()`. If the user explicitly corrected the memory,
|
||||||
|
that correction is approval for the combined archive-plus-write flow. If you
|
||||||
|
discovered the mismatch yourself, ask first.
|
||||||
|
|
||||||
## Minimal Patterns
|
## Minimal Patterns
|
||||||
|
|
||||||
|
|||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const DB_PATH = path.join(os.homedir(), '.claude', 'obelisk.sqlite');
|
||||||
|
|
||||||
|
let db;
|
||||||
|
|
||||||
|
function openDb() {
|
||||||
|
if (!fs.existsSync(DB_PATH)) return null;
|
||||||
|
db = new Database(DB_PATH, { readonly: false });
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 500,
|
||||||
|
titleBarStyle: 'hiddenInset',
|
||||||
|
trafficLightPosition: { x: 14, y: 10 },
|
||||||
|
backgroundColor: '#0a0b14',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDev = process.argv.includes('--dev');
|
||||||
|
if (isDev) {
|
||||||
|
win.loadURL('http://localhost:5173');
|
||||||
|
win.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
win.loadFile(path.join(__dirname, 'dist-renderer', 'index.html'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
openDb();
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (db) db.close();
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- IPC Handlers ---
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSessions', (_, opts = {}) => {
|
||||||
|
if (!db) return [];
|
||||||
|
const { project, limit = 200 } = opts;
|
||||||
|
let sql = `SELECT id, title, project, project_path, started_at, ended_at, git_branch, version, message_count, jsonl_path FROM sessions`;
|
||||||
|
const params = [];
|
||||||
|
if (project) { sql += ` WHERE project LIKE ?`; params.push(project); }
|
||||||
|
sql += ` ORDER BY COALESCE(ended_at, started_at) DESC LIMIT ?`;
|
||||||
|
params.push(limit);
|
||||||
|
return db.prepare(sql).all(...params);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSessionMessages', (_, sessionId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
||||||
|
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
||||||
|
m.content_type, m.is_meta
|
||||||
|
FROM messages m WHERE m.session_id = ? AND m.agent_id IS NULL ORDER BY m.timestamp
|
||||||
|
`).all(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSessionToolCalls', (_, sessionId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`SELECT * FROM tool_calls WHERE session_id = ?`).all(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSessionToolResults', (_, sessionId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`SELECT * FROM tool_results WHERE session_id = ?`).all(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSessionSubagents', (_, sessionId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`SELECT * FROM subagents WHERE session_id = ?`).all(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSessionWorkflows', (_, sessionId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
const workflows = db.prepare(`SELECT * FROM workflows WHERE session_id = ?`).all(sessionId);
|
||||||
|
for (const wf of workflows) {
|
||||||
|
wf.agents = db.prepare(`SELECT * FROM workflow_agents WHERE run_id = ?`).all(wf.run_id);
|
||||||
|
}
|
||||||
|
return workflows;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSubagentMessages', (_, agentId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT m.uuid, m.session_id, m.type, m.parent_uuid, m.timestamp, m.role, m.text, m.model,
|
||||||
|
m.is_sidechain, m.agent_id, m.input_tokens, m.output_tokens, m.cwd, m.skill, m.turn_duration_ms,
|
||||||
|
m.content_type, m.is_meta
|
||||||
|
FROM messages m WHERE m.agent_id = ? ORDER BY m.timestamp
|
||||||
|
`).all(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSubagentToolCalls', (_, agentId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT tc.* FROM tool_calls tc
|
||||||
|
JOIN messages m ON m.uuid = tc.message_uuid
|
||||||
|
WHERE m.agent_id = ?
|
||||||
|
`).all(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSubagentToolResults', (_, agentId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT tr.* FROM tool_results tr
|
||||||
|
JOIN messages m ON m.uuid = tr.message_uuid
|
||||||
|
WHERE m.agent_id = ?
|
||||||
|
`).all(agentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getSessionSummaries', (_, sessionId) => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`SELECT * FROM summaries WHERE session_id = ?`).all(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getMemories', () => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, session_id, project, message_start, message_end, path, summary, created_at, deleted_at, deleted_reason
|
||||||
|
FROM memories ORDER BY created_at DESC
|
||||||
|
`).all();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getMessageFullText', (_, uuid) => {
|
||||||
|
if (!db) return null;
|
||||||
|
const msg = db.prepare('SELECT session_id, agent_id FROM messages WHERE uuid=?').get(uuid);
|
||||||
|
if (!msg) return null;
|
||||||
|
|
||||||
|
// Resolve JSONL path
|
||||||
|
let jsonlPath = null;
|
||||||
|
if (msg.agent_id) {
|
||||||
|
const wa = db.prepare('SELECT agent_id, run_id, session_id FROM workflow_agents WHERE agent_id=?').get(msg.agent_id);
|
||||||
|
if (wa) {
|
||||||
|
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(wa.session_id);
|
||||||
|
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), wa.session_id, 'subagents', 'workflows', wa.run_id, wa.agent_id + '.jsonl');
|
||||||
|
}
|
||||||
|
if (!jsonlPath) {
|
||||||
|
const sa = db.prepare('SELECT agent_id, session_id FROM subagents WHERE agent_id=?').get(msg.agent_id);
|
||||||
|
if (sa) {
|
||||||
|
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(sa.session_id);
|
||||||
|
if (ses) jsonlPath = path.join(path.dirname(ses.jsonl_path), sa.session_id, 'subagents', sa.agent_id + '.jsonl');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!jsonlPath) {
|
||||||
|
const ses = db.prepare('SELECT jsonl_path FROM sessions WHERE id=?').get(msg.session_id);
|
||||||
|
if (ses) jsonlPath = ses.jsonl_path;
|
||||||
|
}
|
||||||
|
if (!jsonlPath || !fs.existsSync(jsonlPath)) return null;
|
||||||
|
|
||||||
|
// Scan JSONL for the message UUID and extract full text
|
||||||
|
const readline = require('readline');
|
||||||
|
const data = fs.readFileSync(jsonlPath, 'utf-8');
|
||||||
|
const lines = data.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.includes(uuid)) continue;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
if (obj.uuid !== uuid) continue;
|
||||||
|
const content = obj.message?.content;
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (!Array.isArray(content)) return null;
|
||||||
|
const parts = [];
|
||||||
|
for (const b of content) {
|
||||||
|
if (b.type === 'text' && b.text) parts.push(b.text);
|
||||||
|
else if (b.type === 'thinking' && b.thinking) parts.push(b.thinking);
|
||||||
|
}
|
||||||
|
return parts.join('\n') || null;
|
||||||
|
} catch { continue; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:readMemoryFile', (_, filePath) => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return null;
|
||||||
|
} catch { return null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:archiveMemory', (_, id, reason) => {
|
||||||
|
if (!db) return false;
|
||||||
|
db.prepare(`UPDATE memories SET deleted_at = ?, deleted_reason = ? WHERE id = ?`)
|
||||||
|
.run(new Date().toISOString(), reason || 'Archived via panel', id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:restoreMemory', (_, id) => {
|
||||||
|
if (!db) return false;
|
||||||
|
db.prepare(`UPDATE memories SET deleted_at = NULL, deleted_reason = NULL WHERE id = ?`).run(id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getProjects', () => {
|
||||||
|
if (!db) return [];
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT project, project_path, COUNT(*) as session_count,
|
||||||
|
MAX(COALESCE(ended_at, started_at)) as last_active
|
||||||
|
FROM sessions WHERE project IS NOT NULL
|
||||||
|
GROUP BY project ORDER BY last_active DESC
|
||||||
|
`).all();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getStats', () => {
|
||||||
|
if (!db) return { sessions: 0, memories: 0, memoriesArchived: 0 };
|
||||||
|
const sessions = db.prepare('SELECT COUNT(*) as c FROM sessions').get()?.c || 0;
|
||||||
|
const memories = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
|
||||||
|
const memoriesArchived = db.prepare('SELECT COUNT(*) as c FROM memories WHERE deleted_at IS NOT NULL').get()?.c || 0;
|
||||||
|
return { sessions, memories, memoriesArchived };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('db:getUsageStats', () => {
|
||||||
|
if (!db) return { daily: [], totalTokens: 0, peakDay: null, longestTurn: null };
|
||||||
|
|
||||||
|
const daily = db.prepare(`
|
||||||
|
SELECT DATE(timestamp) as day,
|
||||||
|
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
|
||||||
|
FROM messages
|
||||||
|
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
||||||
|
GROUP BY DATE(timestamp)
|
||||||
|
ORDER BY day
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const totalTokens = db.prepare(`
|
||||||
|
SELECT SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as total
|
||||||
|
FROM messages
|
||||||
|
`).get()?.total || 0;
|
||||||
|
|
||||||
|
const peakDay = db.prepare(`
|
||||||
|
SELECT DATE(timestamp) as day,
|
||||||
|
SUM(COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) as tokens
|
||||||
|
FROM messages
|
||||||
|
WHERE timestamp IS NOT NULL AND (input_tokens IS NOT NULL OR output_tokens IS NOT NULL)
|
||||||
|
GROUP BY DATE(timestamp)
|
||||||
|
ORDER BY tokens DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get() || null;
|
||||||
|
|
||||||
|
const longestTurn = db.prepare(`
|
||||||
|
SELECT turn_duration_ms, uuid, session_id, timestamp
|
||||||
|
FROM messages
|
||||||
|
WHERE turn_duration_ms IS NOT NULL
|
||||||
|
ORDER BY turn_duration_ms DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get() || null;
|
||||||
|
|
||||||
|
return { daily, totalTokens, peakDay, longestTurn };
|
||||||
|
});
|
||||||
Generated
+6858
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "obelisk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Memory management for Obelisk — let Claude Code search its own memory",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dev": "electron . --dev",
|
||||||
|
"dev:renderer": "vite renderer",
|
||||||
|
"build:renderer": "vite build renderer",
|
||||||
|
"build": "electron-builder"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.obelisk.app",
|
||||||
|
"productName": "Obelisk",
|
||||||
|
"mac": {
|
||||||
|
"target": "dmg",
|
||||||
|
"category": "public.app-category.developer-tools"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"dist-renderer/**/*",
|
||||||
|
"node_modules/better-sqlite3/**/*"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/better-sqlite3/**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"electron": "^33.0.0",
|
||||||
|
"electron-builder": "^25.0.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('obelisk', {
|
||||||
|
getSessions: (opts) => ipcRenderer.invoke('db:getSessions', opts),
|
||||||
|
getSessionMessages: (id) => ipcRenderer.invoke('db:getSessionMessages', id),
|
||||||
|
getSessionToolCalls: (id) => ipcRenderer.invoke('db:getSessionToolCalls', id),
|
||||||
|
getSessionToolResults: (id) => ipcRenderer.invoke('db:getSessionToolResults', id),
|
||||||
|
getSessionSubagents: (id) => ipcRenderer.invoke('db:getSessionSubagents', id),
|
||||||
|
getSessionWorkflows: (id) => ipcRenderer.invoke('db:getSessionWorkflows', id),
|
||||||
|
getSubagentMessages: (agentId) => ipcRenderer.invoke('db:getSubagentMessages', agentId),
|
||||||
|
getSubagentToolCalls: (agentId) => ipcRenderer.invoke('db:getSubagentToolCalls', agentId),
|
||||||
|
getSubagentToolResults: (agentId) => ipcRenderer.invoke('db:getSubagentToolResults', agentId),
|
||||||
|
getSessionSummaries: (id) => ipcRenderer.invoke('db:getSessionSummaries', id),
|
||||||
|
getMessageFullText: (uuid) => ipcRenderer.invoke('db:getMessageFullText', uuid),
|
||||||
|
getMemories: () => ipcRenderer.invoke('db:getMemories'),
|
||||||
|
readMemoryFile: (path) => ipcRenderer.invoke('db:readMemoryFile', path),
|
||||||
|
archiveMemory: (id, reason) => ipcRenderer.invoke('db:archiveMemory', id, reason),
|
||||||
|
restoreMemory: (id) => ipcRenderer.invoke('db:restoreMemory', id),
|
||||||
|
getProjects: () => ipcRenderer.invoke('db:getProjects'),
|
||||||
|
getStats: () => ipcRenderer.invoke('db:getStats'),
|
||||||
|
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'),
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Obelisk</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Entry point -- wires data, rendering, and event handlers together.
|
||||||
|
// No exports; this is the bootstrap module.
|
||||||
|
|
||||||
|
import { loadInitialData } from './data.js';
|
||||||
|
import { state, IS_MAC } from './state.js';
|
||||||
|
import {
|
||||||
|
renderAll,
|
||||||
|
renderMemoryList,
|
||||||
|
renderSessionList,
|
||||||
|
setRoute,
|
||||||
|
setView,
|
||||||
|
setProject,
|
||||||
|
enterDetail,
|
||||||
|
archive,
|
||||||
|
restore,
|
||||||
|
setCursor,
|
||||||
|
navigateToSession,
|
||||||
|
switchView
|
||||||
|
} from './render.js';
|
||||||
|
import { initKeyboard } from './keys.js';
|
||||||
|
|
||||||
|
// -- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
let searchDebounceTimer = null;
|
||||||
|
|
||||||
|
function debounce(fn, ms) {
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = setTimeout(() => fn(...args), ms);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Boot -------------------------------------------------------------------
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await loadInitialData();
|
||||||
|
initKeyboard();
|
||||||
|
|
||||||
|
// -- Sidebar navigation (route switching + project filter) ----------------
|
||||||
|
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
if (sidebar) {
|
||||||
|
sidebar.addEventListener('click', e => {
|
||||||
|
const item = e.target.closest('.sidebar-item');
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const route = item.dataset.route;
|
||||||
|
const project = item.dataset.project;
|
||||||
|
const view = item.dataset.view;
|
||||||
|
|
||||||
|
if (route) {
|
||||||
|
setRoute(route);
|
||||||
|
} else if (view) {
|
||||||
|
setView(view);
|
||||||
|
} else if (project !== undefined) {
|
||||||
|
setProject(project);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Sidebar search (filter projects list) --------------------------------
|
||||||
|
|
||||||
|
const sidebarSearch = document.querySelector('.sidebar-search input');
|
||||||
|
if (sidebarSearch) {
|
||||||
|
sidebarSearch.addEventListener('input', e => {
|
||||||
|
state.projectSearch = e.target.value;
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Breadcrumb click (navigate back to list) -----------------------------
|
||||||
|
|
||||||
|
const breadcrumb = document.querySelector('.breadcrumb');
|
||||||
|
if (breadcrumb) {
|
||||||
|
breadcrumb.addEventListener('click', e => {
|
||||||
|
const crumb = e.target.closest('[data-action]');
|
||||||
|
if (!crumb) return;
|
||||||
|
const action = crumb.dataset.action;
|
||||||
|
if (action === 'goto-sessions') { state.projectFilter = 'all'; setRoute('sessions'); }
|
||||||
|
else if (action === 'goto-memory') { state.projectFilter = 'all'; setView('active'); }
|
||||||
|
else if (action === 'goto-session-detail') { state.subagentId = null; state.subagentDescription = null; switchView(); renderAll(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Toolbar search with debounce -----------------------------------------
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
if (searchInput) {
|
||||||
|
const handleSearch = debounce(value => {
|
||||||
|
state.query = value;
|
||||||
|
if (state.route === 'sessions') renderSessionList();
|
||||||
|
else renderMemoryList();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', e => {
|
||||||
|
handleSearch(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Sort toggle -----------------------------------------------------------
|
||||||
|
|
||||||
|
const sortToggle = document.querySelector('.sort-group');
|
||||||
|
if (sortToggle) {
|
||||||
|
sortToggle.addEventListener('click', () => {
|
||||||
|
state.sortDesc = !state.sortDesc;
|
||||||
|
sortToggle.classList.toggle('desc', state.sortDesc);
|
||||||
|
sortToggle.classList.toggle('asc', !state.sortDesc);
|
||||||
|
if (state.route === 'sessions') renderSessionList();
|
||||||
|
else renderMemoryList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Search messages toggle ------------------------------------------------
|
||||||
|
|
||||||
|
const searchMsgsToggle = document.querySelector('.filter-toggle');
|
||||||
|
if (searchMsgsToggle) {
|
||||||
|
searchMsgsToggle.addEventListener('click', () => {
|
||||||
|
state.includeMessageBodies = !state.includeMessageBodies;
|
||||||
|
searchMsgsToggle.classList.toggle('active', state.includeMessageBodies);
|
||||||
|
if (state.query) {
|
||||||
|
if (state.route === 'sessions') renderSessionList();
|
||||||
|
else renderMemoryList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- #list click (memory rows: selection, actions, navigation) ------------
|
||||||
|
|
||||||
|
const list = document.getElementById('list');
|
||||||
|
if (list) {
|
||||||
|
list.addEventListener('click', e => {
|
||||||
|
// Action buttons (archive/restore)
|
||||||
|
const action = e.target.closest('.row-action');
|
||||||
|
if (action) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const row = action.closest('.row');
|
||||||
|
const id = row?.dataset.id;
|
||||||
|
if (!id) return;
|
||||||
|
if (action.classList.contains('restore')) {
|
||||||
|
restore([id]);
|
||||||
|
} else {
|
||||||
|
archive([id]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkbox toggling
|
||||||
|
const checkbox = e.target.closest('.row-checkbox');
|
||||||
|
if (checkbox) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const row = checkbox.closest('.row');
|
||||||
|
const id = row?.dataset.id;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
if (e.shiftKey && state.cursorId) {
|
||||||
|
// Range select between cursor and clicked
|
||||||
|
const rows = Array.from(list.querySelectorAll('.row'));
|
||||||
|
const ids = rows.map(r => r.dataset.id);
|
||||||
|
const fromIdx = ids.indexOf(state.cursorId);
|
||||||
|
const toIdx = ids.indexOf(id);
|
||||||
|
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
|
||||||
|
for (let i = lo; i <= hi; i++) {
|
||||||
|
state.selection.add(ids[i]);
|
||||||
|
}
|
||||||
|
} else if (e.metaKey || e.ctrlKey) {
|
||||||
|
// Toggle single
|
||||||
|
if (state.selection.has(id)) state.selection.delete(id);
|
||||||
|
else state.selection.add(id);
|
||||||
|
} else {
|
||||||
|
// Simple toggle
|
||||||
|
if (state.selection.has(id)) state.selection.delete(id);
|
||||||
|
else state.selection.add(id);
|
||||||
|
}
|
||||||
|
setCursor(id);
|
||||||
|
renderMemoryList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row click (navigate cursor / open detail)
|
||||||
|
const row = e.target.closest('.row');
|
||||||
|
if (!row) return;
|
||||||
|
const id = row.dataset.id;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
if (e.shiftKey && state.cursorId) {
|
||||||
|
// Shift-click: range select
|
||||||
|
const rows = Array.from(list.querySelectorAll('.row'));
|
||||||
|
const ids = rows.map(r => r.dataset.id);
|
||||||
|
const fromIdx = ids.indexOf(state.cursorId);
|
||||||
|
const toIdx = ids.indexOf(id);
|
||||||
|
const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
|
||||||
|
for (let i = lo; i <= hi; i++) {
|
||||||
|
state.selection.add(ids[i]);
|
||||||
|
}
|
||||||
|
renderMemoryList();
|
||||||
|
} else if ((IS_MAC ? e.metaKey : e.ctrlKey)) {
|
||||||
|
// Cmd/Ctrl-click: toggle selection
|
||||||
|
if (state.selection.has(id)) state.selection.delete(id);
|
||||||
|
else state.selection.add(id);
|
||||||
|
setCursor(id);
|
||||||
|
renderMemoryList();
|
||||||
|
} else {
|
||||||
|
// Plain click: move cursor
|
||||||
|
setCursor(id);
|
||||||
|
renderMemoryList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- #list dblclick (open detail) -----------------------------------------
|
||||||
|
|
||||||
|
list.addEventListener('dblclick', e => {
|
||||||
|
const row = e.target.closest('.row');
|
||||||
|
if (!row) return;
|
||||||
|
const id = row.dataset.id;
|
||||||
|
if (id) enterDetail(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- #session-list click (session rows) ------------------------------------
|
||||||
|
|
||||||
|
const sessionList = document.getElementById('session-list');
|
||||||
|
if (sessionList) {
|
||||||
|
sessionList.addEventListener('click', e => {
|
||||||
|
const srow = e.target.closest('.srow');
|
||||||
|
if (!srow) return;
|
||||||
|
const id = srow.dataset.sessionId;
|
||||||
|
if (id) navigateToSession(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Start on memory view -------------------------------------------------
|
||||||
|
|
||||||
|
setRoute('memory');
|
||||||
|
renderAll();
|
||||||
|
});
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// Data loading layer -- bridges Electron IPC (window.obelisk.*) to app state.
|
||||||
|
// All DB access goes through this module.
|
||||||
|
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load initial data from the DB and populate state.memories, state.sessions,
|
||||||
|
* and state.projects.
|
||||||
|
*/
|
||||||
|
export async function loadInitialData() {
|
||||||
|
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
|
||||||
|
window.obelisk.getMemories(),
|
||||||
|
window.obelisk.getSessions(),
|
||||||
|
window.obelisk.getStats(),
|
||||||
|
window.obelisk.getProjects()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Transform memories: DB records -> render-layer shape
|
||||||
|
state.memories = (rawMemories || []).map(m => ({
|
||||||
|
...m,
|
||||||
|
ts: m.created_at ? new Date(m.created_at).getTime() : 0,
|
||||||
|
archived: !!m.deleted_at,
|
||||||
|
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
|
||||||
|
health: 'ok',
|
||||||
|
anchors: [],
|
||||||
|
markdown: null // loaded on demand via loadMemoryMarkdown
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sessions: keep DB shape, add empty messages array for on-demand loading
|
||||||
|
state.sessions = (rawSessions || []).map(s => ({
|
||||||
|
...s,
|
||||||
|
messages: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
state.projects = projects || [];
|
||||||
|
state.stats = stats || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load full detail for a session: messages with inline tool_calls (each with
|
||||||
|
* result), summaries, subagents, and workflow data.
|
||||||
|
*
|
||||||
|
* Returns the assembled session object (also updates state.sessions entry).
|
||||||
|
*/
|
||||||
|
export async function loadSessionDetail(sessionId) {
|
||||||
|
const [messages, toolCalls, toolResults, subagents, workflows, summaries] =
|
||||||
|
await Promise.all([
|
||||||
|
window.obelisk.getSessionMessages(sessionId),
|
||||||
|
window.obelisk.getSessionToolCalls(sessionId),
|
||||||
|
window.obelisk.getSessionToolResults(sessionId),
|
||||||
|
window.obelisk.getSessionSubagents(sessionId),
|
||||||
|
window.obelisk.getSessionWorkflows(sessionId),
|
||||||
|
window.obelisk.getSessionSummaries(sessionId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Index tool results by tool_use_id for fast lookup
|
||||||
|
const resultsByCallId = {};
|
||||||
|
for (const r of (toolResults || [])) {
|
||||||
|
resultsByCallId[r.tool_use_id] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index subagents by parent_tool_use_id
|
||||||
|
const subagentsByCallId = {};
|
||||||
|
for (const sa of (subagents || [])) {
|
||||||
|
if (sa.parent_tool_use_id) {
|
||||||
|
subagentsByCallId[sa.parent_tool_use_id] = sa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group tool_calls by message_uuid, attaching result and subagent inline
|
||||||
|
const callsByMessageUuid = {};
|
||||||
|
for (const tc of (toolCalls || [])) {
|
||||||
|
const call = {
|
||||||
|
id: tc.id,
|
||||||
|
name: tc.name,
|
||||||
|
input_json: tc.input_json,
|
||||||
|
result: resultsByCallId[tc.id] || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach subagent data if present
|
||||||
|
const sa = subagentsByCallId[tc.id];
|
||||||
|
if (sa) {
|
||||||
|
call.subagent = {
|
||||||
|
agent_id: sa.agent_id,
|
||||||
|
agent_type: sa.agent_type,
|
||||||
|
description: sa.description
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgUuid = tc.message_uuid;
|
||||||
|
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||||
|
callsByMessageUuid[msgUuid].push(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach workflow data to Workflow tool calls
|
||||||
|
for (const wf of (workflows || [])) {
|
||||||
|
for (const calls of Object.values(callsByMessageUuid)) {
|
||||||
|
for (const call of calls) {
|
||||||
|
if (call.name === 'Workflow' && !call.workflow) {
|
||||||
|
const resultText = call.result?.content || '';
|
||||||
|
if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) {
|
||||||
|
call.workflow = {
|
||||||
|
run_id: wf.run_id,
|
||||||
|
workflow_name: wf.workflow_name,
|
||||||
|
status: wf.status,
|
||||||
|
duration_ms: wf.duration_ms,
|
||||||
|
total_tokens: wf.total_tokens,
|
||||||
|
agent_count: wf.agent_count,
|
||||||
|
agents: (wf.agents || []).map(a => ({
|
||||||
|
agent_id: a.agent_id,
|
||||||
|
phase: a.phase,
|
||||||
|
label: a.label,
|
||||||
|
state: a.state,
|
||||||
|
tokens: a.tokens,
|
||||||
|
duration_ms: a.duration_ms,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index summaries by session (summaries don't have per-message IDs in our schema)
|
||||||
|
const sessionSummaries = (summaries || []).map(s => ({
|
||||||
|
source: s.source,
|
||||||
|
content: s.content,
|
||||||
|
timestamp: s.timestamp
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assemble messages with tool_calls inline
|
||||||
|
const rawAssembled = (messages || []).map(msg => {
|
||||||
|
const assembled = {
|
||||||
|
uuid: msg.uuid,
|
||||||
|
type: msg.type || msg.role,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
text: msg.text,
|
||||||
|
content_type: msg.content_type || null,
|
||||||
|
is_meta: msg.is_meta || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const calls = callsByMessageUuid[msg.uuid];
|
||||||
|
if (calls && calls.length > 0) {
|
||||||
|
assembled.tool_calls = calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembled;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge adjacent assistant messages:
|
||||||
|
// - tool_result user messages are skipped (results shown inside tool_call panels)
|
||||||
|
// - consecutive tool_use messages (separated by tool_results) merge into one
|
||||||
|
// - thinking messages merge into the next non-thinking assistant message
|
||||||
|
const assembledMessages = [];
|
||||||
|
for (let i = 0; i < rawAssembled.length; i++) {
|
||||||
|
const msg = rawAssembled[i];
|
||||||
|
|
||||||
|
// Skip tool_result user messages
|
||||||
|
if (msg.content_type === 'tool_result') continue;
|
||||||
|
|
||||||
|
// For thinking messages, collect consecutive thinking blocks and attach to the next assistant
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||||
|
const thinkingParts = [msg.text || ''];
|
||||||
|
let j = i + 1;
|
||||||
|
// Absorb consecutive thinking messages
|
||||||
|
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||||
|
thinkingParts.push(rawAssembled[j].text || '');
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
// Find the next non-thinking assistant message to attach to
|
||||||
|
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||||
|
// Will be picked up by the next iteration; store thinking on it
|
||||||
|
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// No following assistant message — render as standalone collapsed thinking
|
||||||
|
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||||
|
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||||
|
if (msg._thinking) merged._thinking = msg._thinking;
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < rawAssembled.length) {
|
||||||
|
const next = rawAssembled[j];
|
||||||
|
if (next.content_type === 'tool_result') { j++; continue; }
|
||||||
|
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||||
|
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||||
|
if (next.text && !merged.text) merged.text = next.text;
|
||||||
|
j++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assembledMessages.push(merged);
|
||||||
|
i = j - 1;
|
||||||
|
} else {
|
||||||
|
// text or other assistant/user messages
|
||||||
|
const out = { ...msg };
|
||||||
|
if (msg._thinking) out._thinking = msg._thinking;
|
||||||
|
assembledMessages.push(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach workflow data if present
|
||||||
|
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
|
||||||
|
|
||||||
|
// Build assembled session object
|
||||||
|
const session = state.sessions.find(s => s.id === sessionId);
|
||||||
|
const assembled = {
|
||||||
|
...(session || {}),
|
||||||
|
id: sessionId,
|
||||||
|
messages: assembledMessages
|
||||||
|
};
|
||||||
|
|
||||||
|
if (workflow) {
|
||||||
|
assembled.workflow = workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in-place in state.sessions
|
||||||
|
const idx = state.sessions.findIndex(s => s.id === sessionId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
state.sessions[idx] = assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load full detail for a subagent conversation.
|
||||||
|
* Returns assembled messages with tool_calls inline.
|
||||||
|
*/
|
||||||
|
export async function loadSubagentDetail(agentId) {
|
||||||
|
const [messages, toolCalls, toolResults] = await Promise.all([
|
||||||
|
window.obelisk.getSubagentMessages(agentId),
|
||||||
|
window.obelisk.getSubagentToolCalls(agentId),
|
||||||
|
window.obelisk.getSubagentToolResults(agentId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resultsByCallId = {};
|
||||||
|
for (const r of (toolResults || [])) {
|
||||||
|
resultsByCallId[r.tool_use_id] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callsByMessageUuid = {};
|
||||||
|
for (const tc of (toolCalls || [])) {
|
||||||
|
const call = {
|
||||||
|
id: tc.id,
|
||||||
|
name: tc.name,
|
||||||
|
input_json: tc.input_json,
|
||||||
|
result: resultsByCallId[tc.id] || null
|
||||||
|
};
|
||||||
|
const msgUuid = tc.message_uuid;
|
||||||
|
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||||
|
callsByMessageUuid[msgUuid].push(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAssembled = (messages || []).map(msg => {
|
||||||
|
const assembled = {
|
||||||
|
uuid: msg.uuid,
|
||||||
|
type: msg.type || msg.role,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
text: msg.text,
|
||||||
|
content_type: msg.content_type || null,
|
||||||
|
is_meta: msg.is_meta || 0
|
||||||
|
};
|
||||||
|
const calls = callsByMessageUuid[msg.uuid];
|
||||||
|
if (calls && calls.length > 0) assembled.tool_calls = calls;
|
||||||
|
return assembled;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same merging logic as session detail
|
||||||
|
const assembledMessages = [];
|
||||||
|
for (let i = 0; i < rawAssembled.length; i++) {
|
||||||
|
const msg = rawAssembled[i];
|
||||||
|
if (msg.content_type === 'tool_result') continue;
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||||
|
const thinkingParts = [msg.text || ''];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||||
|
thinkingParts.push(rawAssembled[j].text || '');
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||||
|
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||||
|
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||||
|
if (msg._thinking) merged._thinking = msg._thinking;
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < rawAssembled.length) {
|
||||||
|
const next = rawAssembled[j];
|
||||||
|
if (next.content_type === 'tool_result') { j++; continue; }
|
||||||
|
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||||
|
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||||
|
if (next.text && !merged.text) merged.text = next.text;
|
||||||
|
j++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assembledMessages.push(merged);
|
||||||
|
i = j - 1;
|
||||||
|
} else {
|
||||||
|
const out = { ...msg };
|
||||||
|
if (msg._thinking) out._thinking = msg._thinking;
|
||||||
|
assembledMessages.push(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembledMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_LIMIT = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message text was truncated during indexing.
|
||||||
|
*/
|
||||||
|
export function isTextTruncated(text) {
|
||||||
|
return text && text.length >= TEXT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the full untruncated text for a message from its source JSONL.
|
||||||
|
* Returns the full text string or null.
|
||||||
|
*/
|
||||||
|
export async function loadFullText(uuid) {
|
||||||
|
try {
|
||||||
|
return await window.obelisk.getMessageFullText(uuid);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the markdown content of a memory file.
|
||||||
|
* Returns the content string or null on failure.
|
||||||
|
*/
|
||||||
|
export async function loadMemoryMarkdown(memoryPath) {
|
||||||
|
try {
|
||||||
|
const content = await window.obelisk.readMemoryFile(memoryPath);
|
||||||
|
return content || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive a memory by id. Updates state after successful IPC call.
|
||||||
|
*/
|
||||||
|
export async function archiveMemory(id) {
|
||||||
|
await window.obelisk.archiveMemory(id);
|
||||||
|
const mem = state.memories.find(m => m.id === id);
|
||||||
|
if (mem) {
|
||||||
|
mem.archived = true;
|
||||||
|
mem.archivedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore an archived memory by id. Updates state after successful IPC call.
|
||||||
|
*/
|
||||||
|
export async function restoreMemory(id) {
|
||||||
|
await window.obelisk.restoreMemory(id);
|
||||||
|
const mem = state.memories.find(m => m.id === id);
|
||||||
|
if (mem) {
|
||||||
|
mem.archived = false;
|
||||||
|
mem.archivedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Keyboard shortcut handling -- ported from the HTML mock.
|
||||||
|
// Registers a single document-level keydown listener that dispatches
|
||||||
|
// all navigation, mutation, and route-switching shortcuts.
|
||||||
|
|
||||||
|
import { state, IS_MAC } from './state.js';
|
||||||
|
import {
|
||||||
|
renderAll,
|
||||||
|
renderMemoryList,
|
||||||
|
renderSessionList,
|
||||||
|
renderStatus,
|
||||||
|
enterDetail,
|
||||||
|
exitDetail,
|
||||||
|
setRoute,
|
||||||
|
setView,
|
||||||
|
toggleSort,
|
||||||
|
moveCursor,
|
||||||
|
archive,
|
||||||
|
restore,
|
||||||
|
doUndo,
|
||||||
|
navigateToSession
|
||||||
|
} from './render.js';
|
||||||
|
|
||||||
|
export function initKeyboard() {
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
const inInput =
|
||||||
|
document.activeElement.tagName === 'INPUT' ||
|
||||||
|
document.activeElement.tagName === 'TEXTAREA';
|
||||||
|
const mod = IS_MAC ? e.metaKey : e.ctrlKey;
|
||||||
|
|
||||||
|
// -- Route switching: Cmd+1/2/3/4 --
|
||||||
|
if (mod && e.key === '1') { e.preventDefault(); setRoute('sessions'); return; }
|
||||||
|
if (mod && e.key === '2') { e.preventDefault(); setView('active'); return; }
|
||||||
|
if (mod && e.key === '3') { e.preventDefault(); setView('archived'); return; }
|
||||||
|
if (mod && e.key === '4') { e.preventDefault(); setView('broken'); return; }
|
||||||
|
|
||||||
|
// -- Undo: Cmd+Z --
|
||||||
|
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
||||||
|
if (state.lastArchiveSnapshot) { e.preventDefault(); doUndo(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- When inside an input, only Escape is handled (to blur) --
|
||||||
|
if (inInput) {
|
||||||
|
if (e.key === 'Escape') e.target.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Search focus: / --
|
||||||
|
if (e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
if (searchInput) { searchInput.focus(); searchInput.select(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Detail mode shortcuts --
|
||||||
|
if (state.mode === 'detail') {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); exitDetail(); return; }
|
||||||
|
if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const m = state.memories.find(x => x.id === state.detailId);
|
||||||
|
if (m && m.archived) restore([m.id]);
|
||||||
|
else if (m) archive([m.id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- List mode shortcuts --
|
||||||
|
|
||||||
|
// Navigation: j/k/arrows
|
||||||
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.route === 'memory') moveCursor(1, e.shiftKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.route === 'memory') moveCursor(-1, e.shiftKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open detail: Enter
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.route === 'memory' && state.cursorId) enterDetail(state.cursorId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive / Restore: d/D
|
||||||
|
if (state.route === 'memory' && (e.key === 'd' || e.key === 'D')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const ids = state.selection.size > 0
|
||||||
|
? Array.from(state.selection)
|
||||||
|
: state.cursorId ? [state.cursorId] : [];
|
||||||
|
if (!ids.length) return;
|
||||||
|
const m = state.memories.find(x => x.id === ids[0]);
|
||||||
|
if (state.view === 'archived' || (m && m.archived)) restore(ids);
|
||||||
|
else archive(ids);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo: u
|
||||||
|
if (e.key === 'u') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.lastArchiveSnapshot) doUndo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort toggle: s
|
||||||
|
if (e.key === 's') { e.preventDefault(); toggleSort(); return; }
|
||||||
|
|
||||||
|
// Escape: clear selection or search
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (state.selection.size) {
|
||||||
|
state.selection.clear();
|
||||||
|
renderMemoryList();
|
||||||
|
renderStatus();
|
||||||
|
} else if (state.query) {
|
||||||
|
state.query = '';
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
if (state.route === 'sessions') renderSessionList();
|
||||||
|
else renderMemoryList();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// Memory list and detail rendering, extracted from render.js.
|
||||||
|
|
||||||
|
import { state, FOLDER_SVG } from './state.js';
|
||||||
|
import { loadMemoryMarkdown, isTextTruncated, loadFullText } from './data.js';
|
||||||
|
import registry from './registry.js';
|
||||||
|
|
||||||
|
// --- Utilities (local copies to avoid importing render.js) ---
|
||||||
|
|
||||||
|
function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
|
||||||
|
function pad2(n) { return String(n).padStart(2, '0'); }
|
||||||
|
function isSameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
function fmtListTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
if (isSameDay(d, now)) return hhmm;
|
||||||
|
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||||
|
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||||
|
}
|
||||||
|
function fmtRelative(ts) {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
const min = 60000, hr = 3600000, day = 86400000;
|
||||||
|
if (diff < 0) return 'in the future';
|
||||||
|
if (diff < min) return 'just now';
|
||||||
|
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||||
|
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||||
|
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||||
|
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||||
|
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightPlain(text, query) {
|
||||||
|
if (!query) return escapeHTML(text);
|
||||||
|
const safe = escapeHTML(text);
|
||||||
|
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return safe.replace(new RegExp(q, 'gi'), m => `<mark>${m}</mark>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMarkdown(html) {
|
||||||
|
return html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/\son\w+="[^"]*"/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightTextNodes(rootEl, query) {
|
||||||
|
if (!query) return;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null);
|
||||||
|
const nodes = [];
|
||||||
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const text = node.nodeValue;
|
||||||
|
if (!text) continue;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (!lower.includes(q)) continue;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let last = 0, i = lower.indexOf(q);
|
||||||
|
while (i !== -1) {
|
||||||
|
if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i)));
|
||||||
|
const mark = document.createElement('mark');
|
||||||
|
mark.textContent = text.slice(i, i + q.length);
|
||||||
|
frag.appendChild(mark);
|
||||||
|
last = i + q.length;
|
||||||
|
i = lower.indexOf(q, last);
|
||||||
|
}
|
||||||
|
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
|
||||||
|
node.parentNode.replaceChild(frag, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text, opts = {}) {
|
||||||
|
if (text == null) return '';
|
||||||
|
const html = sanitizeMarkdown(marked.parse(text));
|
||||||
|
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
||||||
|
: opts.variant === 'compact' ? 'markdown-compact'
|
||||||
|
: 'markdown-body';
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = cls;
|
||||||
|
container.innerHTML = html;
|
||||||
|
if (opts.query) highlightTextNodes(container, opts.query.trim());
|
||||||
|
return container.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DOM helpers ---
|
||||||
|
|
||||||
|
const $ = sel => document.querySelector(sel);
|
||||||
|
|
||||||
|
function ensureVisible(el, wrapSel) {
|
||||||
|
const wrap = $(wrapSel);
|
||||||
|
if (!wrap || !el) return;
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
if (elRect.top < wrapRect.top + 30) wrap.scrollTop -= (wrapRect.top + 30 - elRect.top);
|
||||||
|
else if (elRect.bottom > wrapRect.bottom - 10) wrap.scrollTop += (elRect.bottom - wrapRect.bottom + 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data filtering (mirrors render.js) ---
|
||||||
|
|
||||||
|
function dominantRowStatus(m) {
|
||||||
|
if (m.health === 'broken') return 'broken';
|
||||||
|
if (m.health === 'partial') return 'partial';
|
||||||
|
if (m.archived) return 'archived';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusGlyphHTML(status) {
|
||||||
|
if (!status) return '';
|
||||||
|
const glyphs = {
|
||||||
|
broken: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
|
||||||
|
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
|
||||||
|
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
|
||||||
|
};
|
||||||
|
return `<span class="row-status ${status}" title="${status}">${glyphs[status] || ''}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProjectLabel(slug) {
|
||||||
|
if (!slug) return '(no project)';
|
||||||
|
const session = state.sessions.find(s => s.project === slug && s.project_path);
|
||||||
|
if (session?.project_path) {
|
||||||
|
const parts = session.project_path.split('/');
|
||||||
|
return parts.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
return slug.replace(/^-/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleMemories() {
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
return state.memories
|
||||||
|
.filter(m => {
|
||||||
|
if (state.view === 'archived') return m.archived;
|
||||||
|
return !m.archived;
|
||||||
|
})
|
||||||
|
.filter(m => state.projectFilter === 'all' || m.project === state.projectFilter)
|
||||||
|
.filter(m => !q || (m.path || '').toLowerCase().includes(q) || (m.summary || '').toLowerCase().includes(q))
|
||||||
|
.sort((a, b) => state.sortDesc ? b.ts - a.ts : a.ts - b.ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Memory list ---
|
||||||
|
|
||||||
|
export function renderMemoryList() {
|
||||||
|
const items = visibleMemories();
|
||||||
|
const list = $('#list');
|
||||||
|
if (!list) return;
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = `<div class="empty">No memories${state.view === 'archived' ? ' archived' : ''} here.<span class="hint">${state.query ? 'Try a different search term.' : 'Press / to search.'}</span></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(m => renderMemoryRow(m)).join('');
|
||||||
|
if (state.cursorId) {
|
||||||
|
const cursorEl = list.querySelector(`.row[data-id="${state.cursorId}"]`);
|
||||||
|
if (cursorEl) ensureVisible(cursorEl, '#list-wrap');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMemoryRow(m) {
|
||||||
|
const isCursor = state.cursorId === m.id;
|
||||||
|
const isSelected = state.selection.has(m.id);
|
||||||
|
const q = state.query.trim();
|
||||||
|
const showProjectPrefix = state.projectFilter === 'all';
|
||||||
|
const status = dominantRowStatus(m);
|
||||||
|
const actionLabel = m.archived
|
||||||
|
? `<button class="row-action restore" data-action="restore">Restore<span class="kbd">D</span></button>`
|
||||||
|
: `<button class="row-action danger" data-action="archive">Archive<span class="kbd">D</span></button>`;
|
||||||
|
return `
|
||||||
|
<div class="row ${isCursor ? 'cursor' : ''} ${isSelected ? 'selected' : ''} ${m.archived ? 'archived' : ''}" data-id="${m.id}">
|
||||||
|
<button class="row-checkbox ${isSelected ? 'checked' : ''}" data-action="check" aria-label="Select">
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M2.5 6.5l2.5 2.5 4.5-5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="row-body">
|
||||||
|
<div class="row-path">
|
||||||
|
${statusGlyphHTML(status)}
|
||||||
|
${showProjectPrefix ? `<span class="project-prefix">${escapeHTML(formatProjectLabel(m.project))}</span><span class="project-prefix-sep">/</span>` : ''}
|
||||||
|
<span class="path-text">${highlightPlain(m.path || '', q)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-summary">${highlightPlain(m.summary || '', q)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-right">
|
||||||
|
<div class="row-meta"><span>${fmtListTime(m.ts)}</span></div>
|
||||||
|
<div class="row-actions">${actionLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Memory detail ---
|
||||||
|
|
||||||
|
export async function renderMemoryDetail() {
|
||||||
|
const m = state.memories.find(x => x.id === state.detailId);
|
||||||
|
if (!m) return;
|
||||||
|
const detail = $('#detail');
|
||||||
|
if (!detail) return;
|
||||||
|
|
||||||
|
// Load markdown on demand
|
||||||
|
if (m.markdown === null && m.path) {
|
||||||
|
m.markdown = await loadMemoryMarkdown(m.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provenanceHTML = `
|
||||||
|
<div class="detail-meta">
|
||||||
|
${m.session_id ? `<button class="session-link" data-action="open-session" data-session="${m.session_id}">
|
||||||
|
${FOLDER_SVG}<span>Source session</span>
|
||||||
|
</button><span class="dot"></span>` : ''}
|
||||||
|
<span>${fmtRelative(m.ts)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let markdownHTML;
|
||||||
|
if (m.markdown == null) {
|
||||||
|
markdownHTML = `<div style="color:var(--muted-2);font-style:italic;padding:20px;text-align:center;border:1px dashed var(--hairline);border-radius:6px;">File not found or empty.</div>`;
|
||||||
|
} else if (state.showSource) {
|
||||||
|
markdownHTML = `<pre class="markdown-source">${escapeHTML(m.markdown)}</pre>`;
|
||||||
|
} else {
|
||||||
|
markdownHTML = renderMarkdown(m.markdown, { variant: 'body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
detail.innerHTML = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-eyebrow">
|
||||||
|
<span class="project-icon">${FOLDER_SVG}</span>
|
||||||
|
<span class="project-name">${escapeHTML(formatProjectLabel(m.project))}</span>
|
||||||
|
${m.archived ? '<span class="archived-tag">archived</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="detail-path">${escapeHTML(m.path)}</div>
|
||||||
|
<div class="detail-summary">${escapeHTML(m.summary)}</div>
|
||||||
|
${provenanceHTML}
|
||||||
|
</div>
|
||||||
|
<div class="markdown-section">
|
||||||
|
<div class="markdown-toolbar">
|
||||||
|
<span class="markdown-toolbar-label">Body</span>
|
||||||
|
<button class="source-toggle ${state.showSource ? 'active' : ''}" data-action="toggle-source" ${m.markdown == null ? 'disabled' : ''}>
|
||||||
|
${state.showSource ? 'Show rendered' : 'Show source'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${markdownHTML}
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="btn" id="detail-back">Back<span class="kbd">Esc</span></button>
|
||||||
|
<button class="btn ${m.archived ? 'primary' : 'danger'}" id="detail-archive">
|
||||||
|
${m.archived ? 'Restore' : 'Archive'}<span class="kbd">D</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire event listeners via registry (avoids circular imports)
|
||||||
|
$('#detail-back')?.addEventListener('click', () => {
|
||||||
|
if (registry.exitDetail) registry.exitDetail();
|
||||||
|
});
|
||||||
|
$('#detail-archive')?.addEventListener('click', () => {
|
||||||
|
if (m.archived) { if (registry.restore) registry.restore([m.id]); }
|
||||||
|
else { if (registry.archive) registry.archive([m.id]); }
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('[data-action]').forEach(el => {
|
||||||
|
el.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (el.dataset.action === 'toggle-source') {
|
||||||
|
state.showSource = !state.showSource;
|
||||||
|
renderMemoryDetail();
|
||||||
|
} else if (el.dataset.action === 'open-session' && el.dataset.session) {
|
||||||
|
if (registry.navigateToSession) registry.navigateToSession(el.dataset.session, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Shared registry to break circular dependencies between modules.
|
||||||
|
const registry = {};
|
||||||
|
export default registry;
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Rendering coordinator -- thin orchestration layer.
|
||||||
|
// Delegates to extracted modules; keeps only cross-module functions locally.
|
||||||
|
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { archiveMemory, restoreMemory } from './data.js';
|
||||||
|
import registry from './registry.js';
|
||||||
|
|
||||||
|
// --- Module imports ---
|
||||||
|
import { escapeHTML, $ } from './utils.js';
|
||||||
|
import { renderSidebar, renderBreadcrumb, updateWindowTitle } from './sidebar.js';
|
||||||
|
import { visibleMemories as _visibleMemories, renderMemoryList, renderMemoryDetail } from './memory-list.js';
|
||||||
|
import { renderSessionList, renderSessionDetail } from './session-list.js';
|
||||||
|
import { renderUsage } from './usage.js';
|
||||||
|
|
||||||
|
// --- Data filtering (coordinator owns the cross-module view) ---
|
||||||
|
|
||||||
|
export function visibleMemories() { return _visibleMemories(); }
|
||||||
|
|
||||||
|
export function visibleSessions() {
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
return state.sessions
|
||||||
|
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
|
||||||
|
.map(s => {
|
||||||
|
if (!q) return { ...s, messageHit: null };
|
||||||
|
const topMatch = (s.title || '').toLowerCase().includes(q) ||
|
||||||
|
(s.project || '').toLowerCase().includes(q) ||
|
||||||
|
(s.git_branch || '').toLowerCase().includes(q);
|
||||||
|
if (topMatch) return { ...s, messageHit: null };
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ta = new Date(a.started_at || 0).getTime();
|
||||||
|
const tb = new Date(b.started_at || 0).getTime();
|
||||||
|
return state.sortDesc ? tb - ta : ta - tb;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cursor / selection ---
|
||||||
|
|
||||||
|
export function flatList() { return visibleMemories(); }
|
||||||
|
|
||||||
|
export function cursorIndex() {
|
||||||
|
const flat = flatList();
|
||||||
|
if (!state.cursorId) return -1;
|
||||||
|
return flat.findIndex(m => m.id === state.cursorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveCursor(delta, extendSelection = false) {
|
||||||
|
const flat = flatList();
|
||||||
|
if (!flat.length) return;
|
||||||
|
let idx = cursorIndex();
|
||||||
|
if (idx === -1) idx = 0;
|
||||||
|
else idx = Math.max(0, Math.min(flat.length - 1, idx + delta));
|
||||||
|
const newId = flat[idx].id;
|
||||||
|
if (extendSelection) { state.selection.add(state.cursorId); state.selection.add(newId); }
|
||||||
|
state.cursorId = newId;
|
||||||
|
renderMemoryList(); renderStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCursor(id, opts = {}) {
|
||||||
|
state.cursorId = id;
|
||||||
|
if (!opts.keepSelection) state.selection.clear();
|
||||||
|
renderMemoryList(); renderStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mutations ---
|
||||||
|
|
||||||
|
export function archive(ids) { if (!ids.length) return; doMutation(ids, true); }
|
||||||
|
export function restore(ids) { if (!ids.length) return; doMutation(ids, false); }
|
||||||
|
|
||||||
|
async function doMutation(ids, toArchived) {
|
||||||
|
for (const id of ids) {
|
||||||
|
if (toArchived) await archiveMemory(id);
|
||||||
|
else await restoreMemory(id);
|
||||||
|
}
|
||||||
|
state.selection = new Set();
|
||||||
|
if (state.mode === 'detail' && state.route === 'memory' && ids.includes(state.detailId)) exitDetail();
|
||||||
|
const flat = flatList();
|
||||||
|
if (state.cursorId && !flat.find(m => m.id === state.cursorId)) state.cursorId = flat[0]?.id ?? null;
|
||||||
|
state.lastArchiveSnapshot = ids;
|
||||||
|
state.undoExpires = Date.now() + 5000;
|
||||||
|
clearInterval(state.undoTimer);
|
||||||
|
state.undoTimer = setInterval(() => {
|
||||||
|
if (Date.now() >= state.undoExpires) { state.lastArchiveSnapshot = null; clearInterval(state.undoTimer); }
|
||||||
|
renderStatus();
|
||||||
|
}, 500);
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doUndo() {
|
||||||
|
if (!state.lastArchiveSnapshot) return;
|
||||||
|
for (const id of state.lastArchiveSnapshot) {
|
||||||
|
const m = state.memories.find(x => x.id === id);
|
||||||
|
if (m) {
|
||||||
|
if (m.archived) await restoreMemory(id);
|
||||||
|
else await archiveMemory(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lastArchiveSnapshot = null;
|
||||||
|
clearInterval(state.undoTimer);
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation ---
|
||||||
|
|
||||||
|
export function navigateToSession(sessionId, focusUuid) {
|
||||||
|
state.route = 'sessions'; state.mode = 'detail';
|
||||||
|
state.detailId = sessionId;
|
||||||
|
state.subagentId = null;
|
||||||
|
state.subagentDescription = null;
|
||||||
|
state.pendingFocusUuid = focusUuid || null;
|
||||||
|
state.query = '';
|
||||||
|
const searchEl = $('#search');
|
||||||
|
if (searchEl) searchEl.value = '';
|
||||||
|
switchView(); renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateToSubagent(agentId, description) {
|
||||||
|
state.subagentId = agentId;
|
||||||
|
state.subagentDescription = description || agentId;
|
||||||
|
switchView(); renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enterDetail(id) { state.detailId = id; state.mode = 'detail'; state.showSource = false; switchView(); renderAll(); }
|
||||||
|
|
||||||
|
export function exitDetail() {
|
||||||
|
if (state.subagentId) {
|
||||||
|
state.subagentId = null;
|
||||||
|
state.subagentDescription = null;
|
||||||
|
switchView(); renderAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.mode = 'list'; state.detailId = null; state.pendingFocusUuid = null; switchView(); renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRoute(route) {
|
||||||
|
state.route = route; state.mode = 'list'; state.detailId = null;
|
||||||
|
state.cursorId = null; state.selection.clear();
|
||||||
|
state.query = ''; const s = $('#search'); if (s) s.value = '';
|
||||||
|
switchView(); renderAll();
|
||||||
|
if (route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setView(v) {
|
||||||
|
state.route = 'memory'; state.view = v; state.mode = 'list'; state.detailId = null;
|
||||||
|
state.cursorId = null; state.selection.clear(); state.projectFilter = 'all';
|
||||||
|
switchView(); renderAll();
|
||||||
|
const flat = visibleMemories();
|
||||||
|
if (flat.length) state.cursorId = flat[0].id;
|
||||||
|
renderMemoryList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setProject(p) {
|
||||||
|
state.projectFilter = p; state.cursorId = null; state.selection.clear();
|
||||||
|
state.mode = 'list'; state.detailId = null;
|
||||||
|
switchView(); renderAll();
|
||||||
|
if (state.route === 'memory') { const flat = visibleMemories(); if (flat.length) state.cursorId = flat[0].id; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSort() {
|
||||||
|
state.sortDesc = !state.sortDesc;
|
||||||
|
const btn = $('#sort-toggle');
|
||||||
|
if (btn) { btn.classList.toggle('desc', state.sortDesc); btn.classList.toggle('asc', !state.sortDesc); }
|
||||||
|
const lbl = $('#sort-label');
|
||||||
|
if (lbl) lbl.textContent = state.sortDesc ? 'newest' : 'oldest';
|
||||||
|
if (state.route === 'sessions') renderSessionList();
|
||||||
|
else renderMemoryList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function switchView() {
|
||||||
|
const showList = state.mode === 'list';
|
||||||
|
const showSessions = state.route === 'sessions';
|
||||||
|
const showUsage = state.route === 'usage';
|
||||||
|
const inSessionDetail = !showList && showSessions;
|
||||||
|
const inSubagent = inSessionDetail && !!state.subagentId;
|
||||||
|
const el = (id, show) => { const e = $(id); if (e) e.style.display = show ? '' : 'none'; };
|
||||||
|
el('#list-wrap', showList && !showSessions && !showUsage);
|
||||||
|
el('#detail-wrap', !showList && !showSessions && !showUsage);
|
||||||
|
el('#session-list-wrap', showList && showSessions);
|
||||||
|
el('#session-detail-wrap', inSessionDetail && !inSubagent);
|
||||||
|
el('#subagent-detail-wrap', inSubagent);
|
||||||
|
el('#usage-wrap', showUsage);
|
||||||
|
el('#search-wrap', showList && !showUsage);
|
||||||
|
el('#sort-toggle', showList && !showUsage);
|
||||||
|
el('#search-msgs-toggle', showList && showSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Status bar ---
|
||||||
|
|
||||||
|
export function renderStatus() {
|
||||||
|
const left = $('#status-left');
|
||||||
|
const right = $('#status-right');
|
||||||
|
if (!left || !right) return;
|
||||||
|
|
||||||
|
if (state.route === 'sessions' && state.mode === 'list') {
|
||||||
|
right.innerHTML = `<span class="kbd-hint"><span class="kbd">⏎</span> open</span><span class="kbd-hint secondary"><span class="kbd">/</span> search</span>`;
|
||||||
|
} else if (state.route === 'sessions' && state.mode === 'detail') {
|
||||||
|
right.innerHTML = `<span class="kbd-hint"><span class="kbd">Esc</span> back</span>`;
|
||||||
|
} else if (state.mode === 'detail') {
|
||||||
|
right.innerHTML = `<span class="kbd-hint"><span class="kbd">Esc</span> back</span><span class="kbd-hint"><span class="kbd">D</span> archive</span>`;
|
||||||
|
} else {
|
||||||
|
right.innerHTML = `<span class="kbd-hint"><span class="kbd">↑↓</span> nav</span><span class="kbd-hint"><span class="kbd">⏎</span> open</span><span class="kbd-hint secondary"><span class="kbd">D</span> archive</span><span class="kbd-hint secondary"><span class="kbd">/</span> search</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.lastArchiveSnapshot && state.undoExpires > Date.now()) {
|
||||||
|
const ids = state.lastArchiveSnapshot;
|
||||||
|
const secs = Math.ceil((state.undoExpires - Date.now()) / 1000);
|
||||||
|
const target = ids.length === 1 ? (state.memories.find(x => x.id === ids[0])?.path || '').split('/').pop() : `${ids.length} memories`;
|
||||||
|
left.innerHTML = `<span class="status-pending">Action pending <strong>${escapeHTML(target)}</strong><button class="undo-btn" id="undo-btn">Undo</button><span class="timer">${secs}s</span></span>`;
|
||||||
|
$('#undo-btn')?.addEventListener('click', doUndo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
left.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Master render ---
|
||||||
|
|
||||||
|
export function renderAll() {
|
||||||
|
renderSidebar();
|
||||||
|
renderBreadcrumb();
|
||||||
|
switchView();
|
||||||
|
if (state.route === 'usage') {
|
||||||
|
renderUsage();
|
||||||
|
} else if (state.route === 'sessions') {
|
||||||
|
if (state.mode === 'list') renderSessionList();
|
||||||
|
else renderSessionDetail();
|
||||||
|
} else {
|
||||||
|
if (state.mode === 'list') renderMemoryList();
|
||||||
|
else renderMemoryDetail();
|
||||||
|
}
|
||||||
|
renderStatus();
|
||||||
|
updateWindowTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Registry (break circular deps for child modules) ---
|
||||||
|
|
||||||
|
registry.navigateToSession = navigateToSession;
|
||||||
|
registry.navigateToSubagent = navigateToSubagent;
|
||||||
|
registry.exitDetail = exitDetail;
|
||||||
|
registry.archive = archive;
|
||||||
|
registry.restore = restore;
|
||||||
|
|
||||||
|
// --- Re-exports for app.js and keys.js ---
|
||||||
|
|
||||||
|
export { renderMemoryList, renderSessionList, escapeHTML };
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
// Session list and detail rendering module.
|
||||||
|
// Extracted from render.js -- all session/subagent DOM generation.
|
||||||
|
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { loadSessionDetail, loadSubagentDetail, isTextTruncated, loadFullText } from './data.js';
|
||||||
|
import { escapeHTML, highlightPlain, fmtListTime, fmtRelative, fmtClockTime, renderMarkdown, formatProjectLabel, $ } from './utils.js';
|
||||||
|
import { FOLDER_SVG } from './state.js';
|
||||||
|
import registry from './registry.js';
|
||||||
|
|
||||||
|
// --- Session list ---
|
||||||
|
|
||||||
|
export function renderSessionList() {
|
||||||
|
const items = visibleSessions();
|
||||||
|
const list = $('#session-list');
|
||||||
|
if (!list) return;
|
||||||
|
if (!items.length) {
|
||||||
|
list.innerHTML = `<div class="empty">No sessions here.<span class="hint">${state.query ? 'Try a different search term.' : 'Press / to search.'}</span></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(s => renderSessionRow(s)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionRow(s) {
|
||||||
|
const q = state.query.trim();
|
||||||
|
const showProjectPrefix = state.projectFilter === 'all';
|
||||||
|
const startedTs = new Date(s.started_at || 0).getTime();
|
||||||
|
return `
|
||||||
|
<div class="srow ${state.cursorId === s.id ? 'cursor' : ''}" data-session-id="${s.id}">
|
||||||
|
<div class="srow-body">
|
||||||
|
<div class="srow-title">${highlightPlain(s.title || '(untitled)', q)}</div>
|
||||||
|
<div class="srow-meta">
|
||||||
|
${showProjectPrefix ? `<span class="project-tag">${escapeHTML(formatProjectLabel(s.project))}</span><span class="dot"></span>` : ''}
|
||||||
|
<span>${s.message_count || 0} msg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="srow-right">${fmtListTime(startedTs)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session detail ---
|
||||||
|
|
||||||
|
export async function renderSessionDetail() {
|
||||||
|
// If viewing a subagent, render that instead
|
||||||
|
if (state.subagentId) {
|
||||||
|
return renderSubagentDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = state.sessions.find(x => x.id === state.detailId);
|
||||||
|
if (!s) return;
|
||||||
|
const detail = $('#session-detail');
|
||||||
|
if (!detail) return;
|
||||||
|
const wrap = $('#session-detail-wrap');
|
||||||
|
|
||||||
|
// If DOM was already built for this session, skip rebuild
|
||||||
|
if (detail.dataset.renderedSession === state.detailId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load messages on demand
|
||||||
|
if (!s.messages || s.messages.length === 0) {
|
||||||
|
const loaded = await loadSessionDetail(s.id);
|
||||||
|
if (loaded) Object.assign(s, loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedTs = new Date(s.started_at || 0).getTime();
|
||||||
|
const headerHTML = `
|
||||||
|
<div class="session-header">
|
||||||
|
<div class="session-eyebrow">
|
||||||
|
<span class="project-icon">${FOLDER_SVG}</span>
|
||||||
|
<span class="project-name">${escapeHTML(formatProjectLabel(s.project))}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span class="project-path">${escapeHTML(s.project_path || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-title">${escapeHTML(s.title || '(untitled)')}</div>
|
||||||
|
<div class="session-meta-inline">
|
||||||
|
<span>${fmtRelative(startedTs)}</span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>${s.message_count || 0} messages</span>
|
||||||
|
${s.git_branch ? `<span class="dot"></span><span>${escapeHTML(s.git_branch)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messagesHTML = (s.messages || []).map((msg, idx) => renderMessage(msg, idx)).join('');
|
||||||
|
detail.innerHTML = `<div class="session-progress"><div class="session-progress-fill" id="session-progress-fill"></div></div>${headerHTML}<div class="timeline">${messagesHTML}</div>`;
|
||||||
|
detail.dataset.renderedSession = state.detailId;
|
||||||
|
|
||||||
|
// Progress bar: track scroll position relative to messages
|
||||||
|
const progressFill = detail.querySelector('#session-progress-fill');
|
||||||
|
if (wrap && progressFill) {
|
||||||
|
const updateProgress = () => {
|
||||||
|
const msgs = detail.querySelectorAll('.msg, .wf-card');
|
||||||
|
if (!msgs.length) return;
|
||||||
|
const wrapTop = wrap.getBoundingClientRect().top;
|
||||||
|
let topMsgIdx = 0;
|
||||||
|
for (let i = 0; i < msgs.length; i++) {
|
||||||
|
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
||||||
|
progressFill.style.width = pct + '%';
|
||||||
|
|
||||||
|
// Show/hide back-to-top button
|
||||||
|
const topBtn = detail.querySelector('#back-to-top');
|
||||||
|
if (topBtn) topBtn.classList.toggle('show', wrap.scrollTop > 300);
|
||||||
|
};
|
||||||
|
wrap.addEventListener('scroll', updateProgress);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to top button
|
||||||
|
const topBtn = document.createElement('button');
|
||||||
|
topBtn.id = 'back-to-top';
|
||||||
|
topBtn.className = 'back-to-top';
|
||||||
|
topBtn.innerHTML = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg>`;
|
||||||
|
topBtn.addEventListener('click', () => { if (wrap) wrap.scrollTo({ top: 0, behavior: 'smooth' }); });
|
||||||
|
detail.appendChild(topBtn);
|
||||||
|
|
||||||
|
// Wire up tool call toggles
|
||||||
|
detail.querySelectorAll('.toolcall-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-tool').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.summary-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-summary').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.thinking-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-thinking').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.meta-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-meta-collapsed').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.truncated-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const uuid = btn.dataset.uuid;
|
||||||
|
btn.textContent = 'Loading…';
|
||||||
|
const fullText = await loadFullText(uuid);
|
||||||
|
if (fullText) {
|
||||||
|
const msgEl = btn.closest('.msg');
|
||||||
|
const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact');
|
||||||
|
const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg';
|
||||||
|
const rendered = renderMarkdown(fullText, { variant, query: state.query });
|
||||||
|
if (bodyEl) bodyEl.outerHTML = rendered;
|
||||||
|
btn.remove();
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Failed to load full text';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subagent navigation
|
||||||
|
detail.querySelectorAll('[data-action="open-subagent"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
registry.navigateToSubagent(btn.dataset.agentId, btn.dataset.agentDesc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus on pending message
|
||||||
|
if (state.pendingFocusUuid) {
|
||||||
|
const targetUuid = state.pendingFocusUuid;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const target = detail.querySelector(`.msg[data-uuid="${targetUuid}"]`);
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
target.classList.add('is-focused');
|
||||||
|
setTimeout(() => target.classList.remove('is-focused'), 1200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
state.pendingFocusUuid = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSubagentDetail() {
|
||||||
|
const detail = $('#subagent-detail');
|
||||||
|
if (!detail) return;
|
||||||
|
const wrap = $('#subagent-detail-wrap');
|
||||||
|
if (wrap) wrap.scrollTop = 0;
|
||||||
|
|
||||||
|
const messages = await loadSubagentDetail(state.subagentId);
|
||||||
|
|
||||||
|
const headerHTML = `
|
||||||
|
<div class="session-header">
|
||||||
|
<div class="session-eyebrow">
|
||||||
|
<span class="meta-label" style="font-size:11px;">SUBAGENT</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-title">${escapeHTML(state.subagentDescription || state.subagentId)}</div>
|
||||||
|
<div class="session-meta-inline">
|
||||||
|
<span>${messages.length} messages</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messagesHTML = messages.map((msg, idx) => {
|
||||||
|
return renderMessage(msg, idx, { isSubagent: true });
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
detail.innerHTML = `<div class="session-progress"><div class="session-progress-fill" id="session-progress-fill"></div></div>${headerHTML}<div class="timeline">${messagesHTML}</div>`;
|
||||||
|
|
||||||
|
// Wire up toggles
|
||||||
|
detail.querySelectorAll('.toolcall-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-tool').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.summary-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-summary').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.thinking-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-thinking').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.meta-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); btn.closest('.msg-meta-collapsed').classList.toggle('open'); });
|
||||||
|
});
|
||||||
|
detail.querySelectorAll('.truncated-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const uuid = btn.dataset.uuid;
|
||||||
|
btn.textContent = 'Loading…';
|
||||||
|
const fullText = await loadFullText(uuid);
|
||||||
|
if (fullText) {
|
||||||
|
const msgEl = btn.closest('.msg');
|
||||||
|
const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact');
|
||||||
|
const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg';
|
||||||
|
const rendered = renderMarkdown(fullText, { variant, query: state.query });
|
||||||
|
if (bodyEl) bodyEl.outerHTML = rendered;
|
||||||
|
btn.remove();
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Failed to load full text';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Nested subagent navigation
|
||||||
|
detail.querySelectorAll('[data-action="open-subagent"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
registry.navigateToSubagent(btn.dataset.agentId, btn.dataset.agentDesc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
const progressFill = detail.querySelector('#session-progress-fill');
|
||||||
|
if (wrap && progressFill) {
|
||||||
|
const updateProgress = () => {
|
||||||
|
const msgs = detail.querySelectorAll('.msg');
|
||||||
|
if (!msgs.length) return;
|
||||||
|
const wrapTop = wrap.getBoundingClientRect().top;
|
||||||
|
let topMsgIdx = 0;
|
||||||
|
for (let i = 0; i < msgs.length; i++) {
|
||||||
|
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
||||||
|
progressFill.style.width = pct + '%';
|
||||||
|
};
|
||||||
|
wrap.addEventListener('scroll', updateProgress);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessage(msg, idx, opts = {}) {
|
||||||
|
const isUser = msg.type === 'user';
|
||||||
|
const isThinking = msg.content_type === 'thinking';
|
||||||
|
const isMeta = msg.is_meta === 1;
|
||||||
|
const tools = (msg.tool_calls || []).map(renderToolCall).join('');
|
||||||
|
|
||||||
|
// In subagent context, all user text messages are prompts (from main agent or human)
|
||||||
|
let roleLabel = isUser ? 'You' : 'Assistant';
|
||||||
|
if (opts.isSubagent && isUser) {
|
||||||
|
roleLabel = 'Prompt';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta messages: collapsed by default, shown as a small system indicator
|
||||||
|
if (isMeta) {
|
||||||
|
const preview = (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80);
|
||||||
|
const truncated = isTextTruncated(msg.text);
|
||||||
|
return `
|
||||||
|
<div class="msg meta" data-uuid="${msg.uuid}">
|
||||||
|
<div class="msg-meta-collapsed">
|
||||||
|
<button class="meta-toggle">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="meta-label">System</span>
|
||||||
|
<span class="meta-preview">${escapeHTML(preview)}</span>
|
||||||
|
</button>
|
||||||
|
<div class="meta-body">
|
||||||
|
${renderMarkdown(msg.text, { variant: 'compact', query: state.query })}
|
||||||
|
${truncated ? `<button class="truncated-btn" data-action="load-full" data-uuid="${msg.uuid}">Message truncated — click to load full text</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workflow as standalone card (not inside assistant bubble)
|
||||||
|
const workflowCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow);
|
||||||
|
if (workflowCall && !isUser) {
|
||||||
|
const wf = workflowCall.workflow;
|
||||||
|
const wfName = wf.workflow_name || 'Workflow';
|
||||||
|
const agents = wf.agents || [];
|
||||||
|
|
||||||
|
const phases = {};
|
||||||
|
for (const a of agents) {
|
||||||
|
const phase = a.phase || 'Other';
|
||||||
|
if (!phases[phase]) phases[phase] = [];
|
||||||
|
phases[phase].push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phasesHTML = Object.entries(phases).map(([phase, agentList]) => `
|
||||||
|
<div class="wf-card-phase">
|
||||||
|
<div class="wf-card-phase-title">${escapeHTML(phase)}</div>
|
||||||
|
${agentList.map(a => `
|
||||||
|
<button class="wf-card-agent" data-action="open-subagent" data-agent-id="${a.agent_id}" data-agent-desc="${escapeHTML(a.label || '')}">
|
||||||
|
<span class="wf-card-agent-label">${escapeHTML(a.label || a.agent_id)}</span>
|
||||||
|
${a.state === 'error' ? `<span class="wf-card-agent-state error">error</span>` : ''}
|
||||||
|
<span class="wf-card-agent-arrow">→</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Render other tool calls (non-workflow) if any
|
||||||
|
const otherTools = (msg.tool_calls || []).filter(tc => tc !== workflowCall).map(renderToolCall).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="wf-card" data-uuid="${msg.uuid}">
|
||||||
|
<div class="wf-card-header">
|
||||||
|
<span class="wf-card-icon">⚙</span>
|
||||||
|
<span class="wf-card-name">${escapeHTML(wfName)}</span>
|
||||||
|
<span class="wf-card-count">${agents.length} agents</span>
|
||||||
|
${wf.status ? `<span class="wf-card-status ${wf.status}">${escapeHTML(wf.status)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="wf-card-body">${phasesHTML}</div>
|
||||||
|
</div>
|
||||||
|
${otherTools ? `<div class="msg assistant" data-uuid="${msg.uuid}-tools"><div class="msg-tools">${otherTools}</div></div>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone thinking message (no following assistant to attach to)
|
||||||
|
if (isThinking) {
|
||||||
|
return `
|
||||||
|
<div class="msg assistant" data-uuid="${msg.uuid}">
|
||||||
|
<div class="msg-thinking">
|
||||||
|
<button class="thinking-toggle">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="thinking-label">Thinking</span>
|
||||||
|
</button>
|
||||||
|
<div class="thinking-body">${renderMarkdown(msg.text, { variant: 'msg', query: state.query })}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thinking block attached to this message (merged from preceding thinking messages)
|
||||||
|
let thinkingHTML = '';
|
||||||
|
if (msg._thinking) {
|
||||||
|
thinkingHTML = `
|
||||||
|
<div class="msg-thinking">
|
||||||
|
<button class="thinking-toggle">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="thinking-label">Thinking</span>
|
||||||
|
</button>
|
||||||
|
<div class="thinking-body">${renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = isTextTruncated(msg.text);
|
||||||
|
let textHTML = msg.text ? renderMarkdown(msg.text, { variant: 'msg', query: state.query }) : (tools ? '' : '<div class="msg-text empty-text">(no text content)</div>');
|
||||||
|
if (truncated) {
|
||||||
|
textHTML += `<button class="truncated-btn" data-action="load-full" data-uuid="${msg.uuid}">Message truncated — click to load full text</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaryHTML = '';
|
||||||
|
if (msg.summary) {
|
||||||
|
summaryHTML = `
|
||||||
|
<div class="msg-summary">
|
||||||
|
<button class="summary-toggle">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="label">Session summary</span>
|
||||||
|
<span class="source">${escapeHTML(msg.summary.source || '')}</span>
|
||||||
|
</button>
|
||||||
|
<div class="summary-body">${renderMarkdown(msg.summary.content, { variant: 'compact' })}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="msg ${isUser ? 'user' : 'assistant'}" data-uuid="${msg.uuid}">
|
||||||
|
<div class="msg-head">
|
||||||
|
<span class="role">${roleLabel}</span>
|
||||||
|
<span class="when">${msg.timestamp ? fmtClockTime(msg.timestamp) : ''}</span>
|
||||||
|
</div>
|
||||||
|
${thinkingHTML}
|
||||||
|
${textHTML}
|
||||||
|
${tools ? `<div class="msg-tools">${tools}</div>` : ''}
|
||||||
|
${summaryHTML}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToolCall(tc) {
|
||||||
|
const isError = tc.result && tc.result.is_error;
|
||||||
|
|
||||||
|
// Special rendering for Agent/Task tool calls (subagents)
|
||||||
|
if (tc.name === 'Agent' || tc.name === 'Task') {
|
||||||
|
let parsed = {};
|
||||||
|
try { parsed = JSON.parse(tc.input_json || '{}'); } catch {}
|
||||||
|
const agentType = parsed.subagent_type || parsed.agentType || 'Agent';
|
||||||
|
const description = parsed.description || parsed.prompt?.slice(0, 80) || '';
|
||||||
|
const resultContent = tc.result?.content || '';
|
||||||
|
const subagentId = tc.subagent?.agent_id || null;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="msg-tool agent-call">
|
||||||
|
<button class="toolcall-toggle">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="tool-name">${escapeHTML(agentType)}</span>
|
||||||
|
<span class="tool-arg">${escapeHTML(description)}</span>
|
||||||
|
${isError ? '<span class="tool-error">error</span>' : ''}
|
||||||
|
${subagentId ? `<button class="agent-nav-btn" data-action="open-subagent" data-agent-id="${subagentId}" data-agent-desc="${escapeHTML(description)}">View conversation →</button>` : ''}
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
${parsed.prompt ? `<div class="tc-section">Prompt</div><div class="agent-prompt">${escapeHTML(parsed.prompt.slice(0, 500))}${parsed.prompt.length > 500 ? '…' : ''}</div>` : ''}
|
||||||
|
${resultContent ? `<div class="tc-section">Result</div><div class="agent-result">${renderMarkdown(resultContent, { variant: 'compact' })}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special rendering for Workflow tool calls
|
||||||
|
if (tc.name === 'Workflow') {
|
||||||
|
let parsed = {};
|
||||||
|
try { parsed = JSON.parse(tc.input_json || '{}'); } catch {}
|
||||||
|
const wf = tc.workflow;
|
||||||
|
const wfName = wf?.workflow_name || parsed.name || 'Workflow';
|
||||||
|
const wfStatus = wf?.status || '';
|
||||||
|
const agents = wf?.agents || [];
|
||||||
|
|
||||||
|
// Group agents by phase
|
||||||
|
const phases = {};
|
||||||
|
for (const a of agents) {
|
||||||
|
const phase = a.phase || 'Other';
|
||||||
|
if (!phases[phase]) phases[phase] = [];
|
||||||
|
phases[phase].push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phasesHTML = Object.entries(phases).map(([phase, agentList]) => `
|
||||||
|
<div class="workflow-phase-group">
|
||||||
|
<div class="workflow-phase-header">${escapeHTML(phase)}</div>
|
||||||
|
<div class="workflow-phase-agents">
|
||||||
|
${agentList.map(a => `
|
||||||
|
<button class="workflow-agent-row" data-action="open-subagent" data-agent-id="${a.agent_id}" data-agent-desc="${escapeHTML(a.label || '')}">
|
||||||
|
<span class="workflow-agent-label">${escapeHTML(a.label || a.agent_id)}</span>
|
||||||
|
<span class="workflow-agent-state ${a.state || ''}">${escapeHTML(a.state || '')}</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const agentListHTML = agents.length ? `
|
||||||
|
<div class="tc-section">Agents · ${agents.length}</div>
|
||||||
|
<div class="workflow-agent-list">${phasesHTML}</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="msg-tool agent-call">
|
||||||
|
<button class="toolcall-toggle">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="tool-name">Workflow</span>
|
||||||
|
<span class="tool-arg">${escapeHTML(wfName)}</span>
|
||||||
|
${wfStatus ? `<span class="workflow-status ${wfStatus}">${escapeHTML(wfStatus)}</span>` : ''}
|
||||||
|
${isError ? '<span class="tool-error">error</span>' : ''}
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
${agentListHTML}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let argPreview = '';
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(tc.input_json || '{}');
|
||||||
|
if (j.file_path) argPreview = j.file_path;
|
||||||
|
else if (j.command) argPreview = j.command;
|
||||||
|
else if (j.path) argPreview = j.path;
|
||||||
|
else if (j.description) argPreview = j.description;
|
||||||
|
else argPreview = JSON.stringify(j).slice(0, 100);
|
||||||
|
} catch { argPreview = (tc.input_json || '').slice(0, 100); }
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="msg-tool ${isError ? 'is-error' : ''}">
|
||||||
|
<button class="toolcall-toggle">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="tool-name">${escapeHTML(tc.name)}</span>
|
||||||
|
<span class="tool-arg">${escapeHTML(argPreview)}</span>
|
||||||
|
${isError ? '<span class="tool-error">error</span>' : ''}
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
<div class="tc-section">Input</div>
|
||||||
|
<pre>${escapeHTML(tc.input_json || '')}</pre>
|
||||||
|
${tc.result ? `<div class="tc-section">${isError ? 'Error' : 'Output'}</div><pre>${escapeHTML(tc.result.content || '(empty)')}</pre>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private helper: visibleSessions (same logic as render.js) ---
|
||||||
|
|
||||||
|
function visibleSessions() {
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
return state.sessions
|
||||||
|
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
|
||||||
|
.map(s => {
|
||||||
|
if (!q) return { ...s, messageHit: null };
|
||||||
|
const topMatch = (s.title || '').toLowerCase().includes(q) ||
|
||||||
|
(s.project || '').toLowerCase().includes(q) ||
|
||||||
|
(s.git_branch || '').toLowerCase().includes(q);
|
||||||
|
if (topMatch) return { ...s, messageHit: null };
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ta = new Date(a.started_at || 0).getTime();
|
||||||
|
const tb = new Date(b.started_at || 0).getTime();
|
||||||
|
return state.sortDesc ? tb - ta : ta - tb;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Sidebar, breadcrumb, and window title rendering.
|
||||||
|
// Extracted from render.js for modularity.
|
||||||
|
|
||||||
|
import { state, FOLDER_SVG } from './state.js';
|
||||||
|
import { $, $$, escapeHTML, formatProjectLabel } from './utils.js';
|
||||||
|
|
||||||
|
// --- Data helpers (sidebar-local) ---
|
||||||
|
|
||||||
|
function projectCountsForCurrentRoute() {
|
||||||
|
const counts = {};
|
||||||
|
if (state.route === 'sessions') {
|
||||||
|
for (const s of state.sessions) if (s.project) counts[s.project] = (counts[s.project] || 0) + 1;
|
||||||
|
} else {
|
||||||
|
for (const m of state.memories) {
|
||||||
|
const matches = state.view === 'archived' ? m.archived : !m.archived;
|
||||||
|
if (!matches) continue;
|
||||||
|
if (m.project) counts[m.project] = (counts[m.project] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sidebar ---
|
||||||
|
|
||||||
|
export function renderSidebar() {
|
||||||
|
const activeCount = state.memories.filter(m => !m.archived).length;
|
||||||
|
const archivedCount = state.memories.filter(m => m.archived).length;
|
||||||
|
const el = id => $(id);
|
||||||
|
if (el('#count-sessions')) el('#count-sessions').textContent = state.sessions.length;
|
||||||
|
if (el('#count-memory-total')) el('#count-memory-total').textContent = activeCount + archivedCount;
|
||||||
|
if (el('#count-active')) el('#count-active').textContent = activeCount;
|
||||||
|
if (el('#count-archived')) el('#count-archived').textContent = archivedCount;
|
||||||
|
|
||||||
|
$$('.sidebar-item').forEach(item => {
|
||||||
|
let isActive = false;
|
||||||
|
if (item.dataset.route === 'sessions' && state.route === 'sessions' && state.projectFilter === 'all') isActive = true;
|
||||||
|
else if (item.dataset.route === 'usage' && state.route === 'usage') isActive = true;
|
||||||
|
else if (item.dataset.route === 'memory' && item.dataset.view === state.view && state.projectFilter === 'all') isActive = true;
|
||||||
|
else if (item.dataset.project && item.dataset.project === state.projectFilter) isActive = true;
|
||||||
|
if (item.dataset.route === 'memory' && !item.classList.contains('sub')) isActive = false;
|
||||||
|
item.classList.toggle('active', isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = projectCountsForCurrentRoute();
|
||||||
|
let projects = [...new Set(
|
||||||
|
(state.route === 'sessions' ? state.sessions : state.memories)
|
||||||
|
.filter(item => {
|
||||||
|
if (state.route === 'sessions') return true;
|
||||||
|
return state.view === 'archived' ? item.archived : !item.archived;
|
||||||
|
})
|
||||||
|
.map(item => item.project)
|
||||||
|
.filter(Boolean)
|
||||||
|
)];
|
||||||
|
if (state.projectSearch) {
|
||||||
|
const q = state.projectSearch.toLowerCase();
|
||||||
|
projects = projects.filter(p => p.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
|
||||||
|
const projectsEl = $('#sidebar-projects');
|
||||||
|
if (projectsEl) {
|
||||||
|
projectsEl.innerHTML = projects.map(p => `
|
||||||
|
<button class="sidebar-item ${state.projectFilter === p ? 'active' : ''}" data-project="${p}">
|
||||||
|
<span class="icon">${FOLDER_SVG}</span>
|
||||||
|
<span class="label">${escapeHTML(formatProjectLabel(p))}</span>
|
||||||
|
<span class="badge">${counts[p] || 0}</span>
|
||||||
|
</button>
|
||||||
|
`).join('') || `<div style="padding:8px 10px;font-size:11px;color:var(--muted-2);">No projects</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Breadcrumb ---
|
||||||
|
|
||||||
|
export function renderBreadcrumb() {
|
||||||
|
const bc = $('#breadcrumb');
|
||||||
|
if (!bc) return;
|
||||||
|
if (state.route === 'sessions') {
|
||||||
|
if (state.mode === 'detail') {
|
||||||
|
const s = state.sessions.find(x => x.id === state.detailId);
|
||||||
|
if (!s) return;
|
||||||
|
if (state.subagentId) {
|
||||||
|
bc.innerHTML = `<button class="crumb" data-action="goto-sessions">Sessions</button><span class="crumb-sep">/</span><button class="crumb" data-action="goto-session-detail">${escapeHTML((s.title || s.id).slice(0, 30))}</button><span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML((state.subagentDescription || '').slice(0, 40))}</span>`;
|
||||||
|
} else {
|
||||||
|
bc.innerHTML = `<button class="crumb" data-action="goto-sessions">Sessions</button><span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(s.title || s.id)}</span>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let html = `<button class="crumb ${state.projectFilter === 'all' ? 'terminal' : ''}" data-action="goto-sessions">Sessions</button>`;
|
||||||
|
if (state.projectFilter !== 'all') html += `<span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(formatProjectLabel(state.projectFilter))}</span>`;
|
||||||
|
bc.innerHTML = html;
|
||||||
|
}
|
||||||
|
} else if (state.route === 'usage') {
|
||||||
|
bc.innerHTML = `<span class="crumb terminal">Usage</span>`;
|
||||||
|
} else {
|
||||||
|
if (state.mode === 'detail') {
|
||||||
|
const m = state.memories.find(x => x.id === state.detailId);
|
||||||
|
if (!m) return;
|
||||||
|
bc.innerHTML = `<button class="crumb" data-action="goto-memory">Memory</button><span class="crumb-sep">/</span><span class="crumb terminal filename">${escapeHTML(m.path.split('/').pop())}</span>`;
|
||||||
|
} else {
|
||||||
|
let html = `<button class="crumb ${state.projectFilter === 'all' ? 'terminal' : ''}" data-action="goto-memory">Memory</button>`;
|
||||||
|
if (state.projectFilter !== 'all') html += `<span class="crumb-sep">/</span><span class="crumb terminal">${escapeHTML(formatProjectLabel(state.projectFilter))}</span>`;
|
||||||
|
bc.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Window title ---
|
||||||
|
|
||||||
|
export function updateWindowTitle() {
|
||||||
|
const appName = 'Obelisk';
|
||||||
|
let scopeText = '';
|
||||||
|
if (state.route === 'usage') {
|
||||||
|
scopeText = 'Usage';
|
||||||
|
} else if (state.route === 'sessions') {
|
||||||
|
if (state.mode === 'detail') {
|
||||||
|
const s = state.sessions.find(x => x.id === state.detailId);
|
||||||
|
scopeText = s ? `Sessions · ${s.title}` : 'Sessions';
|
||||||
|
} else {
|
||||||
|
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
||||||
|
scopeText = `Sessions${proj}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (state.mode === 'detail') {
|
||||||
|
const m = state.memories.find(x => x.id === state.detailId);
|
||||||
|
scopeText = m ? `Memory · ${m.path.split('/').pop()}` : 'Memory';
|
||||||
|
} else {
|
||||||
|
const viewLabel = state.view === 'archived' ? 'Archived' : 'Active';
|
||||||
|
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
||||||
|
scopeText = `Memory · ${viewLabel}${proj}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const titleEl = $('#titlebar-text');
|
||||||
|
if (titleEl) {
|
||||||
|
const truncated = scopeText.length > 50 ? scopeText.slice(0, 50) + '…' : scopeText;
|
||||||
|
titleEl.innerHTML = `<span class="app-name">${appName}</span><span class="sep">—</span><span class="scope">${escapeHTML(truncated)}</span>`;
|
||||||
|
titleEl.title = `${appName} — ${scopeText}`;
|
||||||
|
}
|
||||||
|
document.title = `${appName} — ${scopeText}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// App state -- single source of truth for the renderer process.
|
||||||
|
// Loaded collections (memories, sessions, projects) start empty and are
|
||||||
|
// populated from the DB at boot.
|
||||||
|
|
||||||
|
export const state = {
|
||||||
|
memories: [],
|
||||||
|
sessions: [],
|
||||||
|
projects: [],
|
||||||
|
route: 'memory',
|
||||||
|
view: 'active', // 'active' | 'archived'
|
||||||
|
mode: 'list', // 'list' | 'detail'
|
||||||
|
detailId: null,
|
||||||
|
subagentId: null,
|
||||||
|
subagentDescription: null,
|
||||||
|
pendingFocusUuid: null,
|
||||||
|
query: '',
|
||||||
|
projectFilter: 'all',
|
||||||
|
projectSearch: '',
|
||||||
|
sortDesc: true,
|
||||||
|
includeMessageBodies: false,
|
||||||
|
cursorId: null,
|
||||||
|
selection: new Set(),
|
||||||
|
showSource: false,
|
||||||
|
lastArchiveSnapshot: null,
|
||||||
|
undoTimer: null,
|
||||||
|
undoExpires: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Platform detection
|
||||||
|
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
|
|
||||||
|
// SVG icon constants
|
||||||
|
export const FOLDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
|
||||||
|
export const FILE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>`;
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
// Usage rendering module -- heatmap, weekly chart, cumulative chart.
|
||||||
|
// Extracted from render.js with identical logic.
|
||||||
|
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { escapeHTML, fmtDuration, fmtTokens, fmtTooltipDate, positionTooltip, formatProjectLabel, $ } from './utils.js';
|
||||||
|
import registry from './registry.js';
|
||||||
|
|
||||||
|
function navigateToSession(sessionId, focusUuid) {
|
||||||
|
if (registry.navigateToSession) {
|
||||||
|
registry.navigateToSession(sessionId, focusUuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderUsage() {
|
||||||
|
const usage = $('#usage');
|
||||||
|
if (!usage) return;
|
||||||
|
|
||||||
|
const data = await window.obelisk.getUsageStats();
|
||||||
|
const { daily, totalTokens, peakDay, longestTurn } = data;
|
||||||
|
|
||||||
|
// Build heatmap: 52 weeks x 7 days grid
|
||||||
|
const today = new Date();
|
||||||
|
const dayMs = 86400000;
|
||||||
|
// Start from the first Sunday on or after 364 days ago (full weeks only)
|
||||||
|
let startDate = new Date(today.getTime() - 364 * dayMs);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||||
|
startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs);
|
||||||
|
|
||||||
|
const dailyMap = {};
|
||||||
|
for (const d of daily) dailyMap[d.day] = d.tokens;
|
||||||
|
|
||||||
|
const values = daily.map(d => d.tokens).filter(Boolean);
|
||||||
|
const maxTokens = Math.max(...values, 1);
|
||||||
|
|
||||||
|
// Generate cells (startDate is always a Sunday now)
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < 371; i++) {
|
||||||
|
const date = new Date(startDate.getTime() + i * dayMs);
|
||||||
|
if (date > today) break;
|
||||||
|
const key = date.toISOString().slice(0, 10);
|
||||||
|
const tokens = dailyMap[key] || 0;
|
||||||
|
const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4));
|
||||||
|
const col = Math.floor(i / 7);
|
||||||
|
const row = i % 7;
|
||||||
|
cells.push({ key, tokens, level, col, row, date });
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCol = cells.length ? cells[cells.length - 1].col : 0;
|
||||||
|
const cellSize = 11;
|
||||||
|
const cellGap = 2;
|
||||||
|
const step = cellSize + cellGap;
|
||||||
|
const gridWidth = (maxCol + 1) * step + 20; // extra padding for last month label
|
||||||
|
const gridHeight = 7 * step;
|
||||||
|
|
||||||
|
const cellsHTML = cells.map(c => {
|
||||||
|
const x = c.col * step;
|
||||||
|
const y = c.row * step;
|
||||||
|
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" rx="2" class="heatmap-cell level-${c.level}" data-label="${fmtTokens(c.tokens)} tokens on ${fmtTooltipDate(c.key)}"></rect>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Month labels
|
||||||
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
const monthLabels = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
for (const c of cells) {
|
||||||
|
const m = c.date.getMonth();
|
||||||
|
if (m !== lastMonth && c.row === 0) {
|
||||||
|
monthLabels.push({ col: c.col, label: months[m] });
|
||||||
|
lastMonth = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const monthLabelsHTML = monthLabels.map(m =>
|
||||||
|
`<text x="${m.col * step}" y="${gridHeight + 14}" class="heatmap-month">${m.label}</text>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Streak calculation — check gaps between consecutive active days
|
||||||
|
let longestStreak = 0;
|
||||||
|
let streak = 0;
|
||||||
|
const sortedDays = [...daily].filter(d => d.tokens > 0).sort((a, b) => a.day.localeCompare(b.day));
|
||||||
|
for (let i = 0; i < sortedDays.length; i++) {
|
||||||
|
if (i === 0) {
|
||||||
|
streak = 1;
|
||||||
|
} else {
|
||||||
|
const prev = new Date(sortedDays[i - 1].day).getTime();
|
||||||
|
const curr = new Date(sortedDays[i].day).getTime();
|
||||||
|
if (curr - prev === dayMs) {
|
||||||
|
streak++;
|
||||||
|
} else {
|
||||||
|
streak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (streak > longestStreak) longestStreak = streak;
|
||||||
|
}
|
||||||
|
// Current streak: find the most recent active day, then count consecutive days backwards
|
||||||
|
let currentStreak = 0;
|
||||||
|
let startedCounting = false;
|
||||||
|
for (let i = 0; i <= 365; i++) {
|
||||||
|
const d = new Date(today.getTime() - i * dayMs).toISOString().slice(0, 10);
|
||||||
|
if (dailyMap[d] && dailyMap[d] > 0) {
|
||||||
|
startedCounting = true;
|
||||||
|
currentStreak++;
|
||||||
|
} else if (startedCounting) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.innerHTML = `
|
||||||
|
<div class="usage-header">
|
||||||
|
<span class="usage-title">Token activity</span>
|
||||||
|
<div class="usage-view-tabs">
|
||||||
|
<button class="usage-tab active" data-view="daily">Daily</button>
|
||||||
|
<button class="usage-tab" data-view="weekly">Weekly</button>
|
||||||
|
<button class="usage-tab" data-view="cumulative">Cumulative</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage-stats">
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">${fmtTokens(totalTokens)}</span>
|
||||||
|
<span class="usage-stat-label">Lifetime tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">${peakDay ? fmtTokens(peakDay.tokens) : '—'}</span>
|
||||||
|
<span class="usage-stat-label">Peak tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">${longestTurn ? fmtDuration(longestTurn.turn_duration_ms) : '—'}</span>
|
||||||
|
<span class="usage-stat-label">Longest task</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">${currentStreak}d</span>
|
||||||
|
<span class="usage-stat-label">Current streak</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">${longestStreak}d</span>
|
||||||
|
<span class="usage-stat-label">Longest streak</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="heatmap-container">
|
||||||
|
<svg class="heatmap" width="${gridWidth}" height="${gridHeight + 20}" viewBox="0 0 ${gridWidth} ${gridHeight + 20}">
|
||||||
|
${cellsHTML}
|
||||||
|
${monthLabelsHTML}
|
||||||
|
</svg>
|
||||||
|
<div class="heatmap-legend">
|
||||||
|
<span class="heatmap-legend-label">Less</span>
|
||||||
|
<svg width="70" height="11"><rect x="0" width="11" height="11" rx="2" class="heatmap-cell level-0"/><rect x="14" width="11" height="11" rx="2" class="heatmap-cell level-1"/><rect x="28" width="11" height="11" rx="2" class="heatmap-cell level-2"/><rect x="42" width="11" height="11" rx="2" class="heatmap-cell level-3"/><rect x="56" width="11" height="11" rx="2" class="heatmap-cell level-4"/></svg>
|
||||||
|
<span class="heatmap-legend-label">More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container" id="usage-chart" style="display:none;"></div>
|
||||||
|
<div class="day-sessions" id="day-sessions"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Heatmap tooltip
|
||||||
|
const heatmapTooltip = document.createElement('div');
|
||||||
|
heatmapTooltip.className = 'chart-tooltip';
|
||||||
|
usage.appendChild(heatmapTooltip);
|
||||||
|
usage.querySelectorAll('.heatmap-cell[data-label]').forEach(cell => {
|
||||||
|
cell.addEventListener('mouseenter', () => {
|
||||||
|
heatmapTooltip.textContent = cell.dataset.label;
|
||||||
|
heatmapTooltip.classList.add('show');
|
||||||
|
});
|
||||||
|
cell.addEventListener('mousemove', e => {
|
||||||
|
positionTooltip(heatmapTooltip, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
cell.addEventListener('mouseleave', () => heatmapTooltip.classList.remove('show'));
|
||||||
|
cell.addEventListener('click', () => {
|
||||||
|
usage.querySelectorAll('.heatmap-cell.selected').forEach(c => c.classList.remove('selected'));
|
||||||
|
cell.classList.add('selected');
|
||||||
|
const date = cell.dataset.label.match(/on (.+)$/)?.[1] || '';
|
||||||
|
const dateKey = cell.getAttribute('data-label').split(' tokens')[0]; // not ideal
|
||||||
|
// Extract ISO date from cells array by matching position
|
||||||
|
const allCells = [...usage.querySelectorAll('.heatmap-cell[data-label]')];
|
||||||
|
const idx = allCells.indexOf(cell);
|
||||||
|
if (idx >= 0 && idx < cells.length) {
|
||||||
|
showDaySessions(cells[idx].key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function showDaySessions(dateKey) {
|
||||||
|
const panel = usage.querySelector('#day-sessions');
|
||||||
|
if (!panel) return;
|
||||||
|
const dayStart = dateKey + 'T00:00:00';
|
||||||
|
const dayEnd = dateKey + 'T23:59:59';
|
||||||
|
|
||||||
|
// Find sessions active on this day
|
||||||
|
const daySessions = state.sessions.filter(s => {
|
||||||
|
if (!s.started_at) return false;
|
||||||
|
const end = s.ended_at || s.started_at;
|
||||||
|
return s.started_at <= dayEnd && end >= dayStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Classify each session
|
||||||
|
const classified = daySessions.map(s => {
|
||||||
|
const isNew = s.started_at.slice(0, 10) === dateKey;
|
||||||
|
let kind = 'continued'; // default: session spans this day
|
||||||
|
if (isNew) {
|
||||||
|
// Check if this project had any session before this one
|
||||||
|
const hasEarlierSession = state.sessions.some(
|
||||||
|
other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at
|
||||||
|
);
|
||||||
|
kind = hasEarlierSession ? 'new-session' : 'new-workspace';
|
||||||
|
}
|
||||||
|
return { ...s, kind };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!classified.length) {
|
||||||
|
panel.innerHTML = `<div class="day-sessions-header">${fmtTooltipDate(dateKey)} — no sessions</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by kind for visual hierarchy
|
||||||
|
const newWorkspaces = classified.filter(s => s.kind === 'new-workspace');
|
||||||
|
const newSessions = classified.filter(s => s.kind === 'new-session');
|
||||||
|
const continued = classified.filter(s => s.kind === 'continued');
|
||||||
|
|
||||||
|
let html = `<div class="day-sessions-header">${fmtTooltipDate(dateKey)}</div><div class="day-activity-timeline">`;
|
||||||
|
|
||||||
|
if (newWorkspaces.length) {
|
||||||
|
html += `
|
||||||
|
<div class="activity-group">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon workspace">★</span>
|
||||||
|
<span class="activity-group-title">Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
${newWorkspaces.map(s => `
|
||||||
|
<button class="activity-item" data-session-id="${s.id}">
|
||||||
|
<span class="activity-item-name"><span class="activity-item-project">${escapeHTML(formatProjectLabel(s.project))}</span> ${escapeHTML(s.title || '(untitled)')}</span>
|
||||||
|
<span class="activity-item-meta">${s.message_count || 0} msg</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSessions.length) {
|
||||||
|
// Group new sessions by project
|
||||||
|
const byProject = {};
|
||||||
|
for (const s of newSessions) {
|
||||||
|
const p = s.project || '(none)';
|
||||||
|
if (!byProject[p]) byProject[p] = [];
|
||||||
|
byProject[p].push(s);
|
||||||
|
}
|
||||||
|
html += `
|
||||||
|
<div class="activity-group">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon new">+</span>
|
||||||
|
<span class="activity-group-title">Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
${newSessions.map(s => `
|
||||||
|
<button class="activity-item" data-session-id="${s.id}">
|
||||||
|
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
|
||||||
|
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (continued.length) {
|
||||||
|
html += `
|
||||||
|
<div class="activity-group continued">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon continued">↳</span>
|
||||||
|
<span class="activity-group-title">Continued ${continued.length} session${continued.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
${continued.map(s => `
|
||||||
|
<button class="activity-item" data-session-id="${s.id}">
|
||||||
|
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
|
||||||
|
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
panel.innerHTML = html;
|
||||||
|
panel.querySelectorAll('.activity-item').forEach(row => {
|
||||||
|
row.addEventListener('click', () => navigateToSession(row.dataset.sessionId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
usage.querySelectorAll('.usage-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
usage.querySelectorAll('.usage-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
const view = tab.dataset.view;
|
||||||
|
const heatmap = usage.querySelector('.heatmap-container');
|
||||||
|
const chart = usage.querySelector('#usage-chart');
|
||||||
|
if (view === 'daily') {
|
||||||
|
heatmap.style.display = ''; chart.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
heatmap.style.display = 'none'; chart.style.display = '';
|
||||||
|
if (view === 'weekly') renderWeeklyChart(chart, daily);
|
||||||
|
else renderCumulativeChart(chart, daily);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default: show current month's activity with "show more" for previous months
|
||||||
|
let loadedMonths = 0;
|
||||||
|
showNextMonth();
|
||||||
|
|
||||||
|
function showNextMonth() {
|
||||||
|
const panel = usage.querySelector('#day-sessions');
|
||||||
|
if (!panel) return;
|
||||||
|
const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths, 1);
|
||||||
|
const year = targetDate.getFullYear();
|
||||||
|
const month = targetDate.getMonth();
|
||||||
|
loadedMonths++;
|
||||||
|
|
||||||
|
const monthHTML = buildMonthHTML(year, month);
|
||||||
|
|
||||||
|
// Remove existing "show more" button
|
||||||
|
const existing = panel.querySelector('.show-more-btn');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
panel.insertAdjacentHTML('beforeend', monthHTML);
|
||||||
|
|
||||||
|
// Add "show more" button
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'show-more-btn';
|
||||||
|
btn.textContent = 'Show more activity';
|
||||||
|
btn.addEventListener('click', () => showNextMonth());
|
||||||
|
panel.appendChild(btn);
|
||||||
|
|
||||||
|
// Wire up session links
|
||||||
|
panel.querySelectorAll('.activity-item:not([data-wired])').forEach(row => {
|
||||||
|
row.setAttribute('data-wired', '1');
|
||||||
|
row.addEventListener('click', () => navigateToSession(row.dataset.sessionId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthHTML(year, month) {
|
||||||
|
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||||
|
const nextMonth = month === 11 ? `${year + 1}-01-01` : `${year}-${String(month + 2).padStart(2, '0')}-01`;
|
||||||
|
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
const monthSessions = state.sessions.filter(s => {
|
||||||
|
if (!s.started_at) return false;
|
||||||
|
const end = s.ended_at || s.started_at;
|
||||||
|
return s.started_at < nextMonth && end >= monthStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
const classified = monthSessions.map(s => {
|
||||||
|
const startedInMonth = s.started_at >= monthStart && s.started_at < nextMonth;
|
||||||
|
let kind = 'continued';
|
||||||
|
if (startedInMonth) {
|
||||||
|
const hasEarlierSession = state.sessions.some(
|
||||||
|
other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at
|
||||||
|
);
|
||||||
|
kind = hasEarlierSession ? 'new-session' : 'new-workspace';
|
||||||
|
}
|
||||||
|
return { ...s, kind };
|
||||||
|
});
|
||||||
|
|
||||||
|
const newWorkspaces = classified.filter(s => s.kind === 'new-workspace');
|
||||||
|
const newSessions = classified.filter(s => s.kind === 'new-session');
|
||||||
|
const continued = classified.filter(s => s.kind === 'continued');
|
||||||
|
|
||||||
|
const headerText = `${monthNames[month]} ${year}`;
|
||||||
|
let html = `<div class="day-sessions-header">${headerText}</div><div class="day-activity-timeline">`;
|
||||||
|
|
||||||
|
if (newWorkspaces.length) {
|
||||||
|
html += `
|
||||||
|
<div class="activity-group">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon workspace">★</span>
|
||||||
|
<span class="activity-group-title">Created ${newWorkspaces.length} new workspace${newWorkspaces.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
${newWorkspaces.map(s => `
|
||||||
|
<button class="activity-item" data-session-id="${s.id}">
|
||||||
|
<span class="activity-item-name"><span class="activity-item-project">${escapeHTML(formatProjectLabel(s.project))}</span> ${escapeHTML(s.title || '(untitled)')}</span>
|
||||||
|
<span class="activity-item-meta">${s.message_count || 0} msg</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSessions.length) {
|
||||||
|
const byProject = {};
|
||||||
|
for (const s of newSessions) { const p = s.project || '(none)'; if (!byProject[p]) byProject[p] = []; byProject[p].push(s); }
|
||||||
|
html += `
|
||||||
|
<div class="activity-group">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon new">+</span>
|
||||||
|
<span class="activity-group-title">Started ${newSessions.length} session${newSessions.length > 1 ? 's' : ''} in ${Object.keys(byProject).length} project${Object.keys(byProject).length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
${newSessions.map(s => `
|
||||||
|
<button class="activity-item" data-session-id="${s.id}">
|
||||||
|
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
|
||||||
|
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (continued.length) {
|
||||||
|
html += `
|
||||||
|
<div class="activity-group continued">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon continued">↳</span>
|
||||||
|
<span class="activity-group-title">Continued ${continued.length} session${continued.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
${continued.map(s => `
|
||||||
|
<button class="activity-item" data-session-id="${s.id}">
|
||||||
|
<span class="activity-item-name">${escapeHTML(s.title || '(untitled)')}</span>
|
||||||
|
<span class="activity-item-meta">${escapeHTML(formatProjectLabel(s.project))} · ${s.message_count || 0} msg</span>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!classified.length) html += `<div style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>`;
|
||||||
|
html += `</div>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWeeklyChart(container, daily) {
|
||||||
|
// Build 52 weekly buckets aligned to the same time range as the heatmap
|
||||||
|
const today = new Date();
|
||||||
|
const dayMs = 86400000;
|
||||||
|
let startDate = new Date(today.getTime() - 364 * dayMs);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||||
|
startDate = new Date(startDate.getTime() + daysUntilSunday * dayMs);
|
||||||
|
|
||||||
|
const dailyMap = {};
|
||||||
|
for (const d of daily) dailyMap[d.day] = d.tokens;
|
||||||
|
|
||||||
|
// Aggregate into weeks
|
||||||
|
const weeks = [];
|
||||||
|
for (let w = 0; w < 53; w++) {
|
||||||
|
const weekStart = new Date(startDate.getTime() + w * 7 * dayMs);
|
||||||
|
if (weekStart > today) break;
|
||||||
|
let tokens = 0;
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const date = new Date(weekStart.getTime() + d * dayMs);
|
||||||
|
if (date > today) break;
|
||||||
|
const key = date.toISOString().slice(0, 10);
|
||||||
|
tokens += dailyMap[key] || 0;
|
||||||
|
}
|
||||||
|
weeks.push({ weekStart, tokens });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weeks.length) { container.innerHTML = '<div class="empty">No data</div>'; return; }
|
||||||
|
|
||||||
|
const maxVal = Math.max(...weeks.map(w => w.tokens), 1);
|
||||||
|
const barWidth = 10;
|
||||||
|
const barGap = 3;
|
||||||
|
const chartHeight = 120;
|
||||||
|
const chartWidth = weeks.length * (barWidth + barGap);
|
||||||
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
// Month labels
|
||||||
|
const labels = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
for (let i = 0; i < weeks.length; i++) {
|
||||||
|
const m = weeks[i].weekStart.getMonth();
|
||||||
|
if (m !== lastMonth) { labels.push({ i, label: months[m] }); lastMonth = m; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const barsHTML = weeks.map((w, i) => {
|
||||||
|
const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0;
|
||||||
|
const x = i * (barWidth + barGap);
|
||||||
|
return `<rect x="${x}" y="${chartHeight - h}" width="${barWidth}" height="${Math.max(h, 0.5)}" rx="2" class="bar-fill" data-label="Week of ${w.weekStart.toISOString().slice(0, 10)}: ${fmtTokens(w.tokens)}"></rect>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const labelsHTML = labels.map(l => {
|
||||||
|
const x = l.i * (barWidth + barGap);
|
||||||
|
return `<text x="${x}" y="${chartHeight + 16}" class="heatmap-month">${l.label}</text>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="chart-tooltip" id="chart-tooltip"></div>
|
||||||
|
<svg class="weekly-chart" viewBox="0 0 ${chartWidth + 20} ${chartHeight + 24}" preserveAspectRatio="xMidYMid meet">
|
||||||
|
${barsHTML}
|
||||||
|
${labelsHTML}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Tooltip on hover
|
||||||
|
const tooltip = container.querySelector('#chart-tooltip');
|
||||||
|
container.querySelectorAll('.bar-fill').forEach(bar => {
|
||||||
|
bar.addEventListener('mouseenter', e => {
|
||||||
|
tooltip.textContent = bar.dataset.label;
|
||||||
|
tooltip.classList.add('show');
|
||||||
|
});
|
||||||
|
bar.addEventListener('mousemove', e => {
|
||||||
|
positionTooltip(tooltip, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
bar.addEventListener('mouseleave', () => tooltip.classList.remove('show'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCumulativeChart(container, daily) {
|
||||||
|
const sorted = [...daily].sort((a, b) => a.day.localeCompare(b.day));
|
||||||
|
if (!sorted.length) { container.innerHTML = '<div class="empty">No data</div>'; return; }
|
||||||
|
|
||||||
|
let cumulative = 0;
|
||||||
|
const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; });
|
||||||
|
const maxVal = points[points.length - 1].total;
|
||||||
|
|
||||||
|
const chartWidth = 700;
|
||||||
|
const chartHeight = 140;
|
||||||
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
// Scale x by index, y by value
|
||||||
|
const xScale = (i) => (i / (points.length - 1)) * chartWidth;
|
||||||
|
const yScale = (v) => chartHeight - (v / maxVal) * chartHeight;
|
||||||
|
|
||||||
|
// Build path
|
||||||
|
const pathParts = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(p.total).toFixed(1)}`);
|
||||||
|
const linePath = pathParts.join(' ');
|
||||||
|
const areaPath = linePath + ` L${chartWidth},${chartHeight} L0,${chartHeight} Z`;
|
||||||
|
|
||||||
|
// Month labels
|
||||||
|
const labels = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const m = new Date(points[i].day).getMonth();
|
||||||
|
if (m !== lastMonth) { labels.push({ x: xScale(i), label: months[m] }); lastMonth = m; }
|
||||||
|
}
|
||||||
|
const labelsHTML = labels.map(l => `<text x="${l.x}" y="${chartHeight + 16}" class="heatmap-month">${l.label}</text>`).join('');
|
||||||
|
|
||||||
|
// Invisible hover dots for tooltip
|
||||||
|
const dotsHTML = points.map((p, i) => {
|
||||||
|
return `<circle cx="${xScale(i).toFixed(1)}" cy="${yScale(p.total).toFixed(1)}" r="6" class="cumulative-dot" data-label="${p.day}: ${fmtTokens(p.total)} total"/>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="chart-tooltip" id="chart-tooltip-cum"></div>
|
||||||
|
<svg viewBox="0 0 ${chartWidth} ${chartHeight + 24}" preserveAspectRatio="xMidYMid meet" class="cumulative-chart">
|
||||||
|
<path d="${areaPath}" class="cumulative-area"/>
|
||||||
|
<path d="${linePath}" class="cumulative-line"/>
|
||||||
|
${dotsHTML}
|
||||||
|
${labelsHTML}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const tooltip = container.querySelector('#chart-tooltip-cum');
|
||||||
|
container.querySelectorAll('.cumulative-dot').forEach(dot => {
|
||||||
|
dot.addEventListener('mouseenter', e => {
|
||||||
|
tooltip.textContent = dot.dataset.label;
|
||||||
|
tooltip.classList.add('show');
|
||||||
|
});
|
||||||
|
dot.addEventListener('mousemove', e => {
|
||||||
|
positionTooltip(tooltip, e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
dot.addEventListener('mouseleave', () => tooltip.classList.remove('show'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// Utility functions extracted from render.js
|
||||||
|
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads state).
|
||||||
|
|
||||||
|
import { state } from './state.js';
|
||||||
|
|
||||||
|
// --- Time / formatting ---
|
||||||
|
|
||||||
|
export function pad2(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
export function isSameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtListTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
if (isSameDay(d, now)) return hhmm;
|
||||||
|
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||||
|
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtRelative(ts) {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
const min = 60000, hr = 3600000, day = 86400000;
|
||||||
|
if (diff < 0) return 'in the future';
|
||||||
|
if (diff < min) return 'just now';
|
||||||
|
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||||
|
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||||
|
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||||
|
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||||
|
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtClockTime(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtSize(bytes) {
|
||||||
|
if (!bytes) return '-';
|
||||||
|
if (bytes < 1024) return bytes + 'B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + 'M';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML / Markdown ---
|
||||||
|
|
||||||
|
export function escapeHTML(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
||||||
|
|
||||||
|
export function highlightPlain(text, query) {
|
||||||
|
if (!query) return escapeHTML(text);
|
||||||
|
const safe = escapeHTML(text);
|
||||||
|
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return safe.replace(new RegExp(q, 'gi'), m => `<mark>${m}</mark>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeMarkdown(html) {
|
||||||
|
return html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/\son\w+="[^"]*"/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightTextNodes(rootEl, query) {
|
||||||
|
if (!query) return;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null);
|
||||||
|
const nodes = [];
|
||||||
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const text = node.nodeValue;
|
||||||
|
if (!text) continue;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (!lower.includes(q)) continue;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let last = 0, i = lower.indexOf(q);
|
||||||
|
while (i !== -1) {
|
||||||
|
if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i)));
|
||||||
|
const mark = document.createElement('mark');
|
||||||
|
mark.textContent = text.slice(i, i + q.length);
|
||||||
|
frag.appendChild(mark);
|
||||||
|
last = i + q.length;
|
||||||
|
i = lower.indexOf(q, last);
|
||||||
|
}
|
||||||
|
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
|
||||||
|
node.parentNode.replaceChild(frag, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(text, opts = {}) {
|
||||||
|
if (text == null) return '';
|
||||||
|
const html = sanitizeMarkdown(marked.parse(text));
|
||||||
|
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
||||||
|
: opts.variant === 'compact' ? 'markdown-compact'
|
||||||
|
: 'markdown-body';
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = cls;
|
||||||
|
container.innerHTML = html;
|
||||||
|
if (opts.query) highlightTextNodes(container, opts.query.trim());
|
||||||
|
return container.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duration / tokens / tooltip ---
|
||||||
|
|
||||||
|
export function fmtDuration(ms) {
|
||||||
|
if (!ms) return '—';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
const parts = [];
|
||||||
|
if (d) parts.push(`${d}d`);
|
||||||
|
if (h) parts.push(`${h}h`);
|
||||||
|
if (m) parts.push(`${m}m`);
|
||||||
|
if (sec || !parts.length) parts.push(`${sec}s`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTokens(n) {
|
||||||
|
if (!n) return '0';
|
||||||
|
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B';
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTooltipDate(isoDay) {
|
||||||
|
const d = new Date(isoDay + 'T00:00:00');
|
||||||
|
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
const day = d.getDate();
|
||||||
|
const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th';
|
||||||
|
const thisYear = new Date().getFullYear();
|
||||||
|
if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`;
|
||||||
|
return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function positionTooltip(el, x, y) {
|
||||||
|
const pad = 12;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
let left = x + pad;
|
||||||
|
if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad;
|
||||||
|
el.style.left = left + 'px';
|
||||||
|
el.style.top = (y - 28) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DOM helpers ---
|
||||||
|
|
||||||
|
export const $ = sel => document.querySelector(sel);
|
||||||
|
export const $$ = sel => Array.from(document.querySelectorAll(sel));
|
||||||
|
|
||||||
|
export function ensureVisible(el, wrapSel) {
|
||||||
|
const wrap = $(wrapSel);
|
||||||
|
if (!wrap || !el) return;
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
if (elRect.top < wrapRect.top + 30) wrap.scrollTop -= (wrapRect.top + 30 - elRect.top);
|
||||||
|
else if (elRect.bottom > wrapRect.bottom - 10) wrap.scrollTop += (elRect.bottom - wrapRect.bottom + 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project label ---
|
||||||
|
|
||||||
|
export function formatProjectLabel(slug) {
|
||||||
|
if (!slug) return '(no project)';
|
||||||
|
// Use project_path if available from sessions, otherwise show slug as-is
|
||||||
|
const session = state.sessions.find(s => s.project === slug && s.project_path);
|
||||||
|
if (session?.project_path) {
|
||||||
|
const parts = session.project_path.split('/');
|
||||||
|
return parts.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
return slug.replace(/^-/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Row status ---
|
||||||
|
|
||||||
|
export function dominantRowStatus(m) {
|
||||||
|
if (m.health === 'broken') return 'broken';
|
||||||
|
if (m.health === 'partial') return 'partial';
|
||||||
|
if (m.archived) return 'archived';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusGlyphHTML(status) {
|
||||||
|
if (!status) return '';
|
||||||
|
const glyphs = {
|
||||||
|
broken: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
|
||||||
|
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
|
||||||
|
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
|
||||||
|
};
|
||||||
|
return `<span class="row-status ${status}" title="${status}">${glyphs[status] || ''}</span>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
state,
|
||||||
|
IS_MAC,
|
||||||
|
FOLDER_SVG,
|
||||||
|
setRoute,
|
||||||
|
setView,
|
||||||
|
setProject,
|
||||||
|
setQuery,
|
||||||
|
setProjectSearch,
|
||||||
|
toggleSort,
|
||||||
|
toggleIncludeMessageBodies
|
||||||
|
} from './store.js';
|
||||||
|
import { formatProjectLabel } from './utils.js';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// --- Sidebar data ---
|
||||||
|
|
||||||
|
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
|
||||||
|
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
|
||||||
|
const totalMemoryCount = computed(() => state.memories.length);
|
||||||
|
const sessionCount = computed(() => state.sessions.length);
|
||||||
|
|
||||||
|
const sidebarProjects = computed(() => {
|
||||||
|
const items = state.route === 'sessions' ? state.sessions : state.memories;
|
||||||
|
const filtered = items.filter(item => {
|
||||||
|
if (state.route === 'sessions') return true;
|
||||||
|
return state.view === 'archived' ? item.archived : !item.archived;
|
||||||
|
});
|
||||||
|
let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))];
|
||||||
|
if (state.projectSearch) {
|
||||||
|
const q = state.projectSearch.toLowerCase();
|
||||||
|
projects = projects.filter(p => p.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
|
||||||
|
|
||||||
|
// Count per project
|
||||||
|
const counts = {};
|
||||||
|
for (const item of filtered) {
|
||||||
|
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects.map(p => ({
|
||||||
|
slug: p,
|
||||||
|
label: formatProjectLabel(p),
|
||||||
|
count: counts[p] || 0
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Toolbar visibility ---
|
||||||
|
|
||||||
|
const showToolbar = computed(() => {
|
||||||
|
const r = route.name;
|
||||||
|
return r === 'SessionList' || r === 'MemoryList';
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSearchMsgsToggle = computed(() => {
|
||||||
|
return route.name === 'SessionList';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Window title ---
|
||||||
|
|
||||||
|
const windowTitle = computed(() => {
|
||||||
|
const appName = 'Obelisk';
|
||||||
|
let scopeText = '';
|
||||||
|
if (route.name === 'Usage') {
|
||||||
|
scopeText = 'Usage';
|
||||||
|
} else if (route.name?.startsWith('Session')) {
|
||||||
|
if (route.name === 'SessionDetail' || route.name === 'SubagentDetail') {
|
||||||
|
const s = state.sessions.find(x => x.id === route.params.id);
|
||||||
|
scopeText = s ? `Sessions · ${s.title}` : 'Sessions';
|
||||||
|
} else {
|
||||||
|
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
||||||
|
scopeText = `Sessions${proj}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (route.name === 'MemoryDetail') {
|
||||||
|
const m = state.memories.find(x => x.id === route.params.id);
|
||||||
|
scopeText = m ? `Memory · ${m.path.split('/').pop()}` : 'Memory';
|
||||||
|
} else {
|
||||||
|
const viewLabel = state.view === 'archived' ? 'Archived' : 'Active';
|
||||||
|
const proj = state.projectFilter !== 'all' ? ` · ${formatProjectLabel(state.projectFilter)}` : '';
|
||||||
|
scopeText = `Memory · ${viewLabel}${proj}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { appName, scopeText };
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => windowTitle.value.scopeText, (scopeText) => {
|
||||||
|
document.title = `${windowTitle.value.appName} — ${scopeText}`;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// --- Navigation helpers ---
|
||||||
|
|
||||||
|
function handleSidebarRoute(routeName) {
|
||||||
|
setRoute(routeName);
|
||||||
|
if (routeName === 'sessions') {
|
||||||
|
router.push('/sessions');
|
||||||
|
} else if (routeName === 'usage') {
|
||||||
|
router.push('/usage');
|
||||||
|
} else {
|
||||||
|
router.push('/memory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarView(view) {
|
||||||
|
setView(view);
|
||||||
|
router.push('/memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarProject(slug) {
|
||||||
|
setProject(slug);
|
||||||
|
// Stay on current list route
|
||||||
|
if (state.route === 'sessions') router.push('/sessions');
|
||||||
|
else router.push('/memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProjectSearch(e) {
|
||||||
|
setProjectSearch(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
|
||||||
|
let searchTimer = null;
|
||||||
|
function handleSearch(e) {
|
||||||
|
const value = e.target.value;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
setQuery(value);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleSort() {
|
||||||
|
toggleSort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleSearchMsgs() {
|
||||||
|
toggleIncludeMessageBodies();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keep-alive includes ---
|
||||||
|
const keepAliveIncludes = ['SessionDetail'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<!-- Titlebar (macOS traffic-light region) -->
|
||||||
|
<div class="titlebar" :class="{ mac: IS_MAC }">
|
||||||
|
<div class="titlebar-drag"></div>
|
||||||
|
<div id="titlebar-text" class="titlebar-text">
|
||||||
|
<span class="app-name">{{ windowTitle.appName }}</span>
|
||||||
|
<span class="sep">—</span>
|
||||||
|
<span class="scope">{{ windowTitle.scopeText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Columns: sidebar + main -->
|
||||||
|
<div class="columns">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none">
|
||||||
|
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>
|
||||||
|
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<defs><linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse"><stop stop-color="#a78bfa"/><stop offset="1" stop-color="#6366f1"/></linearGradient></defs>
|
||||||
|
</svg>
|
||||||
|
<span class="name">Obelisk</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation section -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: state.route === 'sessions' && state.projectFilter === 'all' }"
|
||||||
|
@click="handleSidebarRoute('sessions')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 1v4M11 1v4"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Sessions</span>
|
||||||
|
<span class="badge">{{ sessionCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-item sub"
|
||||||
|
:class="{ active: state.route === 'memory' && state.view === 'active' && state.projectFilter === 'all' }"
|
||||||
|
@click="handleSidebarView('active')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="5.5"/><path d="M8 5v3l2 1.5"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Active</span>
|
||||||
|
<span class="badge">{{ activeCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-item sub"
|
||||||
|
:class="{ active: state.route === 'memory' && state.view === 'archived' && state.projectFilter === 'all' }"
|
||||||
|
@click="handleSidebarView('archived')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/><path d="M1.5 3.5h13v2h-13z"/><path d="M6 8h4"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Archived</span>
|
||||||
|
<span class="badge">{{ archivedCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: state.route === 'usage' }"
|
||||||
|
@click="handleSidebarRoute('usage')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Usage</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects section -->
|
||||||
|
<div class="sidebar-section projects">
|
||||||
|
<div class="sidebar-section-title">
|
||||||
|
<span>Projects</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter..."
|
||||||
|
:value="state.projectSearch"
|
||||||
|
@input="handleProjectSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div id="sidebar-projects" class="sidebar-projects-list">
|
||||||
|
<button
|
||||||
|
v-for="p in sidebarProjects"
|
||||||
|
:key="p.slug"
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: state.projectFilter === p.slug }"
|
||||||
|
@click="handleSidebarProject(p.slug)"
|
||||||
|
>
|
||||||
|
<span class="icon" v-html="FOLDER_SVG"></span>
|
||||||
|
<span class="label">{{ p.label }}</span>
|
||||||
|
<span class="badge">{{ p.count }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!sidebarProjects.length" class="sidebar-empty">
|
||||||
|
No projects
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content area -->
|
||||||
|
<div class="main">
|
||||||
|
<!-- Toolbar with search + sort (only on list views) -->
|
||||||
|
<div v-if="showToolbar" class="toolbar">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span class="crumb terminal">
|
||||||
|
{{ state.route === 'sessions' ? 'Sessions' : 'Memory' }}
|
||||||
|
</span>
|
||||||
|
<template v-if="state.projectFilter !== 'all'">
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
|
<span class="crumb terminal">{{ formatProjectLabel(state.projectFilter) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div id="search-wrap" class="search-wrap">
|
||||||
|
<input
|
||||||
|
id="search"
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search..."
|
||||||
|
@input="handleSearch"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="showSearchMsgsToggle"
|
||||||
|
class="filter-toggle"
|
||||||
|
:class="{ active: state.includeMessageBodies }"
|
||||||
|
@click="handleToggleSearchMsgs"
|
||||||
|
title="Include message bodies in search"
|
||||||
|
>
|
||||||
|
Msgs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="sort-toggle"
|
||||||
|
class="sort-group"
|
||||||
|
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
|
||||||
|
@click="handleToggleSort"
|
||||||
|
>
|
||||||
|
<span id="sort-label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar for detail views (breadcrumb only) -->
|
||||||
|
<div v-if="!showToolbar" class="toolbar">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<router-link class="crumb" to="/sessions" v-if="route.name === 'SessionDetail' || route.name === 'SubagentDetail'">
|
||||||
|
Sessions
|
||||||
|
</router-link>
|
||||||
|
<template v-if="route.name === 'SubagentDetail'">
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
|
<router-link class="crumb" :to="`/sessions/${route.params.id}`">
|
||||||
|
{{ (state.sessions.find(s => s.id === route.params.id)?.title || '').slice(0, 30) || route.params.id }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
<template v-if="route.name === 'SessionDetail'">
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
|
<span class="crumb terminal">
|
||||||
|
{{ state.sessions.find(s => s.id === route.params.id)?.title || route.params.id }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="route.name === 'SubagentDetail'">
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
|
<span class="crumb terminal">{{ route.params.agentId }}</span>
|
||||||
|
</template>
|
||||||
|
<router-link class="crumb" to="/memory" v-if="route.name === 'MemoryDetail'">
|
||||||
|
Memory
|
||||||
|
</router-link>
|
||||||
|
<template v-if="route.name === 'MemoryDetail'">
|
||||||
|
<span class="crumb-sep">/</span>
|
||||||
|
<span class="crumb terminal filename">
|
||||||
|
{{ (state.memories.find(m => m.id === route.params.id)?.path || '').split('/').pop() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="route.name === 'Usage'" class="crumb terminal">Usage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Router view with keep-alive for SessionDetail -->
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<keep-alive :include="keepAliveIncludes">
|
||||||
|
<component :is="Component" />
|
||||||
|
</keep-alive>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<div class="statusbar">
|
||||||
|
<div id="status-left" class="status-left"></div>
|
||||||
|
<div id="status-right" class="status-right"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import '../styles/base.css';
|
||||||
|
@import '../styles/sidebar.css';
|
||||||
|
@import '../styles/toolbar.css';
|
||||||
|
@import '../styles/list.css';
|
||||||
|
@import '../styles/detail.css';
|
||||||
|
@import '../styles/statusbar.css';
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
height: 38px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar.mac {
|
||||||
|
padding-left: 78px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-drag {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-text .app-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-text .sep {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
height: 26px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-projects-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
state,
|
||||||
|
FOLDER_SVG,
|
||||||
|
setRoute,
|
||||||
|
setView,
|
||||||
|
setProject,
|
||||||
|
setProjectSearch
|
||||||
|
} from '../store.js';
|
||||||
|
import { formatProjectLabel } from '../utils.js';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// --- Counts ---
|
||||||
|
|
||||||
|
const sessionCount = computed(() => state.sessions.length);
|
||||||
|
const activeCount = computed(() => state.memories.filter(m => !m.archived).length);
|
||||||
|
const archivedCount = computed(() => state.memories.filter(m => m.archived).length);
|
||||||
|
const totalMemoryCount = computed(() => state.memories.length);
|
||||||
|
|
||||||
|
// --- Projects list ---
|
||||||
|
|
||||||
|
const sidebarProjects = computed(() => {
|
||||||
|
const items = state.route === 'sessions' ? state.sessions : state.memories;
|
||||||
|
const filtered = items.filter(item => {
|
||||||
|
if (state.route === 'sessions') return true;
|
||||||
|
return state.view === 'archived' ? item.archived : !item.archived;
|
||||||
|
});
|
||||||
|
let projects = [...new Set(filtered.map(item => item.project).filter(Boolean))];
|
||||||
|
if (state.projectSearch) {
|
||||||
|
const q = state.projectSearch.toLowerCase();
|
||||||
|
projects = projects.filter(p => p.toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
projects.sort((a, b) => formatProjectLabel(a).localeCompare(formatProjectLabel(b)));
|
||||||
|
|
||||||
|
// Count per project
|
||||||
|
const counts = {};
|
||||||
|
for (const item of filtered) {
|
||||||
|
if (item.project) counts[item.project] = (counts[item.project] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects.map(p => ({
|
||||||
|
slug: p,
|
||||||
|
label: formatProjectLabel(p),
|
||||||
|
count: counts[p] || 0
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Active state helpers ---
|
||||||
|
|
||||||
|
function isSessionsActive() {
|
||||||
|
return state.route === 'sessions' && state.projectFilter === 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMemoryViewActive(view) {
|
||||||
|
return state.route === 'memory' && state.view === view && state.projectFilter === 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsageActive() {
|
||||||
|
return state.route === 'usage';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectActive(slug) {
|
||||||
|
return state.projectFilter === slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation handlers ---
|
||||||
|
|
||||||
|
function handleSidebarRoute(routeName) {
|
||||||
|
setRoute(routeName);
|
||||||
|
if (routeName === 'sessions') {
|
||||||
|
router.push('/sessions');
|
||||||
|
} else if (routeName === 'usage') {
|
||||||
|
router.push('/usage');
|
||||||
|
} else {
|
||||||
|
router.push('/memory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarView(view) {
|
||||||
|
setView(view);
|
||||||
|
router.push('/memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSidebarProject(slug) {
|
||||||
|
setProject(slug);
|
||||||
|
if (state.route === 'sessions') router.push('/sessions');
|
||||||
|
else router.push('/memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProjectSearch(e) {
|
||||||
|
setProjectSearch(e.target.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<svg viewBox="0 0 20 20" fill="none">
|
||||||
|
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.2" opacity="0.6"/>
|
||||||
|
<path d="M10 4 L10 16" stroke="url(#obelisk-grad)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="obelisk-grad" x1="10" y1="4" x2="10" y2="16" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#a78bfa"/>
|
||||||
|
<stop offset="1" stop-color="#6366f1"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<span class="name">Obelisk</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Library section -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title"><span>Library</span></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: isSessionsActive() }"
|
||||||
|
@click="handleSidebarRoute('sessions')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||||
|
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||||
|
<path d="M5 1v4M11 1v4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Sessions</span>
|
||||||
|
<span class="badge">{{ sessionCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Memory parent (non-clickable label) -->
|
||||||
|
<div class="sidebar-section-title" style="padding-top: 8px;">
|
||||||
|
<span>Memory</span>
|
||||||
|
<span class="badge">{{ totalMemoryCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-item sub"
|
||||||
|
:class="{ active: isMemoryViewActive('active') }"
|
||||||
|
@click="handleSidebarView('active')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||||
|
<circle cx="8" cy="8" r="5.5"/>
|
||||||
|
<path d="M8 5v3l2 1.5"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Active</span>
|
||||||
|
<span class="badge">{{ activeCount }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-item sub"
|
||||||
|
:class="{ active: isMemoryViewActive('archived') }"
|
||||||
|
@click="handleSidebarView('archived')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||||
|
<path d="M2.5 5h11v7.5a1.5 1.5 0 0 1-1.5 1.5H4a1.5 1.5 0 0 1-1.5-1.5V5z"/>
|
||||||
|
<path d="M1.5 3.5h13v2h-13z"/>
|
||||||
|
<path d="M6 8h4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Archived</span>
|
||||||
|
<span class="badge">{{ archivedCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats section -->
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-title"><span>Stats</span></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: isUsageActive() }"
|
||||||
|
@click="handleSidebarRoute('usage')"
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||||
|
<path d="M2 13h12M4 9v4M7 6v7M10 8v5M13 4v9"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="label">Usage</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects section -->
|
||||||
|
<div class="sidebar-section projects">
|
||||||
|
<div class="sidebar-section-title">
|
||||||
|
<span>Projects</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-search">
|
||||||
|
<svg class="sidebar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="7" cy="7" r="4.5"/>
|
||||||
|
<path d="M10.5 10.5L14 14"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter..."
|
||||||
|
:value="state.projectSearch"
|
||||||
|
@input="handleProjectSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-list">
|
||||||
|
<button
|
||||||
|
v-for="p in sidebarProjects"
|
||||||
|
:key="p.slug"
|
||||||
|
class="sidebar-item"
|
||||||
|
:class="{ active: isProjectActive(p.slug) }"
|
||||||
|
@click="handleSidebarProject(p.slug)"
|
||||||
|
>
|
||||||
|
<span class="icon" v-html="FOLDER_SVG"></span>
|
||||||
|
<span class="label">{{ p.label }}</span>
|
||||||
|
<span class="badge">{{ p.count }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!sidebarProjects.length" class="sidebar-empty">
|
||||||
|
No projects
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--hairline-strong);
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 14px; height: 36px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar-brand svg { width: 18px; height: 18px; filter: drop-shadow(0 0 8px rgba(167,139,250,0.4)); }
|
||||||
|
.sidebar-brand .name { font-size: var(--text-base); font-weight: 600; color: var(--fg-2); }
|
||||||
|
.sidebar-section { padding: 8px 6px; flex-shrink: 0; }
|
||||||
|
.sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; }
|
||||||
|
.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); }
|
||||||
|
.sidebar-section-title {
|
||||||
|
padding: 4px 10px 6px;
|
||||||
|
font-size: 10.5px; color: var(--muted);
|
||||||
|
font-weight: 500; letter-spacing: 0.04em;
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; }
|
||||||
|
.sidebar-search input {
|
||||||
|
width: 100%; height: 24px;
|
||||||
|
padding: 0 8px 0 24px;
|
||||||
|
border: 1px solid var(--hairline); border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: var(--text-sm); color: var(--fg);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.sidebar-search input::placeholder { color: var(--muted-2); }
|
||||||
|
.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); }
|
||||||
|
.sidebar-search-icon {
|
||||||
|
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 11px; height: 11px; color: var(--muted); pointer-events: none;
|
||||||
|
}
|
||||||
|
.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; }
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 10px; height: var(--row-h-compact);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--fg-2); font-size: var(--text-base);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
transition: background 0.08s; position: relative;
|
||||||
|
width: 100%; text-align: left;
|
||||||
|
border: none; background: none;
|
||||||
|
}
|
||||||
|
.sidebar-item:hover { background: var(--surface-strong); color: var(--fg); }
|
||||||
|
.sidebar-item.active { background: var(--accent-soft); color: var(--fg); }
|
||||||
|
.sidebar-item.active::before {
|
||||||
|
content: ''; position: absolute; left: -6px; top: 4px; bottom: 4px;
|
||||||
|
width: 2px; background: var(--accent); border-radius: 1px;
|
||||||
|
box-shadow: 0 0 8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.sidebar-item .icon { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; transition: all 0.08s; }
|
||||||
|
.sidebar-item.active .icon { color: var(--accent-2); filter: drop-shadow(0 0 4px var(--accent-glow)); }
|
||||||
|
.sidebar-item.warning .icon { color: var(--danger); }
|
||||||
|
.sidebar-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sidebar-item .badge {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1; min-width: 22px; text-align: right;
|
||||||
|
flex-shrink: 0; padding: 2px 0;
|
||||||
|
}
|
||||||
|
.sidebar-item.active .badge { color: var(--fg-2); }
|
||||||
|
.sidebar-item.warning .badge {
|
||||||
|
color: var(--danger); background: var(--danger-soft);
|
||||||
|
padding: 2px 6px; border-radius: 8px;
|
||||||
|
margin-right: -6px; min-width: 22px;
|
||||||
|
}
|
||||||
|
.sidebar-item.sub { padding-left: 30px; height: 26px; font-size: var(--text-sm); }
|
||||||
|
.sidebar-item.sub .icon { width: 12px; height: 12px; }
|
||||||
|
.sidebar-empty {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { state, toggleSort, setQuery, toggleIncludeMessageBodies } from '../store.js';
|
||||||
|
import { formatProjectLabel } from '../utils.js';
|
||||||
|
|
||||||
|
// --- Route info ---
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const isListView = computed(() => {
|
||||||
|
return route.name === 'SessionList' || route.name === 'MemoryList';
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSearchMsgsToggle = computed(() => {
|
||||||
|
return route.name === 'SessionList';
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Breadcrumb computation ---
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
const name = route.name;
|
||||||
|
const crumbs = [];
|
||||||
|
|
||||||
|
if (name === 'SessionList') {
|
||||||
|
crumbs.push({ label: 'Sessions', terminal: true });
|
||||||
|
if (state.projectFilter !== 'all') {
|
||||||
|
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
|
||||||
|
}
|
||||||
|
} else if (name === 'SessionDetail') {
|
||||||
|
crumbs.push({ label: 'Sessions', to: '/sessions' });
|
||||||
|
const s = state.sessions.find(x => x.id === route.params.id);
|
||||||
|
crumbs.push({ label: s?.title || route.params.id, terminal: true });
|
||||||
|
} else if (name === 'SubagentDetail') {
|
||||||
|
crumbs.push({ label: 'Sessions', to: '/sessions' });
|
||||||
|
const s = state.sessions.find(x => x.id === route.params.id);
|
||||||
|
crumbs.push({ label: (s?.title || '').slice(0, 30) || route.params.id, to: `/sessions/${route.params.id}` });
|
||||||
|
crumbs.push({ label: route.params.agentId, terminal: true });
|
||||||
|
} else if (name === 'MemoryList') {
|
||||||
|
crumbs.push({ label: 'Memory', terminal: true });
|
||||||
|
if (state.projectFilter !== 'all') {
|
||||||
|
crumbs.push({ label: formatProjectLabel(state.projectFilter), terminal: true });
|
||||||
|
}
|
||||||
|
} else if (name === 'MemoryDetail') {
|
||||||
|
crumbs.push({ label: 'Memory', to: '/memory' });
|
||||||
|
const m = state.memories.find(x => x.id === route.params.id);
|
||||||
|
const filename = (m?.path || '').split('/').pop();
|
||||||
|
crumbs.push({ label: filename, terminal: true, filename: true });
|
||||||
|
} else if (name === 'Usage') {
|
||||||
|
crumbs.push({ label: 'Usage', terminal: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return crumbs;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
const searchInput = ref(null);
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
function handleSearch(e) {
|
||||||
|
const value = e.target.value;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
setQuery(value);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keyboard shortcut: / to focus search ---
|
||||||
|
function handleKeydown(e) {
|
||||||
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
|
e.preventDefault();
|
||||||
|
searchInput.value?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Sort ---
|
||||||
|
function handleToggleSort() {
|
||||||
|
toggleSort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleSearchMsgs() {
|
||||||
|
toggleIncludeMessageBodies();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<template v-for="(crumb, i) in breadcrumbs" :key="i">
|
||||||
|
<span v-if="i > 0" class="crumb-sep">/</span>
|
||||||
|
<router-link
|
||||||
|
v-if="crumb.to"
|
||||||
|
class="crumb"
|
||||||
|
:to="crumb.to"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
</router-link>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="crumb terminal"
|
||||||
|
:class="{ filename: crumb.filename }"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-spacer"></div>
|
||||||
|
|
||||||
|
<!-- Search + sort controls (list views only) -->
|
||||||
|
<template v-if="isListView">
|
||||||
|
<div class="toolbar-search">
|
||||||
|
<svg class="toolbar-search-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||||
|
<circle cx="6.5" cy="6.5" r="4"/>
|
||||||
|
<path d="M10 10l3.5 3.5"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
@input="handleSearch"
|
||||||
|
/>
|
||||||
|
<span class="toolbar-search-kbd">/</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="showSearchMsgsToggle"
|
||||||
|
class="filter-toggle"
|
||||||
|
:class="{ active: state.includeMessageBodies }"
|
||||||
|
@click="handleToggleSearchMsgs"
|
||||||
|
title="Include message bodies in search"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||||
|
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||||
|
<path d="M2 5.5l6 3.5 6-3.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="sort-group"
|
||||||
|
:class="{ desc: state.sortDesc, asc: !state.sortDesc }"
|
||||||
|
@click="handleToggleSort"
|
||||||
|
>
|
||||||
|
<span class="label">{{ state.sortDesc ? 'newest' : 'oldest' }}</span>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||||
|
<path class="arrow-up" d="M8 3v5M5.5 5.5L8 3l2.5 2.5"/>
|
||||||
|
<path class="arrow-down" d="M8 8v5M5.5 10.5L8 13l2.5-2.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid var(--hairline-strong);
|
||||||
|
background: rgba(0, 0, 0, 0.15);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
line-height: 1;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb:hover {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb.terminal {
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb.terminal:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb.filename {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb-sep {
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search {
|
||||||
|
width: 220px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search input {
|
||||||
|
width: 100%;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 30px 0 26px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--fg);
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search input::placeholder {
|
||||||
|
color: var(--muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search input:focus {
|
||||||
|
outline: 0;
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search-kbd {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-search input:focus ~ .toolbar-search-kbd,
|
||||||
|
.toolbar-search input:not(:placeholder-shown) ~ .toolbar-search-kbd {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle {
|
||||||
|
height: 26px;
|
||||||
|
width: 26px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: all 0.1s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle:hover {
|
||||||
|
color: var(--fg-2);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle.active {
|
||||||
|
color: var(--accent-2);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-toggle svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 4px 0 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group:hover {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group .label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group .arrow-up,
|
||||||
|
.sort-group .arrow-down {
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group.desc .arrow-up {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group.desc .arrow-down {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group.asc .arrow-up {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-group.asc .arrow-down {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// Data loading layer -- bridges Electron IPC (window.obelisk.*) to reactive store.
|
||||||
|
// All DB access goes through this module.
|
||||||
|
|
||||||
|
import { markRaw } from 'vue';
|
||||||
|
import { state, clearUndo } from './store.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load initial data from the DB and populate state.memories, state.sessions,
|
||||||
|
* and state.projects.
|
||||||
|
*/
|
||||||
|
export async function loadInitialData() {
|
||||||
|
const [rawMemories, rawSessions, stats, projects] = await Promise.all([
|
||||||
|
window.obelisk.getMemories(),
|
||||||
|
window.obelisk.getSessions(),
|
||||||
|
window.obelisk.getStats(),
|
||||||
|
window.obelisk.getProjects()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Transform memories: DB records -> render-layer shape
|
||||||
|
state.memories = (rawMemories || []).map(m => ({
|
||||||
|
...m,
|
||||||
|
ts: m.created_at ? new Date(m.created_at).getTime() : 0,
|
||||||
|
archived: !!m.deleted_at,
|
||||||
|
archivedAt: m.deleted_at ? new Date(m.deleted_at).getTime() : null,
|
||||||
|
health: 'ok',
|
||||||
|
anchors: [],
|
||||||
|
markdown: null // loaded on demand via loadMemoryMarkdown
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sessions: keep DB shape, add empty messages array for on-demand loading
|
||||||
|
state.sessions = (rawSessions || []).map(s => ({
|
||||||
|
...s,
|
||||||
|
messages: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
state.projects = projects || [];
|
||||||
|
state.stats = stats || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load full detail for a session: messages with inline tool_calls (each with
|
||||||
|
* result), summaries, subagents, and workflow data.
|
||||||
|
*
|
||||||
|
* Returns the assembled session object (also updates state.sessions entry).
|
||||||
|
*/
|
||||||
|
export async function loadSessionDetail(sessionId) {
|
||||||
|
const [messages, toolCalls, toolResults, subagents, workflows, summaries] =
|
||||||
|
await Promise.all([
|
||||||
|
window.obelisk.getSessionMessages(sessionId),
|
||||||
|
window.obelisk.getSessionToolCalls(sessionId),
|
||||||
|
window.obelisk.getSessionToolResults(sessionId),
|
||||||
|
window.obelisk.getSessionSubagents(sessionId),
|
||||||
|
window.obelisk.getSessionWorkflows(sessionId),
|
||||||
|
window.obelisk.getSessionSummaries(sessionId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Index tool results by tool_use_id for fast lookup
|
||||||
|
const resultsByCallId = {};
|
||||||
|
for (const r of (toolResults || [])) {
|
||||||
|
resultsByCallId[r.tool_use_id] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index subagents by parent_tool_use_id
|
||||||
|
const subagentsByCallId = {};
|
||||||
|
for (const sa of (subagents || [])) {
|
||||||
|
if (sa.parent_tool_use_id) {
|
||||||
|
subagentsByCallId[sa.parent_tool_use_id] = sa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group tool_calls by message_uuid, attaching result and subagent inline
|
||||||
|
const callsByMessageUuid = {};
|
||||||
|
for (const tc of (toolCalls || [])) {
|
||||||
|
const call = {
|
||||||
|
id: tc.id,
|
||||||
|
name: tc.name,
|
||||||
|
input_json: tc.input_json,
|
||||||
|
result: resultsByCallId[tc.id] || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach subagent data if present
|
||||||
|
const sa = subagentsByCallId[tc.id];
|
||||||
|
if (sa) {
|
||||||
|
call.subagent = {
|
||||||
|
agent_id: sa.agent_id,
|
||||||
|
agent_type: sa.agent_type,
|
||||||
|
description: sa.description
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgUuid = tc.message_uuid;
|
||||||
|
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||||
|
callsByMessageUuid[msgUuid].push(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach workflow data to Workflow tool calls
|
||||||
|
for (const wf of (workflows || [])) {
|
||||||
|
for (const calls of Object.values(callsByMessageUuid)) {
|
||||||
|
for (const call of calls) {
|
||||||
|
if (call.name === 'Workflow' && !call.workflow) {
|
||||||
|
const resultText = call.result?.content || '';
|
||||||
|
if (resultText.includes(wf.run_id) || resultText.includes(wf.workflow_name || '___none___')) {
|
||||||
|
call.workflow = {
|
||||||
|
run_id: wf.run_id,
|
||||||
|
workflow_name: wf.workflow_name,
|
||||||
|
status: wf.status,
|
||||||
|
duration_ms: wf.duration_ms,
|
||||||
|
total_tokens: wf.total_tokens,
|
||||||
|
agent_count: wf.agent_count,
|
||||||
|
agents: (wf.agents || []).map(a => ({
|
||||||
|
agent_id: a.agent_id,
|
||||||
|
phase: a.phase,
|
||||||
|
label: a.label,
|
||||||
|
state: a.state,
|
||||||
|
tokens: a.tokens,
|
||||||
|
duration_ms: a.duration_ms,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index summaries by session
|
||||||
|
const sessionSummaries = (summaries || []).map(s => ({
|
||||||
|
source: s.source,
|
||||||
|
content: s.content,
|
||||||
|
timestamp: s.timestamp
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Assemble messages with tool_calls inline
|
||||||
|
const rawAssembled = (messages || []).map(msg => {
|
||||||
|
const assembled = {
|
||||||
|
uuid: msg.uuid,
|
||||||
|
type: msg.type || msg.role,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
text: msg.text,
|
||||||
|
content_type: msg.content_type || null,
|
||||||
|
is_meta: msg.is_meta || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const calls = callsByMessageUuid[msg.uuid];
|
||||||
|
if (calls && calls.length > 0) {
|
||||||
|
assembled.tool_calls = calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembled;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge adjacent assistant messages:
|
||||||
|
// - tool_result user messages are skipped (results shown inside tool_call panels)
|
||||||
|
// - consecutive tool_use messages (separated by tool_results) merge into one
|
||||||
|
// - thinking messages merge into the next non-thinking assistant message
|
||||||
|
const assembledMessages = [];
|
||||||
|
for (let i = 0; i < rawAssembled.length; i++) {
|
||||||
|
const msg = rawAssembled[i];
|
||||||
|
|
||||||
|
// Skip tool_result user messages
|
||||||
|
if (msg.content_type === 'tool_result') continue;
|
||||||
|
|
||||||
|
// For thinking messages, collect consecutive thinking blocks and attach to the next assistant
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||||
|
const thinkingParts = [msg.text || ''];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||||
|
thinkingParts.push(rawAssembled[j].text || '');
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||||
|
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tool_use assistant messages, absorb subsequent tool_use (skipping tool_results)
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||||
|
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||||
|
if (msg._thinking) merged._thinking = msg._thinking;
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < rawAssembled.length) {
|
||||||
|
const next = rawAssembled[j];
|
||||||
|
if (next.content_type === 'tool_result') { j++; continue; }
|
||||||
|
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||||
|
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||||
|
if (next.text && !merged.text) merged.text = next.text;
|
||||||
|
j++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assembledMessages.push(merged);
|
||||||
|
i = j - 1;
|
||||||
|
} else {
|
||||||
|
const out = { ...msg };
|
||||||
|
if (msg._thinking) out._thinking = msg._thinking;
|
||||||
|
assembledMessages.push(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach workflow data if present
|
||||||
|
const workflow = (workflows && workflows.length > 0) ? workflows[0] : null;
|
||||||
|
|
||||||
|
// Build assembled session object
|
||||||
|
const session = state.sessions.find(s => s.id === sessionId);
|
||||||
|
const assembled = {
|
||||||
|
...(session || {}),
|
||||||
|
id: sessionId,
|
||||||
|
messages: assembledMessages
|
||||||
|
};
|
||||||
|
|
||||||
|
if (workflow) {
|
||||||
|
assembled.workflow = workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in-place in state.sessions
|
||||||
|
const idx = state.sessions.findIndex(s => s.id === sessionId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
state.sessions[idx] = assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load full detail for a subagent conversation.
|
||||||
|
* Returns assembled messages with tool_calls inline.
|
||||||
|
*/
|
||||||
|
export async function loadSubagentDetail(agentId) {
|
||||||
|
const [messages, toolCalls, toolResults] = await Promise.all([
|
||||||
|
window.obelisk.getSubagentMessages(agentId),
|
||||||
|
window.obelisk.getSubagentToolCalls(agentId),
|
||||||
|
window.obelisk.getSubagentToolResults(agentId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resultsByCallId = {};
|
||||||
|
for (const r of (toolResults || [])) {
|
||||||
|
resultsByCallId[r.tool_use_id] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callsByMessageUuid = {};
|
||||||
|
for (const tc of (toolCalls || [])) {
|
||||||
|
const call = {
|
||||||
|
id: tc.id,
|
||||||
|
name: tc.name,
|
||||||
|
input_json: tc.input_json,
|
||||||
|
result: resultsByCallId[tc.id] || null
|
||||||
|
};
|
||||||
|
const msgUuid = tc.message_uuid;
|
||||||
|
if (!callsByMessageUuid[msgUuid]) callsByMessageUuid[msgUuid] = [];
|
||||||
|
callsByMessageUuid[msgUuid].push(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawAssembled = (messages || []).map(msg => {
|
||||||
|
const assembled = {
|
||||||
|
uuid: msg.uuid,
|
||||||
|
type: msg.type || msg.role,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
text: msg.text,
|
||||||
|
content_type: msg.content_type || null,
|
||||||
|
is_meta: msg.is_meta || 0
|
||||||
|
};
|
||||||
|
const calls = callsByMessageUuid[msg.uuid];
|
||||||
|
if (calls && calls.length > 0) assembled.tool_calls = calls;
|
||||||
|
return assembled;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same merging logic as session detail
|
||||||
|
const assembledMessages = [];
|
||||||
|
for (let i = 0; i < rawAssembled.length; i++) {
|
||||||
|
const msg = rawAssembled[i];
|
||||||
|
if (msg.content_type === 'tool_result') continue;
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'thinking') {
|
||||||
|
const thinkingParts = [msg.text || ''];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type === 'thinking') {
|
||||||
|
thinkingParts.push(rawAssembled[j].text || '');
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
if (j < rawAssembled.length && rawAssembled[j].type === 'assistant' && rawAssembled[j].content_type !== 'thinking') {
|
||||||
|
rawAssembled[j]._thinking = thinkingParts.join('\n\n');
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assembledMessages.push({ ...msg, text: thinkingParts.join('\n\n'), content_type: 'thinking' });
|
||||||
|
i = j - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg.type === 'assistant' && msg.content_type === 'tool_use') {
|
||||||
|
const merged = { ...msg, tool_calls: [...(msg.tool_calls || [])] };
|
||||||
|
if (msg._thinking) merged._thinking = msg._thinking;
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < rawAssembled.length) {
|
||||||
|
const next = rawAssembled[j];
|
||||||
|
if (next.content_type === 'tool_result') { j++; continue; }
|
||||||
|
if (next.type === 'assistant' && next.content_type === 'tool_use') {
|
||||||
|
if (next.tool_calls) merged.tool_calls.push(...next.tool_calls);
|
||||||
|
if (next.text && !merged.text) merged.text = next.text;
|
||||||
|
j++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assembledMessages.push(merged);
|
||||||
|
i = j - 1;
|
||||||
|
} else {
|
||||||
|
const out = { ...msg };
|
||||||
|
if (msg._thinking) out._thinking = msg._thinking;
|
||||||
|
assembledMessages.push(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assembledMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_LIMIT = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a message text was truncated during indexing.
|
||||||
|
*/
|
||||||
|
export function isTextTruncated(text) {
|
||||||
|
return text && text.length >= TEXT_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the full untruncated text for a message from its source JSONL.
|
||||||
|
* Returns the full text string or null.
|
||||||
|
*/
|
||||||
|
export async function loadFullText(uuid) {
|
||||||
|
try {
|
||||||
|
return await window.obelisk.getMessageFullText(uuid);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the markdown content of a memory file.
|
||||||
|
* Returns the content string or null on failure.
|
||||||
|
*/
|
||||||
|
export async function loadMemoryMarkdown(memoryPath) {
|
||||||
|
try {
|
||||||
|
const content = await window.obelisk.readMemoryFile(memoryPath);
|
||||||
|
return content || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive a memory by id. Updates state after successful IPC call.
|
||||||
|
*/
|
||||||
|
export async function archiveMemory(id) {
|
||||||
|
await window.obelisk.archiveMemory(id);
|
||||||
|
const mem = state.memories.find(m => m.id === id);
|
||||||
|
if (mem) {
|
||||||
|
mem.archived = true;
|
||||||
|
mem.archivedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore an archived memory by id. Updates state after successful IPC call.
|
||||||
|
*/
|
||||||
|
export async function restoreMemory(id) {
|
||||||
|
await window.obelisk.restoreMemory(id);
|
||||||
|
const mem = state.memories.find(m => m.id === id);
|
||||||
|
if (mem) {
|
||||||
|
mem.archived = false;
|
||||||
|
mem.archivedAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Vue 3 application entry point for Obelisk.
|
||||||
|
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router.js';
|
||||||
|
import { loadInitialData } from './data.js';
|
||||||
|
|
||||||
|
// Import all original CSS globally
|
||||||
|
import '../styles/base.css';
|
||||||
|
import '../styles/sidebar.css';
|
||||||
|
import '../styles/toolbar.css';
|
||||||
|
import '../styles/list.css';
|
||||||
|
import '../styles/detail.css';
|
||||||
|
import '../styles/statusbar.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
// Load data before the first render completes
|
||||||
|
router.isReady().then(() => {
|
||||||
|
loadInitialData();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Vue Router configuration for Obelisk.
|
||||||
|
// Routes map to the main content views; sidebar navigation drives route changes.
|
||||||
|
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||||
|
|
||||||
|
// Lazy-loaded view components (will be created as Vue SFCs later)
|
||||||
|
const SessionList = () => import('./views/SessionList.vue');
|
||||||
|
const SessionDetail = () => import('./views/SessionDetail.vue');
|
||||||
|
const SubagentDetail = () => import('./views/SubagentDetail.vue');
|
||||||
|
const MemoryList = () => import('./views/MemoryList.vue');
|
||||||
|
const MemoryDetail = () => import('./views/MemoryDetail.vue');
|
||||||
|
const Usage = () => import('./views/Usage.vue');
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/sessions',
|
||||||
|
name: 'SessionList',
|
||||||
|
component: SessionList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sessions/:id',
|
||||||
|
name: 'SessionDetail',
|
||||||
|
component: SessionDetail,
|
||||||
|
props: true,
|
||||||
|
meta: { keepAlive: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/sessions/:id/agent/:agentId',
|
||||||
|
name: 'SubagentDetail',
|
||||||
|
component: SubagentDetail,
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/memory',
|
||||||
|
name: 'MemoryList',
|
||||||
|
component: MemoryList
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/memory/:id',
|
||||||
|
name: 'MemoryDetail',
|
||||||
|
component: MemoryDetail,
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/usage',
|
||||||
|
name: 'Usage',
|
||||||
|
component: Usage
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/memory'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Catch-all redirect
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/memory'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Reactive store -- Vue 3 reactive() replaces the plain object from state.js.
|
||||||
|
// All state fields are ported; action functions mutate the reactive state.
|
||||||
|
|
||||||
|
import { reactive, markRaw } from 'vue';
|
||||||
|
|
||||||
|
export const state = reactive({
|
||||||
|
memories: [],
|
||||||
|
sessions: [],
|
||||||
|
projects: [],
|
||||||
|
stats: {},
|
||||||
|
route: 'memory',
|
||||||
|
view: 'active', // 'active' | 'archived'
|
||||||
|
mode: 'list', // 'list' | 'detail'
|
||||||
|
detailId: null,
|
||||||
|
subagentId: null,
|
||||||
|
subagentDescription: null,
|
||||||
|
pendingFocusUuid: null,
|
||||||
|
query: '',
|
||||||
|
projectFilter: 'all',
|
||||||
|
projectSearch: '',
|
||||||
|
sortDesc: true,
|
||||||
|
includeMessageBodies: false,
|
||||||
|
cursorId: null,
|
||||||
|
selection: markRaw(new Set()),
|
||||||
|
showSource: false,
|
||||||
|
lastArchiveSnapshot: null,
|
||||||
|
undoTimer: null,
|
||||||
|
undoExpires: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Platform detection
|
||||||
|
export const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||||
|
|
||||||
|
// SVG icon constants
|
||||||
|
export const FOLDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><path d="M2.5 4h4l1.5 1.5h5.5v7a1 1 0 0 1-1 1h-10a1 1 0 0 1-1-1v-8z"/></svg>`;
|
||||||
|
export const FILE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 2h6l3 3v9a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M9.5 2v3h3"/></svg>`;
|
||||||
|
|
||||||
|
// --- Action functions ---
|
||||||
|
|
||||||
|
export function setRoute(route) {
|
||||||
|
state.route = route;
|
||||||
|
state.mode = 'list';
|
||||||
|
state.detailId = null;
|
||||||
|
state.cursorId = null;
|
||||||
|
state.selection = markRaw(new Set());
|
||||||
|
state.query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setView(v) {
|
||||||
|
state.route = 'memory';
|
||||||
|
state.view = v;
|
||||||
|
state.mode = 'list';
|
||||||
|
state.detailId = null;
|
||||||
|
state.cursorId = null;
|
||||||
|
state.selection = markRaw(new Set());
|
||||||
|
state.projectFilter = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setProject(p) {
|
||||||
|
state.projectFilter = p;
|
||||||
|
state.cursorId = null;
|
||||||
|
state.selection = markRaw(new Set());
|
||||||
|
state.mode = 'list';
|
||||||
|
state.detailId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleSort() {
|
||||||
|
state.sortDesc = !state.sortDesc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enterDetail(id) {
|
||||||
|
state.detailId = id;
|
||||||
|
state.mode = 'detail';
|
||||||
|
state.showSource = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exitDetail() {
|
||||||
|
if (state.subagentId) {
|
||||||
|
state.subagentId = null;
|
||||||
|
state.subagentDescription = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.mode = 'list';
|
||||||
|
state.detailId = null;
|
||||||
|
state.pendingFocusUuid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateToSession(sessionId, focusUuid) {
|
||||||
|
state.route = 'sessions';
|
||||||
|
state.mode = 'detail';
|
||||||
|
state.detailId = sessionId;
|
||||||
|
state.subagentId = null;
|
||||||
|
state.subagentDescription = null;
|
||||||
|
state.pendingFocusUuid = focusUuid || null;
|
||||||
|
state.query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateToSubagent(agentId, description) {
|
||||||
|
state.subagentId = agentId;
|
||||||
|
state.subagentDescription = description || agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCursor(id, opts = {}) {
|
||||||
|
state.cursorId = id;
|
||||||
|
if (!opts.keepSelection) {
|
||||||
|
state.selection = markRaw(new Set());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setQuery(q) {
|
||||||
|
state.query = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setProjectSearch(q) {
|
||||||
|
state.projectSearch = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleIncludeMessageBodies() {
|
||||||
|
state.includeMessageBodies = !state.includeMessageBodies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUndo() {
|
||||||
|
state.lastArchiveSnapshot = null;
|
||||||
|
if (state.undoTimer) {
|
||||||
|
clearInterval(state.undoTimer);
|
||||||
|
state.undoTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Utility functions ported from the vanilla JS utils.js.
|
||||||
|
// Pure helpers with no side-effects on global state (except formatProjectLabel which reads store).
|
||||||
|
|
||||||
|
import { state } from './store.js';
|
||||||
|
|
||||||
|
// --- Time / formatting ---
|
||||||
|
|
||||||
|
export function pad2(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
export function isSameDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtListTime(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const now = new Date();
|
||||||
|
const hhmm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
if (isSameDay(d, now)) return hhmm;
|
||||||
|
const mmdd = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return `${mmdd} ${hhmm}`;
|
||||||
|
return `${d.getFullYear()}/${mmdd} ${hhmm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtRelative(ts) {
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
const min = 60000, hr = 3600000, day = 86400000;
|
||||||
|
if (diff < 0) return 'in the future';
|
||||||
|
if (diff < min) return 'just now';
|
||||||
|
if (diff < hr) return Math.floor(diff / min) + 'm ago';
|
||||||
|
if (diff < day) return Math.floor(diff / hr) + 'h ago';
|
||||||
|
if (diff < day * 30) return Math.floor(diff / day) + 'd ago';
|
||||||
|
if (diff < day * 365) return Math.floor(diff / (day * 30)) + 'mo ago';
|
||||||
|
return Math.floor(diff / (day * 365)) + 'y ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtClockTime(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtSize(bytes) {
|
||||||
|
if (!bytes) return '-';
|
||||||
|
if (bytes < 1024) return bytes + 'B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'K';
|
||||||
|
return (bytes / 1024 / 1024).toFixed(1) + 'M';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML / Markdown ---
|
||||||
|
|
||||||
|
export function escapeHTML(s) {
|
||||||
|
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightPlain(text, query) {
|
||||||
|
if (!query) return escapeHTML(text);
|
||||||
|
const safe = escapeHTML(text);
|
||||||
|
const q = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return safe.replace(new RegExp(q, 'gi'), m => `<mark>${m}</mark>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeMarkdown(html) {
|
||||||
|
return html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/\son\w+="[^"]*"/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightTextNodes(rootEl, query) {
|
||||||
|
if (!query) return;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, null);
|
||||||
|
const nodes = [];
|
||||||
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const text = node.nodeValue;
|
||||||
|
if (!text) continue;
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
if (!lower.includes(q)) continue;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
let last = 0, i = lower.indexOf(q);
|
||||||
|
while (i !== -1) {
|
||||||
|
if (i > last) frag.appendChild(document.createTextNode(text.slice(last, i)));
|
||||||
|
const mark = document.createElement('mark');
|
||||||
|
mark.textContent = text.slice(i, i + q.length);
|
||||||
|
frag.appendChild(mark);
|
||||||
|
last = i + q.length;
|
||||||
|
i = lower.indexOf(q, last);
|
||||||
|
}
|
||||||
|
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
|
||||||
|
node.parentNode.replaceChild(frag, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(text, opts = {}) {
|
||||||
|
if (text == null) return '';
|
||||||
|
// marked is loaded globally via CDN in index.html
|
||||||
|
const html = sanitizeMarkdown(window.marked.parse(text));
|
||||||
|
const cls = opts.variant === 'msg' ? 'markdown-msg'
|
||||||
|
: opts.variant === 'compact' ? 'markdown-compact'
|
||||||
|
: 'markdown-body';
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = cls;
|
||||||
|
container.innerHTML = html;
|
||||||
|
if (opts.query) highlightTextNodes(container, opts.query.trim());
|
||||||
|
return container.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Duration / tokens / tooltip ---
|
||||||
|
|
||||||
|
export function fmtDuration(ms) {
|
||||||
|
if (!ms) return '—';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
const parts = [];
|
||||||
|
if (d) parts.push(`${d}d`);
|
||||||
|
if (h) parts.push(`${h}h`);
|
||||||
|
if (m) parts.push(`${m}m`);
|
||||||
|
if (sec || !parts.length) parts.push(`${sec}s`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTokens(n) {
|
||||||
|
if (!n) return '0';
|
||||||
|
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(2) + 'B';
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTooltipDate(isoDay) {
|
||||||
|
const d = new Date(isoDay + 'T00:00:00');
|
||||||
|
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
const day = d.getDate();
|
||||||
|
const suffix = day === 1 || day === 21 || day === 31 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th';
|
||||||
|
const thisYear = new Date().getFullYear();
|
||||||
|
if (d.getFullYear() === thisYear) return `${months[d.getMonth()]} ${day}${suffix}`;
|
||||||
|
return `${months[d.getMonth()]} ${day}${suffix}, ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function positionTooltip(el, x, y) {
|
||||||
|
const pad = 12;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
let left = x + pad;
|
||||||
|
if (left + rect.width > window.innerWidth - pad) left = x - rect.width - pad;
|
||||||
|
el.style.left = left + 'px';
|
||||||
|
el.style.top = (y - 28) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project label ---
|
||||||
|
|
||||||
|
export function formatProjectLabel(slug) {
|
||||||
|
if (!slug) return '(no project)';
|
||||||
|
const session = state.sessions.find(s => s.project === slug && s.project_path);
|
||||||
|
if (session?.project_path) {
|
||||||
|
const parts = session.project_path.split('/');
|
||||||
|
return parts.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
return slug.replace(/^-/, '');
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup>
|
||||||
|
defineOptions({ name: 'MemoryDetail' });
|
||||||
|
defineProps({ id: String });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="view-placeholder">MemoryDetail view for {{ id }} (TODO)</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, nextTick, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { state, FOLDER_SVG, clearUndo } from '../store.js';
|
||||||
|
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime, fmtRelative, renderMarkdown } from '../utils.js';
|
||||||
|
import { loadMemoryMarkdown, archiveMemory, restoreMemory } from '../data.js';
|
||||||
|
|
||||||
|
defineOptions({ name: 'MemoryList' });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const listWrapRef = ref(null);
|
||||||
|
const undoCountdown = ref(0);
|
||||||
|
|
||||||
|
// --- Filtered/sorted memories ---
|
||||||
|
|
||||||
|
const visibleMemories = computed(() => {
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
return state.memories
|
||||||
|
.filter(m => {
|
||||||
|
if (state.view === 'archived') return m.archived;
|
||||||
|
return !m.archived;
|
||||||
|
})
|
||||||
|
.filter(m => state.projectFilter === 'all' || m.project === state.projectFilter)
|
||||||
|
.filter(m => !q || (m.path || '').toLowerCase().includes(q) || (m.summary || '').toLowerCase().includes(q))
|
||||||
|
.sort((a, b) => state.sortDesc ? b.ts - a.ts : a.ts - b.ts);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showProjectPrefix = computed(() => state.projectFilter === 'all');
|
||||||
|
|
||||||
|
// --- Detail state ---
|
||||||
|
|
||||||
|
const detailMemory = ref(null);
|
||||||
|
const detailMarkdown = ref(null);
|
||||||
|
const showSource = ref(false);
|
||||||
|
const loadingMarkdown = ref(false);
|
||||||
|
|
||||||
|
const showDetail = computed(() => detailMemory.value !== null);
|
||||||
|
|
||||||
|
// --- Row helpers ---
|
||||||
|
|
||||||
|
function dominantRowStatus(m) {
|
||||||
|
if (m.health === 'broken') return 'broken';
|
||||||
|
if (m.health === 'partial') return 'partial';
|
||||||
|
if (m.archived) return 'archived';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusGlyphs(status) {
|
||||||
|
if (!status) return '';
|
||||||
|
const map = {
|
||||||
|
broken: `<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 1.5l5.5 9.5h-11z M7 5v3M7 9.2v.6"/></svg>`,
|
||||||
|
partial: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="3.5"/></svg>`,
|
||||||
|
archived: `<svg viewBox="0 0 14 14" fill="currentColor"><circle cx="7" cy="7" r="2.5"/></svg>`
|
||||||
|
};
|
||||||
|
return map[status] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathHTML(m) {
|
||||||
|
return highlightPlain(m.path || '', state.query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryHTML(m) {
|
||||||
|
return highlightPlain(m.summary || '', state.query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeLabel(m) {
|
||||||
|
return fmtListTime(m.ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectLabel(m) {
|
||||||
|
return escapeHTML(formatProjectLabel(m.project));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Selection ---
|
||||||
|
|
||||||
|
function toggleSelection(id) {
|
||||||
|
const s = new Set(state.selection);
|
||||||
|
if (s.has(id)) s.delete(id);
|
||||||
|
else s.add(id);
|
||||||
|
state.selection = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cursor navigation ---
|
||||||
|
|
||||||
|
function moveCursor(direction) {
|
||||||
|
const items = visibleMemories.value;
|
||||||
|
if (!items.length) return;
|
||||||
|
const curIdx = items.findIndex(m => m.id === state.cursorId);
|
||||||
|
let next;
|
||||||
|
if (curIdx === -1) {
|
||||||
|
next = 0;
|
||||||
|
} else {
|
||||||
|
next = curIdx + direction;
|
||||||
|
if (next < 0) next = 0;
|
||||||
|
if (next >= items.length) next = items.length - 1;
|
||||||
|
}
|
||||||
|
state.cursorId = items[next].id;
|
||||||
|
nextTick(() => ensureVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVisible() {
|
||||||
|
if (!listWrapRef.value || !state.cursorId) return;
|
||||||
|
const cursorEl = listWrapRef.value.querySelector(`.row[data-id="${state.cursorId}"]`);
|
||||||
|
if (!cursorEl) return;
|
||||||
|
const elRect = cursorEl.getBoundingClientRect();
|
||||||
|
const wrapRect = listWrapRef.value.getBoundingClientRect();
|
||||||
|
if (elRect.top < wrapRect.top + 30) {
|
||||||
|
listWrapRef.value.scrollTop -= (wrapRect.top + 30 - elRect.top);
|
||||||
|
} else if (elRect.bottom > wrapRect.bottom - 10) {
|
||||||
|
listWrapRef.value.scrollTop += (elRect.bottom - wrapRect.bottom + 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Open detail ---
|
||||||
|
|
||||||
|
async function openDetail(m) {
|
||||||
|
detailMemory.value = m;
|
||||||
|
showSource.value = false;
|
||||||
|
loadingMarkdown.value = true;
|
||||||
|
detailMarkdown.value = null;
|
||||||
|
|
||||||
|
if (m.markdown === null && m.path) {
|
||||||
|
m.markdown = await loadMemoryMarkdown(m.path);
|
||||||
|
}
|
||||||
|
detailMarkdown.value = m.markdown;
|
||||||
|
loadingMarkdown.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
detailMemory.value = null;
|
||||||
|
detailMarkdown.value = null;
|
||||||
|
showSource.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSourceView() {
|
||||||
|
showSource.value = !showSource.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Row click ---
|
||||||
|
|
||||||
|
function onRowClick(m) {
|
||||||
|
state.cursorId = m.id;
|
||||||
|
openDetail(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Archive/restore with undo ---
|
||||||
|
|
||||||
|
const undoSnapshot = ref(null);
|
||||||
|
let undoTimer = null;
|
||||||
|
|
||||||
|
async function doArchive(ids) {
|
||||||
|
const targets = ids || (state.cursorId ? [state.cursorId] : []);
|
||||||
|
if (!targets.length) return;
|
||||||
|
undoSnapshot.value = { action: 'archive', ids: [...targets] };
|
||||||
|
undoCountdown.value = 5;
|
||||||
|
for (const id of targets) {
|
||||||
|
await archiveMemory(id);
|
||||||
|
}
|
||||||
|
startUndoTimer();
|
||||||
|
// Move cursor if needed
|
||||||
|
if (targets.includes(state.cursorId)) {
|
||||||
|
const items = visibleMemories.value;
|
||||||
|
if (items.length) state.cursorId = items[0].id;
|
||||||
|
else state.cursorId = null;
|
||||||
|
}
|
||||||
|
if (showDetail.value && targets.includes(detailMemory.value?.id)) {
|
||||||
|
closeDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRestore(ids) {
|
||||||
|
const targets = ids || (state.cursorId ? [state.cursorId] : []);
|
||||||
|
if (!targets.length) return;
|
||||||
|
undoSnapshot.value = { action: 'restore', ids: [...targets] };
|
||||||
|
undoCountdown.value = 5;
|
||||||
|
for (const id of targets) {
|
||||||
|
await restoreMemory(id);
|
||||||
|
}
|
||||||
|
startUndoTimer();
|
||||||
|
if (targets.includes(state.cursorId)) {
|
||||||
|
const items = visibleMemories.value;
|
||||||
|
if (items.length) state.cursorId = items[0].id;
|
||||||
|
else state.cursorId = null;
|
||||||
|
}
|
||||||
|
if (showDetail.value && targets.includes(detailMemory.value?.id)) {
|
||||||
|
closeDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undoAction() {
|
||||||
|
if (!undoSnapshot.value) return;
|
||||||
|
const { action, ids } = undoSnapshot.value;
|
||||||
|
for (const id of ids) {
|
||||||
|
if (action === 'archive') await restoreMemory(id);
|
||||||
|
else await archiveMemory(id);
|
||||||
|
}
|
||||||
|
undoSnapshot.value = null;
|
||||||
|
undoCountdown.value = 0;
|
||||||
|
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUndoTimer() {
|
||||||
|
if (undoTimer) clearInterval(undoTimer);
|
||||||
|
undoCountdown.value = 5;
|
||||||
|
undoTimer = setInterval(() => {
|
||||||
|
undoCountdown.value--;
|
||||||
|
if (undoCountdown.value <= 0) {
|
||||||
|
clearInterval(undoTimer);
|
||||||
|
undoTimer = null;
|
||||||
|
undoSnapshot.value = null;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Detail action ---
|
||||||
|
|
||||||
|
function detailArchiveRestore() {
|
||||||
|
if (!detailMemory.value) return;
|
||||||
|
if (detailMemory.value.archived) {
|
||||||
|
doRestore([detailMemory.value.id]);
|
||||||
|
} else {
|
||||||
|
doArchive([detailMemory.value.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Detail markdown rendering ---
|
||||||
|
|
||||||
|
const renderedMarkdown = computed(() => {
|
||||||
|
if (detailMarkdown.value == null) return null;
|
||||||
|
if (showSource.value) return null; // handled by pre block in template
|
||||||
|
return renderMarkdown(detailMarkdown.value, { variant: 'body' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Keyboard handler ---
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
// Do not handle if user is typing in an input
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
if (showDetail.value) {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); closeDetail(); return; }
|
||||||
|
if (e.key === 'd' || e.key === 'D') { e.preventDefault(); detailArchiveRestore(); return; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'j':
|
||||||
|
e.preventDefault();
|
||||||
|
moveCursor(1);
|
||||||
|
break;
|
||||||
|
case 'k':
|
||||||
|
e.preventDefault();
|
||||||
|
moveCursor(-1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.cursorId) {
|
||||||
|
const m = visibleMemories.value.find(x => x.id === state.cursorId);
|
||||||
|
if (m) openDetail(m);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'x':
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.cursorId) toggleSelection(state.cursorId);
|
||||||
|
break;
|
||||||
|
case 'd':
|
||||||
|
case 'D':
|
||||||
|
e.preventDefault();
|
||||||
|
if (state.view === 'archived') {
|
||||||
|
doRestore(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
|
||||||
|
} else {
|
||||||
|
doArchive(state.selection.size ? [...state.selection] : (state.cursorId ? [state.cursorId] : []));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'z':
|
||||||
|
if ((e.metaKey || e.ctrlKey) && undoSnapshot.value) {
|
||||||
|
e.preventDefault();
|
||||||
|
undoAction();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
if (undoTimer) { clearInterval(undoTimer); undoTimer = null; }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Detail panel overlay -->
|
||||||
|
<div v-if="showDetail" class="detail-wrap">
|
||||||
|
<div class="detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-eyebrow">
|
||||||
|
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||||
|
<span class="project-name">{{ formatProjectLabel(detailMemory.project) }}</span>
|
||||||
|
<span v-if="detailMemory.archived" class="archived-tag">archived</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-path">{{ detailMemory.path }}</div>
|
||||||
|
<div class="detail-summary">{{ detailMemory.summary }}</div>
|
||||||
|
<div class="detail-meta">
|
||||||
|
<span>{{ fmtRelative(detailMemory.ts) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="markdown-section">
|
||||||
|
<div class="markdown-toolbar">
|
||||||
|
<span class="markdown-toolbar-label">Body</span>
|
||||||
|
<button
|
||||||
|
class="source-toggle"
|
||||||
|
:class="{ active: showSource }"
|
||||||
|
:disabled="detailMarkdown == null"
|
||||||
|
@click="toggleSourceView"
|
||||||
|
>
|
||||||
|
{{ showSource ? 'Show rendered' : 'Show source' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingMarkdown" class="markdown-loading">Loading...</div>
|
||||||
|
<div v-else-if="detailMarkdown == null" class="markdown-empty">
|
||||||
|
File not found or empty.
|
||||||
|
</div>
|
||||||
|
<pre v-else-if="showSource" class="markdown-source">{{ detailMarkdown }}</pre>
|
||||||
|
<div v-else class="markdown-body" v-html="renderedMarkdown"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="btn" @click="closeDetail">
|
||||||
|
Back<span class="kbd">Esc</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
:class="detailMemory.archived ? 'primary' : 'danger'"
|
||||||
|
@click="detailArchiveRestore"
|
||||||
|
>
|
||||||
|
{{ detailMemory.archived ? 'Restore' : 'Archive' }}<span class="kbd">D</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List panel -->
|
||||||
|
<div v-else ref="listWrapRef" class="list-wrap">
|
||||||
|
<div v-if="!visibleMemories.length" class="empty">
|
||||||
|
No memories{{ state.view === 'archived' ? ' archived' : '' }} here.
|
||||||
|
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="memory-list">
|
||||||
|
<div
|
||||||
|
v-for="m in visibleMemories"
|
||||||
|
:key="m.id"
|
||||||
|
class="row"
|
||||||
|
:class="{
|
||||||
|
cursor: state.cursorId === m.id,
|
||||||
|
selected: state.selection.has(m.id),
|
||||||
|
archived: m.archived
|
||||||
|
}"
|
||||||
|
:data-id="m.id"
|
||||||
|
@click="onRowClick(m)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="row-checkbox"
|
||||||
|
:class="{ checked: state.selection.has(m.id) }"
|
||||||
|
aria-label="Select"
|
||||||
|
@click.stop="toggleSelection(m.id)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
|
||||||
|
<path d="M2.5 6.5l2.5 2.5 4.5-5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="row-body">
|
||||||
|
<div class="row-path">
|
||||||
|
<span
|
||||||
|
v-if="dominantRowStatus(m)"
|
||||||
|
class="row-status"
|
||||||
|
:class="dominantRowStatus(m)"
|
||||||
|
:title="dominantRowStatus(m)"
|
||||||
|
v-html="statusGlyphs(dominantRowStatus(m))"
|
||||||
|
></span>
|
||||||
|
<template v-if="showProjectPrefix">
|
||||||
|
<span class="project-prefix" v-html="projectLabel(m)"></span>
|
||||||
|
<span class="project-prefix-sep">/</span>
|
||||||
|
</template>
|
||||||
|
<span class="path-text" v-html="pathHTML(m)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row-summary" v-html="summaryHTML(m)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row-right">
|
||||||
|
<div class="row-meta"><span>{{ timeLabel(m) }}</span></div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button
|
||||||
|
v-if="m.archived"
|
||||||
|
class="row-action restore"
|
||||||
|
@click.stop="doRestore([m.id])"
|
||||||
|
>
|
||||||
|
Restore<span class="kbd">D</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="row-action danger"
|
||||||
|
@click.stop="doArchive([m.id])"
|
||||||
|
>
|
||||||
|
Archive<span class="kbd">D</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Undo toast -->
|
||||||
|
<Transition name="undo-fade">
|
||||||
|
<div v-if="undoSnapshot" class="undo-toast" @click="undoAction">
|
||||||
|
{{ undoSnapshot.action === 'archive' ? 'Archived' : 'Restored' }}
|
||||||
|
{{ undoSnapshot.ids.length }} memory{{ undoSnapshot.ids.length > 1 ? 'ies' : '' }}.
|
||||||
|
<button class="undo-btn">Undo ({{ undoCountdown }}s)</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 32px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row styles */
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
column-gap: 12px;
|
||||||
|
padding: 14px 16px 14px 14px;
|
||||||
|
min-height: var(--row-h, 60px);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
transition: background 0.06s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.row:last-child { border-bottom: 0; }
|
||||||
|
.row:hover { background: rgba(255,255,255,0.025); }
|
||||||
|
.row.cursor { background: var(--surface); }
|
||||||
|
.row.cursor::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--muted-2);
|
||||||
|
}
|
||||||
|
.row.selected { background: var(--accent-soft); }
|
||||||
|
.row.selected::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.row.cursor.selected { background: rgba(167,139,250,0.16); }
|
||||||
|
|
||||||
|
.row-checkbox {
|
||||||
|
width: 18px; height: 18px; margin-top: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1.5px solid var(--muted-2);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.1s;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
.row:hover .row-checkbox,
|
||||||
|
.row.selected .row-checkbox,
|
||||||
|
.row.cursor .row-checkbox { opacity: 1; }
|
||||||
|
.row-checkbox:hover { border-color: var(--accent); }
|
||||||
|
.row-checkbox.checked {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.row-checkbox svg { width: 10px; height: 10px; color: #0a0b14; opacity: 0; }
|
||||||
|
.row-checkbox.checked svg { opacity: 1; }
|
||||||
|
|
||||||
|
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
.row-path {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row-status :deep(svg) { width: 100%; height: 100%; }
|
||||||
|
.row-status.broken { color: var(--danger); }
|
||||||
|
.row-status.partial { color: var(--warn); }
|
||||||
|
.row-status.archived { color: var(--muted-2); }
|
||||||
|
|
||||||
|
.row-path .project-prefix { color: var(--muted); font-weight: 400; flex-shrink: 0; }
|
||||||
|
.row-path .project-prefix-sep { color: var(--muted-2); margin: 0 2px; flex-shrink: 0; }
|
||||||
|
.row-path .path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
|
.row-path :deep(mark), .row-summary :deep(mark) {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-2);
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-summary {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--fg-2);
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.row:hover .row-meta { color: var(--muted-2); }
|
||||||
|
|
||||||
|
.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.1s; }
|
||||||
|
.row:hover .row-actions, .row.cursor .row-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.row-action {
|
||||||
|
height: 24px; padding: 0 8px; border-radius: 4px;
|
||||||
|
color: var(--muted); font-size: var(--text-sm);
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
transition: all 0.1s; border: 1px solid transparent;
|
||||||
|
background: transparent; cursor: pointer;
|
||||||
|
}
|
||||||
|
.row-action:hover { background: var(--surface-hi); color: var(--fg); border-color: var(--hairline-strong); }
|
||||||
|
.row-action.danger:hover { background: var(--danger-soft); color: var(--danger); border-color: rgba(248,113,113,0.3); }
|
||||||
|
.row-action.restore { color: var(--accent-2); }
|
||||||
|
.row-action.restore:hover { background: var(--accent-soft); color: var(--fg); border-color: var(--accent-soft); }
|
||||||
|
.row-action .kbd {
|
||||||
|
font-family: var(--font-mono); font-size: 9.5px; color: var(--muted-2);
|
||||||
|
padding: 0 3px; border: 1px solid var(--hairline); border-radius: 2px; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.row-action:hover .kbd { color: var(--fg-2); border-color: var(--hairline-strong); }
|
||||||
|
|
||||||
|
.row.archived .row-path, .row.archived .row-summary { color: var(--muted); }
|
||||||
|
.row.archived .row-path .project-prefix { color: var(--muted-2); }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.empty .hint { font-size: 11px; color: var(--muted-2); }
|
||||||
|
|
||||||
|
/* Detail panel styles */
|
||||||
|
.detail-header { margin-bottom: 24px; }
|
||||||
|
.detail-eyebrow {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
margin-bottom: 14px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.detail-eyebrow .project-icon { width: 13px; height: 13px; color: var(--muted); display: inline-flex; }
|
||||||
|
.detail-eyebrow .project-icon :deep(svg) { width: 100%; height: 100%; }
|
||||||
|
.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
|
||||||
|
.detail-eyebrow .archived-tag {
|
||||||
|
color: var(--accent-2);
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.detail-eyebrow .archived-tag::before {
|
||||||
|
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.detail-path {
|
||||||
|
font-family: var(--font-mono); font-size: 17px; font-weight: 500;
|
||||||
|
color: var(--fg); line-height: 1.5;
|
||||||
|
word-break: break-all; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.detail-summary { font-size: var(--text-md); color: var(--fg-2); line-height: 1.6; margin-bottom: 16px; }
|
||||||
|
.detail-meta {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||||
|
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||||
|
padding-bottom: 16px; border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-section { margin: 28px 0 8px; }
|
||||||
|
.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.markdown-toolbar-label {
|
||||||
|
font-size: 10.5px; color: var(--muted);
|
||||||
|
font-weight: 500; letter-spacing: 0.04em; flex: 1;
|
||||||
|
}
|
||||||
|
.source-toggle {
|
||||||
|
height: 22px; padding: 0 8px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--hairline-strong); background: var(--surface);
|
||||||
|
color: var(--muted); font-size: var(--text-sm);
|
||||||
|
transition: all 0.1s; cursor: pointer;
|
||||||
|
}
|
||||||
|
.source-toggle:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||||
|
.source-toggle.active { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
||||||
|
.source-toggle:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.markdown-loading {
|
||||||
|
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
|
||||||
|
}
|
||||||
|
.markdown-empty {
|
||||||
|
color: var(--muted-2); font-style: italic; padding: 20px; text-align: center;
|
||||||
|
border: 1px dashed var(--hairline); border-radius: 6px;
|
||||||
|
}
|
||||||
|
.markdown-source {
|
||||||
|
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline);
|
||||||
|
border-radius: 6px; padding: 14px 16px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; line-height: 1.55;
|
||||||
|
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions { margin-top: 32px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.detail-actions .btn {
|
||||||
|
height: 30px; padding: 0 14px; border-radius: 6px;
|
||||||
|
font-size: var(--text-base); font-weight: 500;
|
||||||
|
transition: all 0.1s;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
color: var(--fg-2); background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.detail-actions .btn:hover { background: var(--surface-strong); color: var(--fg); }
|
||||||
|
.detail-actions .btn.danger { color: var(--danger); }
|
||||||
|
.detail-actions .btn.danger:hover { background: var(--danger-soft); border-color: rgba(248,113,113,0.3); }
|
||||||
|
.detail-actions .btn.primary { color: var(--accent-2); }
|
||||||
|
.detail-actions .btn.primary:hover { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--fg); }
|
||||||
|
.detail-actions .btn .kbd {
|
||||||
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||||
|
padding: 0 4px; border: 1px solid var(--hairline); border-radius: 3px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Undo toast */
|
||||||
|
.undo-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--fg-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||||
|
z-index: 100;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.undo-btn {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border: 1px solid rgba(167,139,250,0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--accent-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.undo-btn:hover { background: rgba(167,139,250,0.25); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.undo-fade-enter-active, .undo-fade-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||||
|
.undo-fade-enter-from, .undo-fade-leave-to { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, nextTick, onActivated } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { state, FOLDER_SVG } from '../store.js';
|
||||||
|
import { loadSessionDetail, isTextTruncated, loadFullText } from '../data.js';
|
||||||
|
import {
|
||||||
|
escapeHTML,
|
||||||
|
fmtRelative,
|
||||||
|
fmtClockTime,
|
||||||
|
renderMarkdown,
|
||||||
|
formatProjectLabel
|
||||||
|
} from '../utils.js';
|
||||||
|
|
||||||
|
defineOptions({ name: 'SessionDetail' });
|
||||||
|
const props = defineProps({ id: String });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// --- Reactive state ---
|
||||||
|
const session = computed(() => state.sessions.find(s => s.id === props.id));
|
||||||
|
const messages = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const progressPct = ref(0);
|
||||||
|
const showBackToTop = ref(false);
|
||||||
|
|
||||||
|
// DOM refs
|
||||||
|
const wrapRef = ref(null);
|
||||||
|
const detailRef = ref(null);
|
||||||
|
|
||||||
|
// --- Load session on mount ---
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadMessages();
|
||||||
|
});
|
||||||
|
|
||||||
|
// When keep-alive re-activates, re-check if we need data
|
||||||
|
onActivated(async () => {
|
||||||
|
if (messages.value.length === 0 && props.id) {
|
||||||
|
await loadMessages();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
if (!props.id) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const s = state.sessions.find(x => x.id === props.id);
|
||||||
|
if (s && (!s.messages || s.messages.length === 0)) {
|
||||||
|
const loaded = await loadSessionDetail(props.id);
|
||||||
|
if (loaded) Object.assign(s, loaded);
|
||||||
|
}
|
||||||
|
messages.value = s?.messages || [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus pending uuid if any
|
||||||
|
if (state.pendingFocusUuid) {
|
||||||
|
const targetUuid = state.pendingFocusUuid;
|
||||||
|
state.pendingFocusUuid = null;
|
||||||
|
await nextTick();
|
||||||
|
const target = detailRef.value?.querySelector(`.msg[data-uuid="${targetUuid}"]`);
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
target.classList.add('is-focused');
|
||||||
|
setTimeout(() => target.classList.remove('is-focused'), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scroll / progress tracking ---
|
||||||
|
function onScroll() {
|
||||||
|
if (!wrapRef.value || !detailRef.value) return;
|
||||||
|
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card');
|
||||||
|
if (!msgs.length) return;
|
||||||
|
const wrapTop = wrapRef.value.getBoundingClientRect().top;
|
||||||
|
let topMsgIdx = 0;
|
||||||
|
for (let i = 0; i < msgs.length; i++) {
|
||||||
|
if (msgs[i].getBoundingClientRect().top <= wrapTop + 50) topMsgIdx = i;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
const pct = msgs.length <= 1 ? 100 : Math.round((topMsgIdx / (msgs.length - 1)) * 100);
|
||||||
|
progressPct.value = pct;
|
||||||
|
showBackToTop.value = wrapRef.value.scrollTop > 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
if (wrapRef.value) wrapRef.value.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toggle helpers ---
|
||||||
|
function toggleToolCall(event) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
btn.closest('.msg-tool').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSummary(event) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
btn.closest('.msg-summary').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleThinking(event) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
btn.closest('.msg-thinking').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMeta(event) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
btn.closest('.msg-meta-collapsed').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Full text loading ---
|
||||||
|
async function handleLoadFullText(event, uuid) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
btn.textContent = 'Loading...';
|
||||||
|
const fullText = await loadFullText(uuid);
|
||||||
|
if (fullText) {
|
||||||
|
const msgEl = btn.closest('.msg');
|
||||||
|
const bodyEl = msgEl.querySelector('.markdown-msg') || msgEl.querySelector('.markdown-compact');
|
||||||
|
const variant = bodyEl?.classList.contains('markdown-compact') ? 'compact' : 'msg';
|
||||||
|
const rendered = renderMarkdown(fullText, { variant, query: state.query });
|
||||||
|
if (bodyEl) bodyEl.outerHTML = rendered;
|
||||||
|
btn.remove();
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Failed to load full text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subagent navigation ---
|
||||||
|
function navigateToSubagent(agentId, description) {
|
||||||
|
router.push({
|
||||||
|
name: 'SubagentDetail',
|
||||||
|
params: { id: props.id, agentId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render helpers (produce raw HTML strings like the vanilla version) ---
|
||||||
|
|
||||||
|
function getArgPreview(tc) {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(tc.input_json || '{}');
|
||||||
|
if (j.file_path) return j.file_path;
|
||||||
|
if (j.command) return j.command;
|
||||||
|
if (j.path) return j.path;
|
||||||
|
if (j.description) return j.description;
|
||||||
|
return JSON.stringify(j).slice(0, 100);
|
||||||
|
} catch {
|
||||||
|
return (tc.input_json || '').slice(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCallParsedInput(tc) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(tc.input_json || '{}');
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="detail-wrap" ref="wrapRef" @scroll="onScroll">
|
||||||
|
<div class="detail" ref="detailRef">
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="session-progress">
|
||||||
|
<div class="session-progress-fill" :style="{ width: progressPct + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="loading" class="empty" style="padding: 60px 0; text-align: center; color: var(--muted);">
|
||||||
|
Loading session...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session header -->
|
||||||
|
<template v-if="session && !loading">
|
||||||
|
<div class="session-header">
|
||||||
|
<div class="session-eyebrow">
|
||||||
|
<span class="project-icon" v-html="FOLDER_SVG"></span>
|
||||||
|
<span class="project-name">{{ formatProjectLabel(session.project) }}</span>
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span class="project-path">{{ session.project_path || '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-title">{{ session.title || '(untitled)' }}</div>
|
||||||
|
<div class="session-meta-inline">
|
||||||
|
<span>{{ fmtRelative(new Date(session.started_at || 0).getTime()) }}</span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>{{ session.message_count || 0 }} messages</span>
|
||||||
|
<template v-if="session.git_branch">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>{{ session.git_branch }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message timeline -->
|
||||||
|
<div class="timeline">
|
||||||
|
<template v-for="(msg, idx) in messages" :key="msg.uuid || idx">
|
||||||
|
|
||||||
|
<!-- Meta messages: collapsed system indicator -->
|
||||||
|
<template v-if="msg.is_meta === 1">
|
||||||
|
<div class="msg meta" :data-uuid="msg.uuid">
|
||||||
|
<div class="msg-meta-collapsed">
|
||||||
|
<button class="meta-toggle" @click="toggleMeta">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="meta-label">System</span>
|
||||||
|
<span class="meta-preview">{{ (msg.text || '').replace(/<[^>]+>/g, '').slice(0, 80) }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="meta-body">
|
||||||
|
<div v-html="renderMarkdown(msg.text, { variant: 'compact', query: state.query })"></div>
|
||||||
|
<button
|
||||||
|
v-if="isTextTruncated(msg.text)"
|
||||||
|
class="truncated-btn"
|
||||||
|
@click="handleLoadFullText($event, msg.uuid)"
|
||||||
|
>Message truncated — click to load full text</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Workflow card (standalone, outside assistant bubble) -->
|
||||||
|
<template v-else-if="!msg.type || msg.type !== 'user' ? (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow) : false">
|
||||||
|
<template v-if="(() => { const wfCall = (msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow); return wfCall && msg.type !== 'user'; })()">
|
||||||
|
<div class="wf-card" :data-uuid="msg.uuid">
|
||||||
|
<div class="wf-card-header">
|
||||||
|
<span class="wf-card-icon">⚙</span>
|
||||||
|
<span class="wf-card-name">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.workflow_name || 'Workflow' }}</span>
|
||||||
|
<span class="wf-card-count">{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.agents?.length || 0 }} agents</span>
|
||||||
|
<span
|
||||||
|
v-if="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
||||||
|
class="wf-card-status"
|
||||||
|
:class="((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status"
|
||||||
|
>{{ ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-card-body">
|
||||||
|
<!-- Group agents by phase -->
|
||||||
|
<template v-for="(phaseAgents, phase) in (() => {
|
||||||
|
const wf = ((msg.tool_calls || []).find(tc => tc.name === 'Workflow' && tc.workflow)).workflow;
|
||||||
|
const phases = {};
|
||||||
|
for (const a of (wf.agents || [])) {
|
||||||
|
const p = a.phase || 'Other';
|
||||||
|
if (!phases[p]) phases[p] = [];
|
||||||
|
phases[p].push(a);
|
||||||
|
}
|
||||||
|
return phases;
|
||||||
|
})()" :key="phase">
|
||||||
|
<div class="wf-card-phase">
|
||||||
|
<div class="wf-card-phase-title">{{ phase }}</div>
|
||||||
|
<button
|
||||||
|
v-for="a in phaseAgents"
|
||||||
|
:key="a.agent_id"
|
||||||
|
class="wf-card-agent"
|
||||||
|
@click="navigateToSubagent(a.agent_id, a.label || '')"
|
||||||
|
>
|
||||||
|
<span class="wf-card-agent-label">{{ a.label || a.agent_id }}</span>
|
||||||
|
<span v-if="a.state === 'error'" class="wf-card-agent-state error">error</span>
|
||||||
|
<span class="wf-card-agent-arrow">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Other tool calls (non-workflow) for this message -->
|
||||||
|
<template v-if="(msg.tool_calls || []).filter(tc => !(tc.name === 'Workflow' && tc.workflow)).length > 0">
|
||||||
|
<div class="msg assistant" :data-uuid="msg.uuid + '-tools'">
|
||||||
|
<div class="msg-tools">
|
||||||
|
<template v-for="tc in (msg.tool_calls || []).filter(tc2 => !(tc2.name === 'Workflow' && tc2.workflow))" :key="tc.id">
|
||||||
|
<!-- Render non-workflow tool calls -->
|
||||||
|
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||||
|
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="tool-name">{{ tc.name }}</span>
|
||||||
|
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||||
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
<div class="tc-section">Input</div>
|
||||||
|
<pre>{{ tc.input_json || '' }}</pre>
|
||||||
|
<template v-if="tc.result">
|
||||||
|
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
||||||
|
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Standalone thinking message -->
|
||||||
|
<template v-else-if="msg.type === 'assistant' && msg.content_type === 'thinking'">
|
||||||
|
<div class="msg assistant" :data-uuid="msg.uuid">
|
||||||
|
<div class="msg-thinking">
|
||||||
|
<button class="thinking-toggle" @click="toggleThinking">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="thinking-label">Thinking</span>
|
||||||
|
</button>
|
||||||
|
<div class="thinking-body" v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Normal message (user or assistant) -->
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
class="msg"
|
||||||
|
:class="msg.type === 'user' ? 'user' : 'assistant'"
|
||||||
|
:data-uuid="msg.uuid"
|
||||||
|
>
|
||||||
|
<!-- Message header -->
|
||||||
|
<div class="msg-head">
|
||||||
|
<span class="role">{{ msg.type === 'user' ? 'You' : 'Assistant' }}</span>
|
||||||
|
<span class="when">{{ msg.timestamp ? fmtClockTime(msg.timestamp) : '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attached thinking block (merged from preceding thinking messages) -->
|
||||||
|
<div v-if="msg._thinking" class="msg-thinking">
|
||||||
|
<button class="thinking-toggle" @click="toggleThinking">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="thinking-label">Thinking</span>
|
||||||
|
</button>
|
||||||
|
<div class="thinking-body" v-html="renderMarkdown(msg._thinking, { variant: 'msg', query: state.query })"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message text body -->
|
||||||
|
<template v-if="msg.text">
|
||||||
|
<div v-html="renderMarkdown(msg.text, { variant: 'msg', query: state.query })"></div>
|
||||||
|
<button
|
||||||
|
v-if="isTextTruncated(msg.text)"
|
||||||
|
class="truncated-btn"
|
||||||
|
@click="handleLoadFullText($event, msg.uuid)"
|
||||||
|
>Message truncated — click to load full text</button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="!(msg.tool_calls && msg.tool_calls.length)">
|
||||||
|
<div class="msg-text empty-text">(no text content)</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Tool calls -->
|
||||||
|
<div v-if="msg.tool_calls && msg.tool_calls.length" class="msg-tools">
|
||||||
|
<template v-for="tc in msg.tool_calls" :key="tc.id">
|
||||||
|
|
||||||
|
<!-- Agent/Task tool call (subagent) -->
|
||||||
|
<template v-if="tc.name === 'Agent' || tc.name === 'Task'">
|
||||||
|
<div class="msg-tool agent-call">
|
||||||
|
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="tool-name">{{ getToolCallParsedInput(tc).subagent_type || getToolCallParsedInput(tc).agentType || 'Agent' }}</span>
|
||||||
|
<span class="tool-arg">{{ getToolCallParsedInput(tc).description || (getToolCallParsedInput(tc).prompt || '').slice(0, 80) }}</span>
|
||||||
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
|
<button
|
||||||
|
v-if="tc.subagent?.agent_id"
|
||||||
|
class="agent-nav-btn"
|
||||||
|
@click.stop="navigateToSubagent(tc.subagent.agent_id, getToolCallParsedInput(tc).description || '')"
|
||||||
|
>View conversation →</button>
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
<template v-if="getToolCallParsedInput(tc).prompt">
|
||||||
|
<div class="tc-section">Prompt</div>
|
||||||
|
<div class="agent-prompt">{{ (getToolCallParsedInput(tc).prompt || '').slice(0, 500) }}{{ (getToolCallParsedInput(tc).prompt || '').length > 500 ? '...' : '' }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="tc.result?.content">
|
||||||
|
<div class="tc-section">Result</div>
|
||||||
|
<div class="agent-result" v-html="renderMarkdown(tc.result.content, { variant: 'compact' })"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Workflow tool call (inside assistant bubble) -->
|
||||||
|
<template v-else-if="tc.name === 'Workflow'">
|
||||||
|
<div class="msg-tool agent-call">
|
||||||
|
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="tool-name">Workflow</span>
|
||||||
|
<span class="tool-arg">{{ tc.workflow?.workflow_name || getToolCallParsedInput(tc).name || 'Workflow' }}</span>
|
||||||
|
<span
|
||||||
|
v-if="tc.workflow?.status"
|
||||||
|
class="workflow-status"
|
||||||
|
:class="tc.workflow.status"
|
||||||
|
>{{ tc.workflow.status }}</span>
|
||||||
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
<template v-if="tc.workflow?.agents?.length">
|
||||||
|
<div class="tc-section">Agents · {{ tc.workflow.agents.length }}</div>
|
||||||
|
<div class="workflow-agent-list">
|
||||||
|
<template v-for="(phaseAgents, phase) in (() => {
|
||||||
|
const phases = {};
|
||||||
|
for (const a of (tc.workflow.agents || [])) {
|
||||||
|
const p = a.phase || 'Other';
|
||||||
|
if (!phases[p]) phases[p] = [];
|
||||||
|
phases[p].push(a);
|
||||||
|
}
|
||||||
|
return phases;
|
||||||
|
})()" :key="phase">
|
||||||
|
<div class="workflow-phase-group">
|
||||||
|
<div class="workflow-phase-header">{{ phase }}</div>
|
||||||
|
<div class="workflow-phase-agents">
|
||||||
|
<button
|
||||||
|
v-for="a in phaseAgents"
|
||||||
|
:key="a.agent_id"
|
||||||
|
class="workflow-agent-row"
|
||||||
|
@click.stop="navigateToSubagent(a.agent_id, a.label || '')"
|
||||||
|
>
|
||||||
|
<span class="workflow-agent-label">{{ a.label || a.agent_id }}</span>
|
||||||
|
<span class="workflow-agent-state" :class="a.state || ''">{{ a.state || '' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Generic tool call -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="msg-tool" :class="{ 'is-error': tc.result && tc.result.is_error }">
|
||||||
|
<button class="toolcall-toggle" @click="toggleToolCall">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="tool-name">{{ tc.name }}</span>
|
||||||
|
<span class="tool-arg">{{ getArgPreview(tc) }}</span>
|
||||||
|
<span v-if="tc.result && tc.result.is_error" class="tool-error">error</span>
|
||||||
|
</button>
|
||||||
|
<div class="toolcall-body">
|
||||||
|
<div class="tc-section">Input</div>
|
||||||
|
<pre>{{ tc.input_json || '' }}</pre>
|
||||||
|
<template v-if="tc.result">
|
||||||
|
<div class="tc-section">{{ tc.result.is_error ? 'Error' : 'Output' }}</div>
|
||||||
|
<pre>{{ tc.result.content || '(empty)' }}</pre>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary block -->
|
||||||
|
<div v-if="msg.summary" class="msg-summary">
|
||||||
|
<button class="summary-toggle" @click="toggleSummary">
|
||||||
|
<svg class="chevron" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 1.5l3 2.5-3 2.5"/></svg>
|
||||||
|
<span class="label">Session summary</span>
|
||||||
|
<span class="source">{{ msg.summary.source || '' }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="summary-body" v-html="renderMarkdown(msg.summary.content, { variant: 'compact' })"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Back to top button -->
|
||||||
|
<button
|
||||||
|
class="back-to-top"
|
||||||
|
:class="{ show: showBackToTop }"
|
||||||
|
@click="scrollToTop"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12V4M4 7l4-4 4 4"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { state } from '../store.js';
|
||||||
|
import { highlightPlain, escapeHTML, formatProjectLabel, fmtListTime } from '../utils.js';
|
||||||
|
|
||||||
|
defineOptions({ name: 'SessionList' });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const visibleSessions = computed(() => {
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
return state.sessions
|
||||||
|
.filter(s => state.projectFilter === 'all' || s.project === state.projectFilter)
|
||||||
|
.map(s => {
|
||||||
|
if (!q) return { ...s, messageHit: null };
|
||||||
|
const topMatch = (s.title || '').toLowerCase().includes(q) ||
|
||||||
|
(s.project || '').toLowerCase().includes(q) ||
|
||||||
|
(s.git_branch || '').toLowerCase().includes(q);
|
||||||
|
if (topMatch) return { ...s, messageHit: null };
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ta = new Date(a.started_at || 0).getTime();
|
||||||
|
const tb = new Date(b.started_at || 0).getTime();
|
||||||
|
return state.sortDesc ? tb - ta : ta - tb;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const showProjectPrefix = computed(() => state.projectFilter === 'all');
|
||||||
|
|
||||||
|
function titleHTML(session) {
|
||||||
|
return highlightPlain(session.title || '(untitled)', state.query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectLabel(session) {
|
||||||
|
return escapeHTML(formatProjectLabel(session.project));
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeLabel(session) {
|
||||||
|
const ts = new Date(session.started_at || 0).getTime();
|
||||||
|
return fmtListTime(ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSession(session) {
|
||||||
|
router.push({ name: 'SessionDetail', params: { id: session.id } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="session-list-wrap">
|
||||||
|
<div v-if="!visibleSessions.length" class="empty">
|
||||||
|
No sessions here.
|
||||||
|
<span class="hint">{{ state.query ? 'Try a different search term.' : 'Press / to search.' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="session-list">
|
||||||
|
<div
|
||||||
|
v-for="s in visibleSessions"
|
||||||
|
:key="s.id"
|
||||||
|
class="srow"
|
||||||
|
:class="{ cursor: state.cursorId === s.id }"
|
||||||
|
:data-session-id="s.id"
|
||||||
|
@click="openSession(s)"
|
||||||
|
>
|
||||||
|
<div class="srow-body">
|
||||||
|
<div class="srow-title" v-html="titleHTML(s)"></div>
|
||||||
|
<div class="srow-meta">
|
||||||
|
<template v-if="showProjectPrefix">
|
||||||
|
<span class="project-tag" v-html="projectLabel(s)"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
</template>
|
||||||
|
<span>{{ s.message_count || 0 }} msg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="srow-right">{{ timeLabel(s) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.session-list-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
column-gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
min-height: var(--row-h-session);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
transition: background 0.06s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.srow:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
.srow.cursor {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.srow.cursor::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--muted-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srow-body {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.srow-title {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg);
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.srow-title :deep(mark) {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-2);
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srow-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.srow-meta .project-tag {
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.srow-meta .dot {
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--muted-2);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.srow-right {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-2);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.empty .hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup>
|
||||||
|
defineOptions({ name: 'SubagentDetail' });
|
||||||
|
defineProps({ id: String, agentId: String });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="view-placeholder">SubagentDetail view for agent {{ agentId }} in session {{ id }} (TODO)</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,770 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
|
import { state, navigateToSession } from '../store.js';
|
||||||
|
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
|
||||||
|
|
||||||
|
defineOptions({ name: 'Usage' });
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
const activeTab = ref('daily');
|
||||||
|
const loading = ref(true);
|
||||||
|
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
|
||||||
|
const selectedDayKey = ref(null);
|
||||||
|
const loadedMonths = ref(0);
|
||||||
|
const monthBlocks = ref([]);
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
const DAY_MS = 86400000;
|
||||||
|
const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
const MONTHS_FULL = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
|
// --- Computed: heatmap grid ---
|
||||||
|
const heatmapGrid = computed(() => {
|
||||||
|
const today = new Date();
|
||||||
|
let startDate = new Date(today.getTime() - 364 * DAY_MS);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||||
|
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
|
||||||
|
|
||||||
|
const dailyMap = {};
|
||||||
|
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||||
|
|
||||||
|
const values = usageData.daily.map(d => d.tokens).filter(Boolean);
|
||||||
|
const maxTokens = Math.max(...values, 1);
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
for (let i = 0; i < 371; i++) {
|
||||||
|
const date = new Date(startDate.getTime() + i * DAY_MS);
|
||||||
|
if (date > today) break;
|
||||||
|
const key = date.toISOString().slice(0, 10);
|
||||||
|
const tokens = dailyMap[key] || 0;
|
||||||
|
const level = tokens === 0 ? 0 : Math.min(4, Math.ceil((tokens / maxTokens) * 4));
|
||||||
|
const col = Math.floor(i / 7);
|
||||||
|
const row = i % 7;
|
||||||
|
cells.push({ key, tokens, level, col, row, date });
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCol = cells.length ? cells[cells.length - 1].col : 0;
|
||||||
|
const cellSize = 11;
|
||||||
|
const cellGap = 2;
|
||||||
|
const step = cellSize + cellGap;
|
||||||
|
const gridWidth = (maxCol + 1) * step + 20;
|
||||||
|
const gridHeight = 7 * step;
|
||||||
|
|
||||||
|
// Month labels
|
||||||
|
const monthLabels = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
for (const c of cells) {
|
||||||
|
const m = c.date.getMonth();
|
||||||
|
if (m !== lastMonth && c.row === 0) {
|
||||||
|
monthLabels.push({ col: c.col, label: MONTHS_SHORT[m] });
|
||||||
|
lastMonth = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cells, monthLabels, gridWidth, gridHeight, cellSize, step };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Computed: streaks ---
|
||||||
|
const currentStreak = computed(() => {
|
||||||
|
const today = new Date();
|
||||||
|
const dailyMap = {};
|
||||||
|
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||||
|
|
||||||
|
let streak = 0;
|
||||||
|
let startedCounting = false;
|
||||||
|
for (let i = 0; i <= 365; i++) {
|
||||||
|
const d = new Date(today.getTime() - i * DAY_MS).toISOString().slice(0, 10);
|
||||||
|
if (dailyMap[d] && dailyMap[d] > 0) {
|
||||||
|
startedCounting = true;
|
||||||
|
streak++;
|
||||||
|
} else if (startedCounting) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streak;
|
||||||
|
});
|
||||||
|
|
||||||
|
const longestStreak = computed(() => {
|
||||||
|
const sortedDays = [...usageData.daily]
|
||||||
|
.filter(d => d.tokens > 0)
|
||||||
|
.sort((a, b) => a.day.localeCompare(b.day));
|
||||||
|
|
||||||
|
let longest = 0;
|
||||||
|
let streak = 0;
|
||||||
|
for (let i = 0; i < sortedDays.length; i++) {
|
||||||
|
if (i === 0) {
|
||||||
|
streak = 1;
|
||||||
|
} else {
|
||||||
|
const prev = new Date(sortedDays[i - 1].day).getTime();
|
||||||
|
const curr = new Date(sortedDays[i].day).getTime();
|
||||||
|
streak = (curr - prev === DAY_MS) ? streak + 1 : 1;
|
||||||
|
}
|
||||||
|
if (streak > longest) longest = streak;
|
||||||
|
}
|
||||||
|
return longest;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Computed: weekly chart ---
|
||||||
|
const weeklyBars = computed(() => {
|
||||||
|
const today = new Date();
|
||||||
|
let startDate = new Date(today.getTime() - 364 * DAY_MS);
|
||||||
|
startDate.setHours(0, 0, 0, 0);
|
||||||
|
const daysUntilSunday = (7 - startDate.getDay()) % 7;
|
||||||
|
startDate = new Date(startDate.getTime() + daysUntilSunday * DAY_MS);
|
||||||
|
|
||||||
|
const dailyMap = {};
|
||||||
|
for (const d of usageData.daily) dailyMap[d.day] = d.tokens;
|
||||||
|
|
||||||
|
const weeks = [];
|
||||||
|
for (let w = 0; w < 53; w++) {
|
||||||
|
const weekStart = new Date(startDate.getTime() + w * 7 * DAY_MS);
|
||||||
|
if (weekStart > today) break;
|
||||||
|
let tokens = 0;
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const date = new Date(weekStart.getTime() + d * DAY_MS);
|
||||||
|
if (date > today) break;
|
||||||
|
const key = date.toISOString().slice(0, 10);
|
||||||
|
tokens += dailyMap[key] || 0;
|
||||||
|
}
|
||||||
|
weeks.push({ weekStart, tokens, weekKey: weekStart.toISOString().slice(0, 10) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVal = Math.max(...weeks.map(w => w.tokens), 1);
|
||||||
|
const barWidth = 10;
|
||||||
|
const barGap = 3;
|
||||||
|
const chartHeight = 120;
|
||||||
|
const chartWidth = weeks.length * (barWidth + barGap);
|
||||||
|
|
||||||
|
const labels = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
for (let i = 0; i < weeks.length; i++) {
|
||||||
|
const m = weeks[i].weekStart.getMonth();
|
||||||
|
if (m !== lastMonth) { labels.push({ i, label: MONTHS_SHORT[m] }); lastMonth = m; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bars = weeks.map((w, i) => {
|
||||||
|
const h = maxVal > 0 ? (w.tokens / maxVal) * chartHeight : 0;
|
||||||
|
const x = i * (barWidth + barGap);
|
||||||
|
return { x, y: chartHeight - h, width: barWidth, height: Math.max(h, 0.5), label: `Week of ${w.weekKey}: ${fmtTokens(w.tokens)}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { bars, labels, chartWidth, chartHeight, barWidth, barGap };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Computed: cumulative chart ---
|
||||||
|
const cumulativeData = computed(() => {
|
||||||
|
const sorted = [...usageData.daily].sort((a, b) => a.day.localeCompare(b.day));
|
||||||
|
if (!sorted.length) return null;
|
||||||
|
|
||||||
|
let cumulative = 0;
|
||||||
|
const points = sorted.map(d => { cumulative += d.tokens; return { day: d.day, total: cumulative }; });
|
||||||
|
const maxVal = points[points.length - 1].total || 1;
|
||||||
|
|
||||||
|
const chartWidth = 700;
|
||||||
|
const chartHeight = 140;
|
||||||
|
|
||||||
|
const xScale = (i) => (i / (points.length - 1)) * chartWidth;
|
||||||
|
const yScale = (v) => chartHeight - (v / maxVal) * chartHeight;
|
||||||
|
|
||||||
|
const pathParts = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${yScale(p.total).toFixed(1)}`);
|
||||||
|
const linePath = pathParts.join(' ');
|
||||||
|
const areaPath = linePath + ` L${chartWidth},${chartHeight} L0,${chartHeight} Z`;
|
||||||
|
|
||||||
|
const labels = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const m = new Date(points[i].day).getMonth();
|
||||||
|
if (m !== lastMonth) { labels.push({ x: xScale(i), label: MONTHS_SHORT[m] }); lastMonth = m; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dots = points.map((p, i) => ({
|
||||||
|
cx: xScale(i).toFixed(1),
|
||||||
|
cy: yScale(p.total).toFixed(1),
|
||||||
|
label: `${p.day}: ${fmtTokens(p.total)} total`
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { linePath, areaPath, labels, dots, chartWidth, chartHeight };
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Computed: day sessions ---
|
||||||
|
const daySessions = computed(() => {
|
||||||
|
if (!selectedDayKey.value) return null;
|
||||||
|
const dateKey = selectedDayKey.value;
|
||||||
|
const dayStart = dateKey + 'T00:00:00';
|
||||||
|
const dayEnd = dateKey + 'T23:59:59';
|
||||||
|
|
||||||
|
const sessions = state.sessions.filter(s => {
|
||||||
|
if (!s.started_at) return false;
|
||||||
|
const end = s.ended_at || s.started_at;
|
||||||
|
return s.started_at <= dayEnd && end >= dayStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
const classified = sessions.map(s => {
|
||||||
|
const isNew = s.started_at.slice(0, 10) === dateKey;
|
||||||
|
let kind = 'continued';
|
||||||
|
if (isNew) {
|
||||||
|
const hasEarlierSession = state.sessions.some(
|
||||||
|
other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at
|
||||||
|
);
|
||||||
|
kind = hasEarlierSession ? 'new-session' : 'new-workspace';
|
||||||
|
}
|
||||||
|
return { ...s, kind };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateKey,
|
||||||
|
dateLabel: fmtTooltipDate(dateKey),
|
||||||
|
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||||
|
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||||
|
continued: classified.filter(s => s.kind === 'continued'),
|
||||||
|
isEmpty: classified.length === 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Methods ---
|
||||||
|
function switchTab(view) {
|
||||||
|
activeTab.value = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellEnter(cell, event) {
|
||||||
|
tooltip.text = `${fmtTokens(cell.tokens)} tokens on ${fmtTooltipDate(cell.key)}`;
|
||||||
|
tooltip.show = true;
|
||||||
|
updateTooltipPos(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellMove(event) {
|
||||||
|
updateTooltipPos(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellLeave() {
|
||||||
|
tooltip.show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellClick(cell) {
|
||||||
|
selectedDayKey.value = cell.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBarEnter(bar, event) {
|
||||||
|
tooltip.text = bar.label;
|
||||||
|
tooltip.show = true;
|
||||||
|
updateTooltipPos(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDotEnter(dot, event) {
|
||||||
|
tooltip.text = dot.label;
|
||||||
|
tooltip.show = true;
|
||||||
|
updateTooltipPos(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTooltipPos(event) {
|
||||||
|
const pad = 12;
|
||||||
|
let left = event.clientX + pad;
|
||||||
|
if (left + 200 > window.innerWidth - pad) left = event.clientX - 200 - pad;
|
||||||
|
tooltip.x = left;
|
||||||
|
tooltip.y = event.clientY - 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToSession(sessionId) {
|
||||||
|
navigateToSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthBlock(year, month) {
|
||||||
|
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||||
|
const nextMonth = month === 11 ? `${year + 1}-01-01` : `${year}-${String(month + 2).padStart(2, '0')}-01`;
|
||||||
|
|
||||||
|
const monthSessions = state.sessions.filter(s => {
|
||||||
|
if (!s.started_at) return false;
|
||||||
|
const end = s.ended_at || s.started_at;
|
||||||
|
return s.started_at < nextMonth && end >= monthStart;
|
||||||
|
});
|
||||||
|
|
||||||
|
const classified = monthSessions.map(s => {
|
||||||
|
const startedInMonth = s.started_at >= monthStart && s.started_at < nextMonth;
|
||||||
|
let kind = 'continued';
|
||||||
|
if (startedInMonth) {
|
||||||
|
const hasEarlierSession = state.sessions.some(
|
||||||
|
other => other.project === s.project && other.id !== s.id && other.started_at < s.started_at
|
||||||
|
);
|
||||||
|
kind = hasEarlierSession ? 'new-session' : 'new-workspace';
|
||||||
|
}
|
||||||
|
return { ...s, kind };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
header: `${MONTHS_FULL[month]} ${year}`,
|
||||||
|
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||||
|
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||||
|
continued: classified.filter(s => s.kind === 'continued'),
|
||||||
|
isEmpty: classified.length === 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNextMonth() {
|
||||||
|
const today = new Date();
|
||||||
|
const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths.value, 1);
|
||||||
|
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
|
||||||
|
monthBlocks.value.push(block);
|
||||||
|
loadedMonths.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectLabel(project) {
|
||||||
|
return formatProjectLabel(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSessionProjectCount(sessions) {
|
||||||
|
const projects = new Set(sessions.map(s => s.project || '(none)'));
|
||||||
|
return projects.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lifecycle ---
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const data = await window.obelisk.getUsageStats();
|
||||||
|
usageData.daily = data.daily || [];
|
||||||
|
usageData.totalTokens = data.totalTokens || 0;
|
||||||
|
usageData.peakDay = data.peakDay || null;
|
||||||
|
usageData.longestTurn = data.longestTurn || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load usage stats:', e);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
showNextMonth();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="usage-wrap" v-if="!loading">
|
||||||
|
<div class="detail-wide">
|
||||||
|
<!-- Header with tabs -->
|
||||||
|
<div class="usage-header">
|
||||||
|
<span class="usage-title">Token activity</span>
|
||||||
|
<div class="usage-view-tabs">
|
||||||
|
<button
|
||||||
|
class="usage-tab"
|
||||||
|
:class="{ active: activeTab === 'daily' }"
|
||||||
|
@click="switchTab('daily')"
|
||||||
|
>Daily</button>
|
||||||
|
<button
|
||||||
|
class="usage-tab"
|
||||||
|
:class="{ active: activeTab === 'weekly' }"
|
||||||
|
@click="switchTab('weekly')"
|
||||||
|
>Weekly</button>
|
||||||
|
<button
|
||||||
|
class="usage-tab"
|
||||||
|
:class="{ active: activeTab === 'cumulative' }"
|
||||||
|
@click="switchTab('cumulative')"
|
||||||
|
>Cumulative</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats bar -->
|
||||||
|
<div class="usage-stats">
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">{{ fmtTokens(usageData.totalTokens) }}</span>
|
||||||
|
<span class="usage-stat-label">Lifetime tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">{{ usageData.peakDay ? fmtTokens(usageData.peakDay.tokens) : '—' }}</span>
|
||||||
|
<span class="usage-stat-label">Peak tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">{{ usageData.longestTurn ? fmtDuration(usageData.longestTurn.turn_duration_ms) : '—' }}</span>
|
||||||
|
<span class="usage-stat-label">Longest task</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">{{ currentStreak }}d</span>
|
||||||
|
<span class="usage-stat-label">Current streak</span>
|
||||||
|
</div>
|
||||||
|
<div class="usage-stat">
|
||||||
|
<span class="usage-stat-value">{{ longestStreak }}d</span>
|
||||||
|
<span class="usage-stat-label">Longest streak</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily heatmap -->
|
||||||
|
<div class="heatmap-container" v-show="activeTab === 'daily'">
|
||||||
|
<svg
|
||||||
|
class="heatmap"
|
||||||
|
:width="heatmapGrid.gridWidth"
|
||||||
|
:height="heatmapGrid.gridHeight + 20"
|
||||||
|
:viewBox="`0 0 ${heatmapGrid.gridWidth} ${heatmapGrid.gridHeight + 20}`"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
v-for="cell in heatmapGrid.cells"
|
||||||
|
:key="cell.key"
|
||||||
|
:x="cell.col * heatmapGrid.step"
|
||||||
|
:y="cell.row * heatmapGrid.step"
|
||||||
|
:width="heatmapGrid.cellSize"
|
||||||
|
:height="heatmapGrid.cellSize"
|
||||||
|
rx="2"
|
||||||
|
:class="['heatmap-cell', `level-${cell.level}`, { selected: selectedDayKey === cell.key }]"
|
||||||
|
@mouseenter="onCellEnter(cell, $event)"
|
||||||
|
@mousemove="onCellMove"
|
||||||
|
@mouseleave="onCellLeave"
|
||||||
|
@click="onCellClick(cell)"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
v-for="ml in heatmapGrid.monthLabels"
|
||||||
|
:key="'ml-' + ml.col"
|
||||||
|
:x="ml.col * heatmapGrid.step"
|
||||||
|
:y="heatmapGrid.gridHeight + 14"
|
||||||
|
class="heatmap-month"
|
||||||
|
>{{ ml.label }}</text>
|
||||||
|
</svg>
|
||||||
|
<div class="heatmap-legend">
|
||||||
|
<span class="heatmap-legend-label">Less</span>
|
||||||
|
<svg width="70" height="11">
|
||||||
|
<rect x="0" width="11" height="11" rx="2" class="heatmap-cell level-0"/>
|
||||||
|
<rect x="14" width="11" height="11" rx="2" class="heatmap-cell level-1"/>
|
||||||
|
<rect x="28" width="11" height="11" rx="2" class="heatmap-cell level-2"/>
|
||||||
|
<rect x="42" width="11" height="11" rx="2" class="heatmap-cell level-3"/>
|
||||||
|
<rect x="56" width="11" height="11" rx="2" class="heatmap-cell level-4"/>
|
||||||
|
</svg>
|
||||||
|
<span class="heatmap-legend-label">More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly bar chart -->
|
||||||
|
<div class="chart-container" v-show="activeTab === 'weekly'">
|
||||||
|
<svg
|
||||||
|
class="weekly-chart"
|
||||||
|
:viewBox="`0 0 ${weeklyBars.chartWidth + 20} ${weeklyBars.chartHeight + 24}`"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
v-for="(bar, i) in weeklyBars.bars"
|
||||||
|
:key="'bar-' + i"
|
||||||
|
:x="bar.x"
|
||||||
|
:y="bar.y"
|
||||||
|
:width="bar.width"
|
||||||
|
:height="bar.height"
|
||||||
|
rx="2"
|
||||||
|
class="bar-fill"
|
||||||
|
@mouseenter="onBarEnter(bar, $event)"
|
||||||
|
@mousemove="onCellMove"
|
||||||
|
@mouseleave="onCellLeave"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
v-for="(lbl, i) in weeklyBars.labels"
|
||||||
|
:key="'wlbl-' + i"
|
||||||
|
:x="lbl.i * (weeklyBars.barWidth + weeklyBars.barGap)"
|
||||||
|
:y="weeklyBars.chartHeight + 16"
|
||||||
|
class="heatmap-month"
|
||||||
|
>{{ lbl.label }}</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cumulative line chart -->
|
||||||
|
<div class="chart-container" v-show="activeTab === 'cumulative'">
|
||||||
|
<template v-if="cumulativeData">
|
||||||
|
<svg
|
||||||
|
:viewBox="`0 0 ${cumulativeData.chartWidth} ${cumulativeData.chartHeight + 24}`"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
class="cumulative-chart"
|
||||||
|
>
|
||||||
|
<path :d="cumulativeData.areaPath" class="cumulative-area"/>
|
||||||
|
<path :d="cumulativeData.linePath" class="cumulative-line"/>
|
||||||
|
<circle
|
||||||
|
v-for="(dot, i) in cumulativeData.dots"
|
||||||
|
:key="'dot-' + i"
|
||||||
|
:cx="dot.cx"
|
||||||
|
:cy="dot.cy"
|
||||||
|
r="6"
|
||||||
|
class="cumulative-dot"
|
||||||
|
@mouseenter="onDotEnter(dot, $event)"
|
||||||
|
@mousemove="onCellMove"
|
||||||
|
@mouseleave="onCellLeave"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
v-for="(lbl, i) in cumulativeData.labels"
|
||||||
|
:key="'clbl-' + i"
|
||||||
|
:x="lbl.x"
|
||||||
|
:y="cumulativeData.chartHeight + 16"
|
||||||
|
class="heatmap-month"
|
||||||
|
>{{ lbl.label }}</text>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty">No data</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day sessions panel (from heatmap click) -->
|
||||||
|
<div class="day-sessions" v-if="daySessions">
|
||||||
|
<div class="day-sessions-header">{{ daySessions.dateLabel }}<template v-if="daySessions.isEmpty"> — no sessions</template></div>
|
||||||
|
<div class="day-activity-timeline" v-if="!daySessions.isEmpty">
|
||||||
|
<!-- New workspaces -->
|
||||||
|
<div class="activity-group" v-if="daySessions.newWorkspaces.length">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon workspace">★</span>
|
||||||
|
<span class="activity-group-title">Created {{ daySessions.newWorkspaces.length }} new workspace{{ daySessions.newWorkspaces.length > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
<button
|
||||||
|
v-for="s in daySessions.newWorkspaces"
|
||||||
|
:key="s.id"
|
||||||
|
class="activity-item"
|
||||||
|
@click="goToSession(s.id)"
|
||||||
|
>
|
||||||
|
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
||||||
|
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- New sessions -->
|
||||||
|
<div class="activity-group" v-if="daySessions.newSessions.length">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon new">+</span>
|
||||||
|
<span class="activity-group-title">Started {{ daySessions.newSessions.length }} session{{ daySessions.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(daySessions.newSessions) }} project{{ newSessionProjectCount(daySessions.newSessions) > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
<button
|
||||||
|
v-for="s in daySessions.newSessions"
|
||||||
|
:key="s.id"
|
||||||
|
class="activity-item"
|
||||||
|
@click="goToSession(s.id)"
|
||||||
|
>
|
||||||
|
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||||
|
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Continued sessions -->
|
||||||
|
<div class="activity-group continued" v-if="daySessions.continued.length">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon continued">↳</span>
|
||||||
|
<span class="activity-group-title">Continued {{ daySessions.continued.length }} session{{ daySessions.continued.length > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
<button
|
||||||
|
v-for="s in daySessions.continued"
|
||||||
|
:key="s.id"
|
||||||
|
class="activity-item"
|
||||||
|
@click="goToSession(s.id)"
|
||||||
|
>
|
||||||
|
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||||
|
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly activity blocks -->
|
||||||
|
<div class="day-sessions" v-if="!selectedDayKey">
|
||||||
|
<template v-for="(block, bi) in monthBlocks" :key="bi">
|
||||||
|
<div class="day-sessions-header">{{ block.header }}</div>
|
||||||
|
<div class="day-activity-timeline" v-if="!block.isEmpty">
|
||||||
|
<div class="activity-group" v-if="block.newWorkspaces.length">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon workspace">★</span>
|
||||||
|
<span class="activity-group-title">Created {{ block.newWorkspaces.length }} new workspace{{ block.newWorkspaces.length > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
<button
|
||||||
|
v-for="s in block.newWorkspaces"
|
||||||
|
:key="s.id"
|
||||||
|
class="activity-item"
|
||||||
|
@click="goToSession(s.id)"
|
||||||
|
>
|
||||||
|
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
|
||||||
|
<span class="activity-item-meta">{{ s.message_count || 0 }} msg</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group" v-if="block.newSessions.length">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon new">+</span>
|
||||||
|
<span class="activity-group-title">Started {{ block.newSessions.length }} session{{ block.newSessions.length > 1 ? 's' : '' }} in {{ newSessionProjectCount(block.newSessions) }} project{{ newSessionProjectCount(block.newSessions) > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
<button
|
||||||
|
v-for="s in block.newSessions"
|
||||||
|
:key="s.id"
|
||||||
|
class="activity-item"
|
||||||
|
@click="goToSession(s.id)"
|
||||||
|
>
|
||||||
|
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||||
|
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group continued" v-if="block.continued.length">
|
||||||
|
<div class="activity-group-header">
|
||||||
|
<span class="activity-icon continued">↳</span>
|
||||||
|
<span class="activity-group-title">Continued {{ block.continued.length }} session{{ block.continued.length > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="activity-group-items">
|
||||||
|
<button
|
||||||
|
v-for="s in block.continued"
|
||||||
|
:key="s.id"
|
||||||
|
class="activity-item"
|
||||||
|
@click="goToSession(s.id)"
|
||||||
|
>
|
||||||
|
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
|
||||||
|
<span class="activity-item-meta">{{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>
|
||||||
|
</template>
|
||||||
|
<button class="show-more-btn" @click="showNextMonth">Show more activity</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<div
|
||||||
|
class="chart-tooltip"
|
||||||
|
:class="{ show: tooltip.show }"
|
||||||
|
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
|
||||||
|
>{{ tooltip.text }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.usage-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||||
|
.usage-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.usage-title { font-size: 16px; font-weight: 600; color: var(--fg); letter-spacing: -0.01em; }
|
||||||
|
|
||||||
|
.usage-view-tabs { display: flex; gap: 0; }
|
||||||
|
.usage-tab {
|
||||||
|
padding: 5px 12px; font-size: 12px; font-family: var(--font-mono);
|
||||||
|
color: var(--muted); background: transparent;
|
||||||
|
border: 1px solid var(--hairline); cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.usage-tab:first-child { border-radius: 4px 0 0 4px; }
|
||||||
|
.usage-tab:last-child { border-radius: 0 4px 4px 0; }
|
||||||
|
.usage-tab:not(:first-child) { border-left: 0; }
|
||||||
|
.usage-tab:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||||
|
.usage-tab.active { color: var(--fg); background: var(--accent-soft); border-color: var(--accent-soft); }
|
||||||
|
|
||||||
|
.usage-stats {
|
||||||
|
display: flex; gap: 0; margin-bottom: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--hairline);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.usage-stat {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 4px; padding: 16px 12px;
|
||||||
|
border-right: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.usage-stat:last-child { border-right: 0; }
|
||||||
|
.usage-stat-value { font-size: 18px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||||
|
.usage-stat-label { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); text-align: center; }
|
||||||
|
|
||||||
|
.heatmap-container { margin-top: 8px; }
|
||||||
|
.heatmap { display: block; width: 100%; height: auto; }
|
||||||
|
.heatmap-cell { transition: opacity 0.08s; cursor: pointer; }
|
||||||
|
.heatmap-cell.level-0 { fill: var(--surface-strong); }
|
||||||
|
.heatmap-cell.level-1 { fill: rgba(99, 102, 241, 0.3); }
|
||||||
|
.heatmap-cell.level-2 { fill: rgba(99, 102, 241, 0.5); }
|
||||||
|
.heatmap-cell.level-3 { fill: rgba(139, 92, 246, 0.7); }
|
||||||
|
.heatmap-cell.level-4 { fill: rgba(168, 85, 247, 0.9); }
|
||||||
|
.heatmap-cell:hover { opacity: 0.7; }
|
||||||
|
.heatmap-cell.selected { stroke: var(--fg); stroke-width: 1.5; }
|
||||||
|
.heatmap-month { font-size: 10px; fill: var(--muted-2); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.heatmap-legend {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
margin-top: 12px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.heatmap-legend-label { font-size: 10px; color: var(--muted-2); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Chart container (weekly / cumulative) */
|
||||||
|
.chart-container { margin-top: 8px; overflow-x: auto; }
|
||||||
|
.chart-container svg { display: block; width: 100%; max-height: 160px; }
|
||||||
|
|
||||||
|
.bar-fill { fill: var(--accent); opacity: 0.8; transition: opacity 0.08s; cursor: pointer; }
|
||||||
|
.bar-fill:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.cumulative-area { fill: rgba(99, 102, 241, 0.12); }
|
||||||
|
.cumulative-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
|
||||||
|
.cumulative-dot { fill: var(--accent); opacity: 0; transition: opacity 0.08s; cursor: pointer; }
|
||||||
|
.cumulative-dot:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Chart tooltip */
|
||||||
|
.chart-tooltip {
|
||||||
|
position: fixed; z-index: 200;
|
||||||
|
padding: 5px 10px; border-radius: 4px;
|
||||||
|
background: rgba(30, 35, 50, 0.95);
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
pointer-events: none; opacity: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
.chart-tooltip.show { opacity: 1; }
|
||||||
|
|
||||||
|
/* Day sessions panel */
|
||||||
|
.day-sessions { margin-top: 24px; }
|
||||||
|
.day-sessions-header {
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--fg);
|
||||||
|
margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.day-sessions-header:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.day-activity-timeline {
|
||||||
|
display: flex; flex-direction: column; gap: 20px;
|
||||||
|
padding-left: 16px; border-left: 2px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-group { position: relative; }
|
||||||
|
.activity-group-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 8px; font-size: 14px; color: var(--fg);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.activity-icon {
|
||||||
|
width: 24px; height: 24px; border-radius: 50%;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; flex-shrink: 0;
|
||||||
|
margin-left: -28px;
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
||||||
|
.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); }
|
||||||
|
.activity-icon.continued { background: var(--surface-strong); color: var(--muted); }
|
||||||
|
|
||||||
|
.activity-group-title { font-size: 13px; }
|
||||||
|
.activity-group.continued .activity-group-title { color: var(--muted); }
|
||||||
|
|
||||||
|
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
|
||||||
|
.activity-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 8px 12px; border-radius: 5px;
|
||||||
|
background: transparent; border: 0;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; width: 100%;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.activity-item:hover { background: var(--surface-strong); }
|
||||||
|
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
|
||||||
|
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.activity-group.continued .activity-item-name { color: var(--fg-2); }
|
||||||
|
|
||||||
|
.show-more-btn {
|
||||||
|
display: block; width: 100%; margin-top: 20px;
|
||||||
|
padding: 8px; border-radius: 4px;
|
||||||
|
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
|
||||||
|
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
|
||||||
|
cursor: pointer; transition: all 0.1s; text-align: center;
|
||||||
|
}
|
||||||
|
.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0a0b14;
|
||||||
|
--bg-2: #11131f;
|
||||||
|
--surface: rgba(255,255,255,0.03);
|
||||||
|
--surface-strong: rgba(255,255,255,0.06);
|
||||||
|
--surface-hi: rgba(255,255,255,0.09);
|
||||||
|
--fg: rgba(255,255,255,0.92);
|
||||||
|
--fg-2: rgba(255,255,255,0.72);
|
||||||
|
--muted: rgba(255,255,255,0.48);
|
||||||
|
--muted-2: rgba(255,255,255,0.28);
|
||||||
|
--edge-hi: rgba(255,255,255,0.08);
|
||||||
|
--edge-lo: rgba(0,0,0,0.35);
|
||||||
|
--hairline: rgba(255,255,255,0.05);
|
||||||
|
--hairline-strong: rgba(255,255,255,0.08);
|
||||||
|
--accent: #a78bfa;
|
||||||
|
--accent-2: #c4b5fd;
|
||||||
|
--accent-glow: rgba(167,139,250,0.35);
|
||||||
|
--accent-soft: rgba(167,139,250,0.12);
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-soft: rgba(248,113,113,0.12);
|
||||||
|
--warn: #fbbf24;
|
||||||
|
--warn-soft: rgba(251,191,36,0.14);
|
||||||
|
--workflow: #f59e0b;
|
||||||
|
--workflow-soft: rgba(245,158,11,0.12);
|
||||||
|
--workflow-strong: rgba(245,158,11,0.28);
|
||||||
|
--user-bubble: rgba(167,139,250,0.08);
|
||||||
|
--user-bubble-border: rgba(167,139,250,0.18);
|
||||||
|
--asst-bubble: rgba(255,255,255,0.025);
|
||||||
|
--asst-bubble-border: rgba(255,255,255,0.06);
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-mono: ui-monospace, 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
|
||||||
|
--text-xs: 11px;
|
||||||
|
--text-sm: 12px;
|
||||||
|
--text-base: 13px;
|
||||||
|
--text-md: 14px;
|
||||||
|
--row-h: 88px;
|
||||||
|
--row-h-session: 64px;
|
||||||
|
--row-h-compact: 28px;
|
||||||
|
--col-sidebar: 220px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; overflow: hidden; }
|
||||||
|
body {
|
||||||
|
color: var(--fg);
|
||||||
|
font: var(--text-base)/1.4 var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
background-color: var(--bg);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(80% 60% at 100% 0%, rgba(236, 72, 153, 0.14), transparent 55%),
|
||||||
|
radial-gradient(60% 50% at 50% 40%, rgba(167, 139, 250, 0.12), transparent 60%),
|
||||||
|
radial-gradient(70% 60% at 0% 100%, rgba(99, 102, 241, 0.16), transparent 60%),
|
||||||
|
linear-gradient(to bottom, var(--bg) 0%, var(--bg-2) 100%);
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
pointer-events: none; z-index: 1;
|
||||||
|
opacity: 0.3;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.05 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
}
|
||||||
|
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
|
||||||
|
button:disabled { cursor: not-allowed; }
|
||||||
|
input { font: inherit; color: inherit; }
|
||||||
|
::selection { background: var(--accent-soft); color: var(--fg); }
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.16); background-clip: padding-box; border: 2px solid transparent; }
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
height: 32px; width: 100%;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
background: rgba(0,0,0,0.15);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
flex-shrink: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0 16px 0 78px;
|
||||||
|
}
|
||||||
|
.titlebar-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
max-width: 100%; user-select: none; pointer-events: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.titlebar-text .app-name { color: var(--fg-2); font-weight: 600; }
|
||||||
|
.titlebar-text .sep { margin: 0 6px; color: var(--muted-2); }
|
||||||
|
.titlebar-text .scope { color: var(--muted); }
|
||||||
|
.titlebar-text .scope-leaf { color: var(--fg-2); font-family: var(--font-mono); font-size: 11.5px; }
|
||||||
|
|
||||||
|
button, input, .row, .sidebar-item, .toolbar-btn,
|
||||||
|
.row-action, .row-checkbox, .crumb, .provenance-link,
|
||||||
|
.banner-action, .source-toggle, .anchor-link,
|
||||||
|
.session-link, .msg-tool, .toolcall-toggle,
|
||||||
|
.agent-indicator, .agent-row, .filter-toggle,
|
||||||
|
.summary-toggle {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app { position: relative; z-index: 2; height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
.columns { flex: 1; display: grid; grid-template-columns: var(--col-sidebar) 1fr; min-height: 0; }
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
.detail { max-width: 720px; margin: 0 auto; padding: 32px 32px 60px; }
|
||||||
|
.detail-wide { max-width: 860px; margin: 0 auto; padding: 28px 32px 60px; }
|
||||||
|
|
||||||
|
/* Usage page */
|
||||||
|
.usage-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||||
|
.usage-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.usage-title { font-size: 16px; font-weight: 600; color: var(--fg); letter-spacing: -0.01em; }
|
||||||
|
.usage-subtitle { font-size: 13px; color: var(--muted); }
|
||||||
|
|
||||||
|
.usage-view-tabs { display: flex; gap: 0; }
|
||||||
|
.usage-tab {
|
||||||
|
padding: 5px 12px; font-size: 12px; font-family: var(--font-mono);
|
||||||
|
color: var(--muted); background: transparent;
|
||||||
|
border: 1px solid var(--hairline); cursor: pointer;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.usage-tab:first-child { border-radius: 4px 0 0 4px; }
|
||||||
|
.usage-tab:last-child { border-radius: 0 4px 4px 0; }
|
||||||
|
.usage-tab:not(:first-child) { border-left: 0; }
|
||||||
|
.usage-tab:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||||
|
.usage-tab.active { color: var(--fg); background: var(--accent-soft); border-color: var(--accent-soft); }
|
||||||
|
|
||||||
|
.usage-stats {
|
||||||
|
display: flex; gap: 0; margin-bottom: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--hairline);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.usage-stat {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 4px; padding: 16px 12px;
|
||||||
|
border-right: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.usage-stat:last-child { border-right: 0; }
|
||||||
|
.usage-stat-value { font-size: 18px; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||||
|
.usage-stat-label { font-size: 10.5px; color: var(--muted); font-family: var(--font-mono); text-align: center; }
|
||||||
|
|
||||||
|
.heatmap-container { margin-top: 8px; }
|
||||||
|
.heatmap { display: block; width: 100%; height: auto; }
|
||||||
|
.heatmap-cell { transition: opacity 0.08s; }
|
||||||
|
.heatmap-cell.level-0 { fill: var(--surface-strong); }
|
||||||
|
.heatmap-cell.level-1 { fill: rgba(99, 102, 241, 0.3); }
|
||||||
|
.heatmap-cell.level-2 { fill: rgba(99, 102, 241, 0.5); }
|
||||||
|
.heatmap-cell.level-3 { fill: rgba(139, 92, 246, 0.7); }
|
||||||
|
.heatmap-cell.level-4 { fill: rgba(168, 85, 247, 0.9); }
|
||||||
|
.heatmap-cell:hover { opacity: 0.7; }
|
||||||
|
.heatmap-cell.selected { stroke: var(--fg); stroke-width: 1.5; }
|
||||||
|
.heatmap-month { font-size: 10px; fill: var(--muted-2); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Day sessions panel (below heatmap on click) */
|
||||||
|
.day-sessions { margin-top: 24px; }
|
||||||
|
.day-sessions-header {
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--fg);
|
||||||
|
margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.day-sessions-header:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.day-activity-timeline {
|
||||||
|
display: flex; flex-direction: column; gap: 20px;
|
||||||
|
padding-left: 16px; border-left: 2px solid var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-group { position: relative; }
|
||||||
|
.activity-group-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 8px; font-size: 14px; color: var(--fg);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.activity-icon {
|
||||||
|
width: 24px; height: 24px; border-radius: 50%;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; flex-shrink: 0;
|
||||||
|
margin-left: -28px;
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
}
|
||||||
|
.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
||||||
|
.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); }
|
||||||
|
.activity-icon.continued { background: var(--surface-strong); color: var(--muted); }
|
||||||
|
|
||||||
|
.activity-group-title { font-size: 13px; }
|
||||||
|
.activity-group.continued .activity-group-title { color: var(--muted); }
|
||||||
|
|
||||||
|
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
|
||||||
|
.activity-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 8px 12px; border-radius: 5px;
|
||||||
|
background: transparent; border: 0;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; width: 100%;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.activity-item:hover { background: var(--surface-strong); }
|
||||||
|
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
|
||||||
|
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.activity-group.continued .activity-item-name { color: var(--fg-2); }
|
||||||
|
|
||||||
|
.show-more-btn {
|
||||||
|
display: block; width: 100%; margin-top: 20px;
|
||||||
|
padding: 8px; border-radius: 4px;
|
||||||
|
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
|
||||||
|
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
|
||||||
|
cursor: pointer; transition: all 0.1s; text-align: center;
|
||||||
|
}
|
||||||
|
.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.heatmap-legend {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
margin-top: 12px; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.heatmap-legend-label { font-size: 10px; color: var(--muted-2); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Chart container (weekly / cumulative) */
|
||||||
|
.chart-container { margin-top: 8px; overflow-x: auto; }
|
||||||
|
.chart-container svg { display: block; width: 100%; max-height: 160px; }
|
||||||
|
|
||||||
|
.bar-fill { fill: var(--accent); opacity: 0.8; transition: opacity 0.08s; }
|
||||||
|
.bar-fill:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.cumulative-area { fill: rgba(99, 102, 241, 0.12); }
|
||||||
|
.cumulative-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
|
||||||
|
.cumulative-dot { fill: var(--accent); opacity: 0; transition: opacity 0.08s; }
|
||||||
|
.cumulative-dot:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Chart tooltip */
|
||||||
|
.chart-tooltip {
|
||||||
|
position: fixed; z-index: 200;
|
||||||
|
padding: 5px 10px; border-radius: 4px;
|
||||||
|
background: rgba(30, 35, 50, 0.95);
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
color: var(--fg-2);
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
pointer-events: none; opacity: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.chart-tooltip.show { opacity: 1; }
|
||||||
|
|
||||||
|
/* Session progress bar */
|
||||||
|
.session-progress {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
height: 2px; background: var(--hairline);
|
||||||
|
margin: 0 -32px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.session-progress-fill {
|
||||||
|
height: 100%; background: var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
transition: width 0.15s ease-out;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-banner {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
padding: 10px 12px; border-radius: 6px; margin-bottom: 18px;
|
||||||
|
font-size: var(--text-base); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.detail-banner.broken { background: var(--danger-soft); border: 1px solid rgba(248,113,113,0.25); color: var(--fg); }
|
||||||
|
.detail-banner.partial { background: var(--warn-soft); border: 1px solid rgba(251,191,36,0.25); color: var(--fg); }
|
||||||
|
.detail-banner-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||||
|
.detail-banner.broken .detail-banner-icon { color: var(--danger); }
|
||||||
|
.detail-banner.partial .detail-banner-icon { color: var(--warn); }
|
||||||
|
.detail-banner-body { flex: 1; min-width: 0; }
|
||||||
|
.detail-banner-body strong { font-weight: 600; }
|
||||||
|
.detail-banner-body ul { margin-top: 4px; padding-left: 16px; color: var(--fg-2); font-size: var(--text-sm); }
|
||||||
|
.detail-banner-body li { list-style: disc; margin: 2px 0; }
|
||||||
|
.detail-banner-actions { display: flex; gap: 6px; margin-top: 8px; }
|
||||||
|
.banner-action {
|
||||||
|
height: 24px; padding: 0 10px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--fg-2); font-size: var(--text-sm);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.banner-action:hover { background: var(--surface-strong); color: var(--fg); }
|
||||||
|
.banner-action.danger { color: var(--danger); border-color: rgba(248,113,113,0.3); }
|
||||||
|
.banner-action.danger:hover { background: var(--danger-soft); }
|
||||||
|
|
||||||
|
.detail-header { margin-bottom: 24px; }
|
||||||
|
.detail-eyebrow {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
margin-bottom: 14px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.detail-eyebrow .project-icon { width: 13px; height: 13px; color: var(--muted); }
|
||||||
|
.detail-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
|
||||||
|
.detail-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); }
|
||||||
|
.detail-eyebrow .sep { color: var(--muted-2); margin: 0 2px; }
|
||||||
|
.detail-eyebrow .archived-tag {
|
||||||
|
color: var(--accent-2);
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.detail-eyebrow .archived-tag::before {
|
||||||
|
content: ''; width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.detail-path {
|
||||||
|
font-family: var(--font-mono); font-size: 17px; font-weight: 500;
|
||||||
|
color: var(--fg); line-height: 1.5;
|
||||||
|
word-break: break-all; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.detail-summary { font-size: var(--text-md); color: var(--fg-2); line-height: 1.6; margin-bottom: 16px; }
|
||||||
|
.detail-meta {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||||
|
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||||
|
padding-bottom: 16px; border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.detail-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.session-link {
|
||||||
|
color: var(--accent-2); border: 0; background: transparent;
|
||||||
|
padding: 2px 5px; margin: -2px 0; border-radius: 3px;
|
||||||
|
font: inherit; cursor: pointer; transition: all 0.1s;
|
||||||
|
text-decoration: underline; text-decoration-color: var(--accent-soft);
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
}
|
||||||
|
.session-link:hover { background: var(--accent-soft); color: var(--accent-2); text-decoration-color: var(--accent-2); }
|
||||||
|
.session-link svg { width: 11px; height: 11px; }
|
||||||
|
|
||||||
|
.markdown-section { margin: 28px 0 8px; }
|
||||||
|
.markdown-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.markdown-toolbar-label {
|
||||||
|
font-size: 10.5px; color: var(--muted);
|
||||||
|
font-weight: 500; letter-spacing: 0.04em; flex: 1;
|
||||||
|
}
|
||||||
|
.source-toggle {
|
||||||
|
height: 22px; padding: 0 8px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--hairline-strong); background: var(--surface);
|
||||||
|
color: var(--muted); font-size: var(--text-sm);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.source-toggle:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||||
|
.source-toggle.active { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
||||||
|
|
||||||
|
.markdown-body { font-size: var(--text-md); line-height: 1.65; color: var(--fg); word-wrap: break-word; }
|
||||||
|
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
|
||||||
|
font-weight: 600; letter-spacing: -0.01em;
|
||||||
|
margin: 1.5em 0 0.5em; line-height: 1.3;
|
||||||
|
}
|
||||||
|
.markdown-body h1:first-child, .markdown-body h2:first-child, .markdown-body h3:first-child { margin-top: 0; }
|
||||||
|
.markdown-body h1 { font-size: 20px; }
|
||||||
|
.markdown-body h2 { font-size: 17px; }
|
||||||
|
.markdown-body h3 { font-size: 15px; }
|
||||||
|
.markdown-body p { margin: 0.6em 0; }
|
||||||
|
.markdown-body ul, .markdown-body ol { margin: 0.6em 0; padding-left: 24px; }
|
||||||
|
.markdown-body li { margin: 0.2em 0; }
|
||||||
|
.markdown-body code {
|
||||||
|
font-family: var(--font-mono); font-size: 12.5px;
|
||||||
|
background: rgba(255,255,255,0.06); padding: 1px 5px;
|
||||||
|
border-radius: 3px; color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.markdown-body pre {
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 6px; padding: 12px 14px;
|
||||||
|
overflow-x: auto; margin: 0.8em 0;
|
||||||
|
}
|
||||||
|
.markdown-body pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 12px; line-height: 1.55; }
|
||||||
|
.markdown-body blockquote {
|
||||||
|
margin: 0.8em 0; padding: 0 0 0 14px;
|
||||||
|
border-left: 2px solid var(--accent-soft);
|
||||||
|
color: var(--fg-2); font-style: italic;
|
||||||
|
}
|
||||||
|
.markdown-body a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.markdown-body hr { border: 0; border-top: 1px solid var(--hairline); margin: 1.5em 0; }
|
||||||
|
.markdown-body table { border-collapse: collapse; margin: 0.8em 0; font-size: 12.5px; }
|
||||||
|
.markdown-body th, .markdown-body td { border: 1px solid var(--hairline); padding: 6px 10px; text-align: left; }
|
||||||
|
.markdown-body th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
||||||
|
.markdown-source {
|
||||||
|
background: rgba(0,0,0,0.3); border: 1px solid var(--hairline);
|
||||||
|
border-radius: 6px; padding: 14px 16px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px; line-height: 1.55;
|
||||||
|
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-compact { font-size: var(--text-sm); line-height: 1.55; color: var(--fg-2); word-wrap: break-word; }
|
||||||
|
.markdown-compact h1, .markdown-compact h2, .markdown-compact h3 {
|
||||||
|
font-weight: 600; letter-spacing: -0.01em;
|
||||||
|
margin: 1em 0 0.4em; line-height: 1.3; color: var(--fg);
|
||||||
|
}
|
||||||
|
.markdown-compact h1:first-child, .markdown-compact h2:first-child, .markdown-compact h3:first-child { margin-top: 0; }
|
||||||
|
.markdown-compact h1 { font-size: var(--text-md); }
|
||||||
|
.markdown-compact h2 { font-size: var(--text-base); }
|
||||||
|
.markdown-compact h3 { font-size: var(--text-sm); }
|
||||||
|
.markdown-compact p { margin: 0.5em 0; }
|
||||||
|
.markdown-compact p:first-child { margin-top: 0; }
|
||||||
|
.markdown-compact p:last-child { margin-bottom: 0; }
|
||||||
|
.markdown-compact ul, .markdown-compact ol { margin: 0.5em 0; padding-left: 20px; }
|
||||||
|
.markdown-compact li { margin: 0.15em 0; }
|
||||||
|
.markdown-compact code {
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px;
|
||||||
|
background: rgba(255,255,255,0.06); padding: 1px 4px;
|
||||||
|
border-radius: 3px; color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.markdown-compact pre {
|
||||||
|
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline);
|
||||||
|
border-radius: 4px; padding: 8px 10px;
|
||||||
|
overflow-x: auto; margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
.markdown-compact pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11px; line-height: 1.5; }
|
||||||
|
.markdown-compact blockquote {
|
||||||
|
margin: 0.6em 0; padding: 0 0 0 12px;
|
||||||
|
border-left: 2px solid var(--accent-soft);
|
||||||
|
color: var(--muted); font-style: italic;
|
||||||
|
}
|
||||||
|
.markdown-compact strong { color: var(--fg); font-weight: 600; }
|
||||||
|
.markdown-compact a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.markdown-compact table { border-collapse: collapse; margin: 0.6em 0; font-size: 11.5px; }
|
||||||
|
.markdown-compact th, .markdown-compact td { border: 1px solid var(--hairline); padding: 4px 8px; text-align: left; }
|
||||||
|
.markdown-compact th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
||||||
|
.markdown-compact mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
||||||
|
|
||||||
|
.markdown-msg { font-size: var(--text-base); line-height: 1.6; color: var(--fg); word-wrap: break-word; }
|
||||||
|
.markdown-msg h1, .markdown-msg h2, .markdown-msg h3 {
|
||||||
|
font-weight: 600; letter-spacing: -0.01em;
|
||||||
|
margin: 1em 0 0.4em; line-height: 1.3;
|
||||||
|
}
|
||||||
|
.markdown-msg h1:first-child, .markdown-msg h2:first-child, .markdown-msg h3:first-child { margin-top: 0; }
|
||||||
|
.markdown-msg h1 { font-size: 16px; }
|
||||||
|
.markdown-msg h2 { font-size: 15px; }
|
||||||
|
.markdown-msg h3 { font-size: var(--text-md); }
|
||||||
|
.markdown-msg p { margin: 0.5em 0; }
|
||||||
|
.markdown-msg p:first-child { margin-top: 0; }
|
||||||
|
.markdown-msg p:last-child { margin-bottom: 0; }
|
||||||
|
.markdown-msg ul, .markdown-msg ol { margin: 0.5em 0; padding-left: 22px; }
|
||||||
|
.markdown-msg li { margin: 0.18em 0; }
|
||||||
|
.markdown-msg code {
|
||||||
|
font-family: var(--font-mono); font-size: 12px;
|
||||||
|
background: rgba(255,255,255,0.06); padding: 1px 5px;
|
||||||
|
border-radius: 3px; color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.markdown-msg pre {
|
||||||
|
background: rgba(0,0,0,0.4); border: 1px solid var(--hairline);
|
||||||
|
border-radius: 5px; padding: 10px 12px;
|
||||||
|
overflow-x: auto; margin: 0.7em 0;
|
||||||
|
}
|
||||||
|
.markdown-msg pre code { background: transparent; padding: 0; color: var(--fg-2); font-size: 11.5px; line-height: 1.55; }
|
||||||
|
.markdown-msg blockquote {
|
||||||
|
margin: 0.6em 0; padding: 0 0 0 12px;
|
||||||
|
border-left: 2px solid var(--accent-soft);
|
||||||
|
color: var(--fg-2); font-style: italic;
|
||||||
|
}
|
||||||
|
.markdown-msg strong { font-weight: 600; }
|
||||||
|
.markdown-msg a { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.markdown-msg table { border-collapse: collapse; margin: 0.6em 0; font-size: 12px; }
|
||||||
|
.markdown-msg th, .markdown-msg td { border: 1px solid var(--hairline); padding: 5px 9px; text-align: left; }
|
||||||
|
.markdown-msg th { background: rgba(255,255,255,0.04); font-weight: 600; }
|
||||||
|
.markdown-msg mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
||||||
|
|
||||||
|
.detail-section-divider {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin: 32px 0 14px; color: var(--muted);
|
||||||
|
font-size: 10.5px; font-weight: 500; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.detail-section-divider::after { content: ''; flex: 1; height: 1px; background: var(--hairline); }
|
||||||
|
.detail-section-divider .count { font-family: var(--font-mono); color: var(--fg-2); }
|
||||||
|
.anchor-list { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.anchor-link {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px; border-radius: 4px;
|
||||||
|
color: var(--fg-2); font-family: var(--font-mono); font-size: 12px;
|
||||||
|
transition: all 0.1s; cursor: pointer;
|
||||||
|
text-align: left; border: 0; background: transparent; width: 100%;
|
||||||
|
}
|
||||||
|
.anchor-link:hover { background: var(--surface-strong); color: var(--fg); }
|
||||||
|
.anchor-link .anchor-icon { width: 12px; height: 12px; color: var(--muted); flex-shrink: 0; }
|
||||||
|
.anchor-link:hover .anchor-icon { color: var(--accent-2); }
|
||||||
|
.anchor-link .anchor-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.anchor-link .anchor-line { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||||
|
.anchor-link:disabled {
|
||||||
|
color: var(--muted-2);
|
||||||
|
text-decoration: line-through; text-decoration-color: var(--danger); text-decoration-thickness: 1px;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.anchor-link:disabled .anchor-icon { color: var(--danger); }
|
||||||
|
.anchor-link:disabled:hover { background: transparent; color: var(--muted-2); }
|
||||||
|
|
||||||
|
.detail-actions { margin-top: 32px; display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.detail-actions .btn {
|
||||||
|
height: 30px; padding: 0 14px; border-radius: 6px;
|
||||||
|
font-size: var(--text-base); font-weight: 500;
|
||||||
|
transition: all 0.1s;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
color: var(--fg-2); background: var(--surface);
|
||||||
|
}
|
||||||
|
.detail-actions .btn:hover { background: var(--surface-strong); color: var(--fg); }
|
||||||
|
.detail-actions .btn.danger { color: var(--danger); }
|
||||||
|
.detail-actions .btn.danger:hover { background: var(--danger-soft); border-color: rgba(248,113,113,0.3); }
|
||||||
|
.detail-actions .btn.primary { color: var(--accent-2); }
|
||||||
|
.detail-actions .btn.primary:hover { background: var(--accent-soft); border-color: var(--accent-soft); color: var(--fg); }
|
||||||
|
.detail-actions .btn .kbd {
|
||||||
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||||
|
padding: 0 4px; border: 1px solid var(--hairline); border-radius: 3px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-header {
|
||||||
|
margin-bottom: 28px; padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.session-eyebrow {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
margin-bottom: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.session-eyebrow .project-icon { width: 13px; height: 13px; }
|
||||||
|
.session-eyebrow .project-name { color: var(--fg-2); font-weight: 500; }
|
||||||
|
.session-eyebrow .project-path { font-family: var(--font-mono); color: var(--muted); }
|
||||||
|
.session-eyebrow .sep { color: var(--muted-2); margin: 0 2px; }
|
||||||
|
.session-title {
|
||||||
|
font-size: 22px; font-weight: 600; color: var(--fg);
|
||||||
|
line-height: 1.3; margin-bottom: 14px; letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.session-meta-inline {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-sm);
|
||||||
|
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.session-meta-inline .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.timeline { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.msg {
|
||||||
|
border-radius: 8px; padding: 12px 14px;
|
||||||
|
border: 1px solid; position: relative;
|
||||||
|
transition: border-color 0.6s ease-out, box-shadow 0.6s ease-out;
|
||||||
|
}
|
||||||
|
.msg.user { background: var(--user-bubble); border-color: var(--user-bubble-border); }
|
||||||
|
.msg.assistant { background: var(--asst-bubble); border-color: var(--asst-bubble-border); }
|
||||||
|
.msg.is-focused {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent), 0 0 22px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.msg-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
margin-bottom: 8px; font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.msg-head .role {
|
||||||
|
font-weight: 600; color: var(--fg-2);
|
||||||
|
text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.msg.user .msg-head .role { color: var(--accent-2); }
|
||||||
|
.msg-head .when { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.msg-text {
|
||||||
|
font-size: var(--text-base); line-height: 1.55; color: var(--fg);
|
||||||
|
white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.msg-text.empty-text { color: var(--muted-2); font-style: italic; }
|
||||||
|
.msg-text mark { background: var(--accent-soft); color: var(--accent-2); padding: 0 2px; border-radius: 2px; }
|
||||||
|
|
||||||
|
.msg-summary {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-left: 3px solid var(--accent-soft);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(167,139,250,0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.summary-toggle {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 100%; padding: 7px 12px;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; border: 0; background: transparent;
|
||||||
|
color: inherit; font: inherit;
|
||||||
|
}
|
||||||
|
.summary-toggle:hover { background: rgba(167,139,250,0.06); }
|
||||||
|
.summary-toggle .chevron {
|
||||||
|
width: 8px; height: 8px; color: var(--muted);
|
||||||
|
transition: transform 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.msg-summary.open .summary-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
||||||
|
.summary-toggle .label {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--accent-2); font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.summary-toggle .source { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); margin-left: 4px; }
|
||||||
|
.summary-body {
|
||||||
|
display: none; padding: 8px 14px 12px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.msg-summary.open .summary-body { display: block; }
|
||||||
|
|
||||||
|
.msg-tools { margin-top: 10px; display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.msg-tool {
|
||||||
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
overflow: hidden; transition: border-color 0.1s;
|
||||||
|
}
|
||||||
|
.msg-tool.is-error { border-color: rgba(248,113,113,0.3); background: var(--danger-soft); }
|
||||||
|
.toolcall-toggle {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 100%; padding: 6px 10px;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; border: 0; background: transparent;
|
||||||
|
color: inherit; font: inherit;
|
||||||
|
}
|
||||||
|
.toolcall-toggle:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.toolcall-toggle .chevron {
|
||||||
|
width: 8px; height: 8px; color: var(--muted);
|
||||||
|
transition: transform 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.msg-tool.open .toolcall-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
||||||
|
.toolcall-toggle .tool-name {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--accent-2); font-weight: 600; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.msg-tool.is-error .toolcall-toggle .tool-name { color: var(--danger); }
|
||||||
|
.toolcall-toggle .tool-arg {
|
||||||
|
font-family: var(--font-mono); font-size: 11px; color: var(--fg-2);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.toolcall-toggle .tool-error {
|
||||||
|
font-size: 10px; color: var(--danger);
|
||||||
|
padding: 1px 6px; background: rgba(248,113,113,0.18); border-radius: 3px;
|
||||||
|
flex-shrink: 0; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; font-weight: 500;
|
||||||
|
}
|
||||||
|
.toolcall-body {
|
||||||
|
display: none; border-top: 1px solid var(--hairline);
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
padding: 10px 12px; max-height: 300px; overflow: auto;
|
||||||
|
}
|
||||||
|
.msg-tool.open .toolcall-body { display: block; }
|
||||||
|
.toolcall-body .tc-section {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||||
|
letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
margin: 4px 0 4px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.toolcall-body .tc-section:first-child { margin-top: 0; }
|
||||||
|
.toolcall-body pre {
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.5;
|
||||||
|
color: var(--fg-2); white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tc-subagent {
|
||||||
|
margin-top: 8px; padding: 8px 10px;
|
||||||
|
border: 1px solid var(--workflow-soft);
|
||||||
|
border-left: 3px solid var(--workflow);
|
||||||
|
border-radius: 4px; background: var(--workflow-soft);
|
||||||
|
}
|
||||||
|
.tc-subagent-head {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--workflow); margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.tc-subagent-head svg { width: 11px; height: 11px; flex-shrink: 0; }
|
||||||
|
.tc-subagent-head .label {
|
||||||
|
font-weight: 600; text-transform: uppercase;
|
||||||
|
font-size: 9.5px; letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.tc-subagent-head .type {
|
||||||
|
color: var(--fg-2); font-weight: 500;
|
||||||
|
text-transform: none; letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-indicator {
|
||||||
|
margin-top: 10px; display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 5px 10px; border-radius: 5px;
|
||||||
|
border: 1px solid var(--workflow-soft);
|
||||||
|
background: var(--workflow-soft);
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--workflow);
|
||||||
|
cursor: pointer; transition: all 0.1s; text-align: left;
|
||||||
|
}
|
||||||
|
.agent-indicator:hover { background: var(--workflow-strong); border-color: var(--workflow); }
|
||||||
|
.agent-indicator svg { width: 11px; height: 11px; flex-shrink: 0; }
|
||||||
|
.agent-indicator .chevron { transition: transform 0.15s; }
|
||||||
|
.agent-indicator.open .chevron { transform: rotate(90deg); }
|
||||||
|
.agent-indicator .label { color: var(--fg-2); }
|
||||||
|
.agent-indicator .meta { color: var(--muted); margin-left: 4px; }
|
||||||
|
|
||||||
|
.workflow-card {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--workflow-soft);
|
||||||
|
border-left: 3px solid var(--workflow);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
overflow: hidden; display: none;
|
||||||
|
}
|
||||||
|
.workflow-card.show { display: block; }
|
||||||
|
.workflow-card-head {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
}
|
||||||
|
.workflow-card-head .title {
|
||||||
|
color: var(--fg); font-weight: 600;
|
||||||
|
font-size: var(--text-sm); font-family: var(--font-sans);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.workflow-card-head .status {
|
||||||
|
padding: 1px 6px; border-radius: 3px;
|
||||||
|
font-size: 10px; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; font-weight: 500;
|
||||||
|
}
|
||||||
|
.workflow-card-head .status.completed { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||||
|
.workflow-card-head .status.failed { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.workflow-card-head .meta { margin-left: auto; color: var(--muted); }
|
||||||
|
.agent-list { display: flex; flex-direction: column; }
|
||||||
|
.agent-row {
|
||||||
|
display: grid; grid-template-columns: 80px 1fr auto; gap: 10px;
|
||||||
|
align-items: center; padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
transition: background 0.08s; cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
border-left: 0; border-right: 0; border-top: 0;
|
||||||
|
background: transparent; color: inherit; font: inherit; width: 100%;
|
||||||
|
}
|
||||||
|
.agent-row:last-child { border-bottom: 0; }
|
||||||
|
.agent-row:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.agent-row .phase {
|
||||||
|
font-family: var(--font-mono); font-size: 10px;
|
||||||
|
color: var(--workflow);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600;
|
||||||
|
padding: 2px 6px; background: var(--workflow-soft);
|
||||||
|
border-radius: 3px; text-align: center;
|
||||||
|
}
|
||||||
|
.agent-row .agent-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.agent-row .agent-label {
|
||||||
|
font-size: var(--text-sm); color: var(--fg);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.agent-row .agent-meta {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.agent-row .agent-state {
|
||||||
|
padding: 1px 5px; border-radius: 3px;
|
||||||
|
font-size: 9.5px; text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; font-weight: 500;
|
||||||
|
}
|
||||||
|
.agent-row .agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||||
|
.agent-row .agent-state.error { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.agent-row .chevron-r {
|
||||||
|
width: 8px; height: 8px; color: var(--muted-2);
|
||||||
|
transition: transform 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.agent-row.open .chevron-r { transform: rotate(90deg); color: var(--workflow); }
|
||||||
|
.agent-detail {
|
||||||
|
display: none; padding: 10px 14px 14px 102px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
.agent-detail.show { display: block; }
|
||||||
|
|
||||||
|
/* Muted messages (all except first) - no opacity change */
|
||||||
|
|
||||||
|
/* Thinking messages - collapsed by default */
|
||||||
|
.msg-thinking {
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(0,0,0,0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.thinking-toggle {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 100%; padding: 7px 10px;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; border: 0; background: transparent;
|
||||||
|
color: inherit; font: inherit;
|
||||||
|
}
|
||||||
|
.thinking-toggle:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.thinking-toggle .chevron {
|
||||||
|
width: 8px; height: 8px; color: var(--muted);
|
||||||
|
transition: transform 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.msg-thinking.open .thinking-toggle .chevron { transform: rotate(90deg); color: var(--accent-2); }
|
||||||
|
.thinking-toggle .thinking-label {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--muted); font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.thinking-toggle .thinking-preview {
|
||||||
|
font-size: 11px; color: var(--muted-2);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.thinking-body {
|
||||||
|
display: none; padding: 8px 14px 12px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.msg-thinking.open .thinking-body { display: block; }
|
||||||
|
|
||||||
|
/* Truncated message button */
|
||||||
|
.truncated-btn {
|
||||||
|
display: block; width: 100%;
|
||||||
|
margin-top: 8px; padding: 6px 12px;
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--accent-2); background: var(--accent-soft);
|
||||||
|
border: 1px solid rgba(167,139,250,0.2);
|
||||||
|
border-radius: 4px; cursor: pointer;
|
||||||
|
text-align: center; transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.truncated-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Meta (system) messages - collapsed by default */
|
||||||
|
.msg.meta {
|
||||||
|
border-color: var(--hairline);
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
.msg-meta-collapsed {
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.meta-toggle {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
width: 100%; padding: 5px 10px;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; border: 0; background: transparent;
|
||||||
|
color: inherit; font: inherit;
|
||||||
|
}
|
||||||
|
.meta-toggle:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.meta-toggle .chevron {
|
||||||
|
width: 8px; height: 8px; color: var(--muted-2);
|
||||||
|
transition: transform 0.15s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.msg-meta-collapsed.open .meta-toggle .chevron { transform: rotate(90deg); color: var(--muted); }
|
||||||
|
.meta-toggle .meta-label {
|
||||||
|
font-family: var(--font-mono); font-size: 10px;
|
||||||
|
color: var(--muted-2); font-weight: 600;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.meta-toggle .meta-preview {
|
||||||
|
font-size: 11px; color: var(--muted-2);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.meta-body {
|
||||||
|
display: none; padding: 6px 12px 10px;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.msg-meta-collapsed.open .meta-body { display: block; }
|
||||||
|
|
||||||
|
/* Agent tool call special styling */
|
||||||
|
.msg-tool.agent-call { border-color: var(--workflow-soft); border-left: 2px solid var(--workflow); }
|
||||||
|
.msg-tool.agent-call .tool-name { color: var(--workflow); }
|
||||||
|
.agent-prompt {
|
||||||
|
font-size: 12px; color: var(--fg-2); line-height: 1.5;
|
||||||
|
padding: 8px 10px; margin-bottom: 8px;
|
||||||
|
background: rgba(0,0,0,0.2); border-radius: 4px;
|
||||||
|
white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.agent-result { max-height: 400px; overflow-y: auto; }
|
||||||
|
.agent-nav-btn {
|
||||||
|
margin-left: auto; padding: 2px 8px;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--workflow); background: var(--workflow-soft);
|
||||||
|
border: 1px solid rgba(245,158,11,0.25); border-radius: 3px;
|
||||||
|
cursor: pointer; transition: all 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.agent-nav-btn:hover { background: rgba(245,158,11,0.25); border-color: var(--workflow); }
|
||||||
|
|
||||||
|
/* Workflow agent list inside tool call */
|
||||||
|
.workflow-agent-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.workflow-phase-group {}
|
||||||
|
.workflow-phase-header {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--muted); text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em; font-weight: 500;
|
||||||
|
padding: 4px 0 4px; margin-bottom: 2px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.workflow-phase-agents { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.workflow-agent-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 6px 10px; border-radius: 4px;
|
||||||
|
background: transparent; border: 0;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; width: 100%;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.workflow-agent-row:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
.workflow-agent-label { font-size: 12px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.workflow-agent-row:hover .workflow-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.workflow-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 1px 5px; border-radius: 3px; flex-shrink: 0; }
|
||||||
|
.workflow-agent-state.done { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||||
|
.workflow-agent-state.error { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.workflow-status { font-family: var(--font-mono); font-size: 10px; padding: 1px 6px; border-radius: 3px; margin-left: auto; }
|
||||||
|
.workflow-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||||
|
.workflow-status.failed { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
|
||||||
|
/* Standalone workflow card (outside assistant bubble) */
|
||||||
|
.wf-card {
|
||||||
|
border: 1px solid var(--workflow-soft);
|
||||||
|
border-left: 3px solid var(--workflow);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(245, 158, 11, 0.04);
|
||||||
|
padding: 0; overflow: hidden;
|
||||||
|
}
|
||||||
|
.wf-card-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--workflow-soft);
|
||||||
|
background: rgba(245, 158, 11, 0.06);
|
||||||
|
}
|
||||||
|
.wf-card-icon { font-size: 14px; }
|
||||||
|
.wf-card-name { font-size: 14px; font-weight: 600; color: var(--fg); font-family: var(--font-mono); }
|
||||||
|
.wf-card-count { font-size: 11px; color: var(--muted); font-family: var(--font-mono); }
|
||||||
|
.wf-card-status { font-family: var(--font-mono); font-size: 10px; padding: 2px 8px; border-radius: 3px; margin-left: auto; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500; }
|
||||||
|
.wf-card-status.completed { background: rgba(74,222,128,0.14); color: #4ade80; }
|
||||||
|
.wf-card-status.failed { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.wf-card-body { padding: 12px 16px; }
|
||||||
|
.wf-card-phase { margin-bottom: 12px; }
|
||||||
|
.wf-card-phase:last-child { margin-bottom: 0; }
|
||||||
|
.wf-card-phase-title {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--workflow); text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em; font-weight: 600;
|
||||||
|
margin-bottom: 4px; padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.wf-card-agent {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 7px 10px; border-radius: 4px;
|
||||||
|
background: transparent; border: 0;
|
||||||
|
cursor: pointer; transition: background 0.08s;
|
||||||
|
text-align: left; width: 100%;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
.wf-card-agent:hover { background: rgba(245, 158, 11, 0.08); }
|
||||||
|
.wf-card-agent-label { font-size: 13px; color: var(--fg-2); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wf-card-agent:hover .wf-card-agent-label { color: var(--accent-2); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.wf-card-agent-state { font-family: var(--font-mono); font-size: 10px; padding: 2px 6px; border-radius: 3px; flex-shrink: 0; }
|
||||||
|
.wf-card-agent-state.error { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.wf-card-agent-arrow { color: var(--muted-2); font-size: 12px; flex-shrink: 0; transition: color 0.08s, transform 0.08s; }
|
||||||
|
.wf-card-agent:hover .wf-card-agent-arrow { color: var(--accent-2); transform: translateX(2px); }
|
||||||
|
|
||||||
|
/* Back to parent session link */
|
||||||
|
.back-to-bar {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 0; margin-bottom: 12px;
|
||||||
|
font-size: 12px; font-family: var(--font-mono);
|
||||||
|
color: var(--accent-2); background: none;
|
||||||
|
border: none; cursor: pointer;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
.back-to-bar:hover { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Back to top floating button */
|
||||||
|
.back-to-top {
|
||||||
|
position: sticky; bottom: 20px; float: right;
|
||||||
|
margin-right: 8px; margin-top: -40px;
|
||||||
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
|
background: var(--surface-strong); border: 1px solid var(--hairline-strong);
|
||||||
|
color: var(--muted); cursor: pointer;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
opacity: 0; transition: opacity 0.15s, background 0.1s;
|
||||||
|
pointer-events: none; z-index: 10;
|
||||||
|
}
|
||||||
|
.back-to-top.show { opacity: 1; pointer-events: auto; }
|
||||||
|
.back-to-top:hover { background: var(--accent-soft); color: var(--accent-2); border-color: var(--accent-soft); }
|
||||||
|
.back-to-top svg { width: 14px; height: 14px; }
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
.list-wrap, .detail-wrap, .session-list-wrap, .session-detail-wrap {
|
||||||
|
flex: 1; overflow-y: auto; min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid; grid-template-columns: 22px 1fr auto;
|
||||||
|
align-items: start; column-gap: 12px;
|
||||||
|
padding: 14px 16px 14px 14px;
|
||||||
|
min-height: var(--row-h);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
transition: background 0.06s; position: relative;
|
||||||
|
}
|
||||||
|
.row:last-child { border-bottom: 0; }
|
||||||
|
.row:hover { background: rgba(255,255,255,0.025); }
|
||||||
|
.row.cursor { background: var(--surface); }
|
||||||
|
.row.cursor::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: var(--muted-2);
|
||||||
|
}
|
||||||
|
.row.selected { background: var(--accent-soft); }
|
||||||
|
.row.selected::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: var(--accent);
|
||||||
|
box-shadow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.row.cursor.selected { background: rgba(167,139,250,0.16); }
|
||||||
|
.row-checkbox {
|
||||||
|
width: 18px; height: 18px; margin-top: 1px;
|
||||||
|
border-radius: 4px; border: 1.5px solid var(--muted-2);
|
||||||
|
background: transparent; cursor: pointer;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
opacity: 0; transition: all 0.1s;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
.row:hover .row-checkbox,
|
||||||
|
.row.selected .row-checkbox,
|
||||||
|
.row.cursor .row-checkbox { opacity: 1; }
|
||||||
|
.row-checkbox:hover { border-color: var(--accent); }
|
||||||
|
.row-checkbox.checked {
|
||||||
|
background: var(--accent); border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.row-checkbox svg { width: 10px; height: 10px; color: #0a0b14; opacity: 0; }
|
||||||
|
.row-checkbox.checked svg { opacity: 1; }
|
||||||
|
.row-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.row-path {
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-md);
|
||||||
|
font-weight: 500; color: var(--fg); line-height: 1.4;
|
||||||
|
display: flex; align-items: center; gap: 6px; min-width: 0;
|
||||||
|
}
|
||||||
|
.row-status {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 14px; height: 14px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.row-status svg { width: 100%; height: 100%; }
|
||||||
|
.row-status.broken { color: var(--danger); }
|
||||||
|
.row-status.partial { color: var(--warn); }
|
||||||
|
.row-status.archived { color: var(--muted-2); }
|
||||||
|
.row-path .project-prefix { color: var(--muted); font-weight: 400; flex-shrink: 0; }
|
||||||
|
.row-path .project-prefix-sep { color: var(--muted-2); margin: 0 2px; flex-shrink: 0; }
|
||||||
|
.row-path .path-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
|
.row-path mark, .row-summary mark, .srow-title mark, .srow-snippet mark {
|
||||||
|
background: var(--accent-soft); color: var(--accent-2);
|
||||||
|
padding: 0 2px; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.row-summary {
|
||||||
|
font-size: var(--text-base); color: var(--fg-2);
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden; word-break: break-word;
|
||||||
|
}
|
||||||
|
.row-right {
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end;
|
||||||
|
gap: 10px; flex-shrink: 0; padding-top: 1px;
|
||||||
|
}
|
||||||
|
.row-meta {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--muted); letter-spacing: 0.02em;
|
||||||
|
font-variant-numeric: tabular-nums; white-space: nowrap;
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
}
|
||||||
|
.row:hover .row-meta { color: var(--muted-2); }
|
||||||
|
.row-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 0.1s; }
|
||||||
|
.row:hover .row-actions, .row.cursor .row-actions { opacity: 1; }
|
||||||
|
.row-action {
|
||||||
|
height: 24px; padding: 0 8px; border-radius: 4px;
|
||||||
|
color: var(--muted); font-size: var(--text-sm);
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
transition: all 0.1s; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.row-action:hover { background: var(--surface-hi); color: var(--fg); border-color: var(--hairline-strong); }
|
||||||
|
.row-action.danger:hover { background: var(--danger-soft); color: var(--danger); border-color: rgba(248,113,113,0.3); }
|
||||||
|
.row-action.restore { color: var(--accent-2); }
|
||||||
|
.row-action.restore:hover { background: var(--accent-soft); color: var(--fg); border-color: var(--accent-soft); }
|
||||||
|
.row-action .kbd {
|
||||||
|
font-family: var(--font-mono); font-size: 9.5px; color: var(--muted-2);
|
||||||
|
padding: 0 3px; border: 1px solid var(--hairline); border-radius: 2px; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.row-action:hover .kbd { color: var(--fg-2); border-color: var(--hairline-strong); }
|
||||||
|
.row.archived .row-path, .row.archived .row-summary { color: var(--muted); }
|
||||||
|
.row.archived .row-path .project-prefix { color: var(--muted-2); }
|
||||||
|
|
||||||
|
.srow {
|
||||||
|
display: grid; grid-template-columns: 1fr auto;
|
||||||
|
align-items: start; column-gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
min-height: var(--row-h-session);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
transition: background 0.06s; position: relative;
|
||||||
|
}
|
||||||
|
.srow:hover { background: rgba(255,255,255,0.025); }
|
||||||
|
.srow.cursor { background: var(--surface); }
|
||||||
|
.srow.cursor::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: var(--muted-2);
|
||||||
|
}
|
||||||
|
.srow-body { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.srow-title {
|
||||||
|
font-size: var(--text-md); font-weight: 500; color: var(--fg);
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.srow-meta {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.srow-meta .project-tag { color: var(--fg-2); font-weight: 500; }
|
||||||
|
.srow-meta .dot { width: 2px; height: 2px; background: var(--muted-2); border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.srow-snippet {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: var(--text-sm); color: var(--fg-2); line-height: 1.4;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-left: 14px; border-left: 2px solid var(--accent-soft);
|
||||||
|
}
|
||||||
|
.srow-snippet .snippet-label {
|
||||||
|
font-family: var(--font-mono); font-size: 9.5px;
|
||||||
|
color: var(--accent-2); letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase; margin-right: 6px;
|
||||||
|
}
|
||||||
|
.srow-right {
|
||||||
|
font-family: var(--font-mono); font-size: 11px;
|
||||||
|
color: var(--fg-2); text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0; padding-top: 2px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--muted-2); font-size: var(--text-sm);
|
||||||
|
padding: 60px 20px; text-align: center;
|
||||||
|
flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.empty .hint { font-size: 11px; color: var(--muted-2); }
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--hairline-strong);
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 14px; height: 36px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar-brand svg { width: 18px; height: 18px; filter: drop-shadow(0 0 8px rgba(167,139,250,0.4)); }
|
||||||
|
.sidebar-brand .name { font-size: var(--text-base); font-weight: 600; color: var(--fg-2); }
|
||||||
|
.sidebar-section { padding: 8px 6px; flex-shrink: 0; }
|
||||||
|
.sidebar-section.projects { flex: 1; min-height: 0; display: flex; flex-direction: column; padding-bottom: 0; }
|
||||||
|
.sidebar-section + .sidebar-section { border-top: 1px solid var(--hairline); }
|
||||||
|
.sidebar-section-title {
|
||||||
|
padding: 4px 10px 6px;
|
||||||
|
font-size: 10.5px; color: var(--muted);
|
||||||
|
font-weight: 500; letter-spacing: 0.04em;
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar-search { position: relative; padding: 0 6px 6px; flex-shrink: 0; }
|
||||||
|
.sidebar-search input {
|
||||||
|
width: 100%; height: 24px;
|
||||||
|
padding: 0 8px 0 24px;
|
||||||
|
border: 1px solid var(--hairline); border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: var(--text-sm); color: var(--fg);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.sidebar-search input::placeholder { color: var(--muted-2); }
|
||||||
|
.sidebar-search input:focus { outline: 0; border-color: var(--accent); background: var(--surface-strong); }
|
||||||
|
.sidebar-search-icon {
|
||||||
|
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 11px; height: 11px; color: var(--muted); pointer-events: none;
|
||||||
|
}
|
||||||
|
.sidebar-list { overflow-y: auto; padding: 2px 0 8px; flex: 1; min-height: 0; }
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 10px; height: var(--row-h-compact);
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--fg-2); font-size: var(--text-base);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
transition: background 0.08s; position: relative;
|
||||||
|
width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.sidebar-item:hover { background: var(--surface-strong); color: var(--fg); }
|
||||||
|
.sidebar-item.active { background: var(--accent-soft); color: var(--fg); }
|
||||||
|
.sidebar-item.active::before {
|
||||||
|
content: ''; position: absolute; left: -6px; top: 4px; bottom: 4px;
|
||||||
|
width: 2px; background: var(--accent); border-radius: 1px;
|
||||||
|
box-shadow: 0 0 8px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.sidebar-item .icon { width: 14px; height: 14px; color: var(--muted); flex-shrink: 0; transition: all 0.08s; }
|
||||||
|
.sidebar-item.active .icon { color: var(--accent-2); filter: drop-shadow(0 0 4px var(--accent-glow)); }
|
||||||
|
.sidebar-item.warning .icon { color: var(--danger); }
|
||||||
|
.sidebar-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sidebar-item .badge {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--muted); font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1; min-width: 22px; text-align: right;
|
||||||
|
flex-shrink: 0; padding: 2px 0;
|
||||||
|
}
|
||||||
|
.sidebar-item.active .badge { color: var(--fg-2); }
|
||||||
|
.sidebar-item.warning .badge {
|
||||||
|
color: var(--danger); background: var(--danger-soft);
|
||||||
|
padding: 2px 6px; border-radius: 8px;
|
||||||
|
margin-right: -6px; min-width: 22px;
|
||||||
|
}
|
||||||
|
.sidebar-item.sub { padding-left: 30px; height: 26px; font-size: var(--text-sm); }
|
||||||
|
.sidebar-item.sub .icon { width: 12px; height: 12px; }
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.statusbar {
|
||||||
|
height: 24px; flex-shrink: 0;
|
||||||
|
border-top: 1px solid var(--hairline-strong);
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 10.5px; color: var(--muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.statusbar .status-left { display: flex; gap: 8px; flex: 1; }
|
||||||
|
.statusbar .status-right { display: flex; gap: 8px; }
|
||||||
|
.statusbar .kbd-hint { display: inline-flex; align-items: center; gap: 4px; transition: opacity 0.15s; }
|
||||||
|
.statusbar .kbd-hint.secondary { opacity: 0; }
|
||||||
|
.statusbar:hover .kbd-hint.secondary { opacity: 1; }
|
||||||
|
.statusbar .kbd {
|
||||||
|
color: var(--fg-2); padding: 0 4px;
|
||||||
|
border: 1px solid var(--hairline-strong); border-radius: 3px; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.status-pending { color: var(--accent-2); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.status-pending strong { color: var(--fg); font-weight: 500; }
|
||||||
|
.status-pending .undo-btn {
|
||||||
|
color: var(--accent-2); padding: 0 6px;
|
||||||
|
border: 1px solid var(--accent-soft); border-radius: 3px;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.status-pending .undo-btn:hover { background: var(--accent-soft); color: var(--fg); }
|
||||||
|
.status-pending .timer { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
||||||
|
.toolbar {
|
||||||
|
height: 44px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid var(--hairline-strong);
|
||||||
|
background: rgba(0,0,0,0.15);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
.breadcrumb { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||||
|
.crumb {
|
||||||
|
font-size: var(--text-md); color: var(--muted);
|
||||||
|
padding: 4px 6px; border-radius: 4px;
|
||||||
|
cursor: pointer; transition: all 0.1s;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
line-height: 1; border: 0; background: transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.crumb:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||||
|
.crumb.terminal { color: var(--fg); font-weight: 600; cursor: default; }
|
||||||
|
.crumb.terminal:hover { background: transparent; }
|
||||||
|
.crumb svg { width: 13px; height: 13px; color: var(--muted); }
|
||||||
|
.crumb.filename {
|
||||||
|
font-family: var(--font-mono); font-weight: 500; color: var(--fg);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; min-width: 0;
|
||||||
|
}
|
||||||
|
.crumb-sep { color: var(--muted-2); font-size: var(--text-md); user-select: none; }
|
||||||
|
.toolbar-spacer { flex: 1; }
|
||||||
|
.toolbar-search { width: 220px; position: relative; }
|
||||||
|
.toolbar-search input {
|
||||||
|
width: 100%; height: 26px;
|
||||||
|
padding: 0 30px 0 26px;
|
||||||
|
border: 1px solid var(--hairline); border-radius: 5px;
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: var(--text-base); color: var(--fg);
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.toolbar-search input::placeholder { color: var(--muted-2); }
|
||||||
|
.toolbar-search input:focus {
|
||||||
|
outline: 0; border-color: var(--accent);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.toolbar-search-icon {
|
||||||
|
position: absolute; left: 8px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 12px; height: 12px; color: var(--muted); pointer-events: none;
|
||||||
|
}
|
||||||
|
.toolbar-search-kbd {
|
||||||
|
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||||
|
font-family: var(--font-mono); font-size: 10px; color: var(--muted-2);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid var(--hairline); border-radius: 3px;
|
||||||
|
pointer-events: none; line-height: 1.2;
|
||||||
|
}
|
||||||
|
.toolbar-search input:focus ~ .toolbar-search-kbd,
|
||||||
|
.toolbar-search input:not(:placeholder-shown) ~ .toolbar-search-kbd { opacity: 0; }
|
||||||
|
.filter-toggle {
|
||||||
|
height: 26px; width: 26px; border-radius: 5px;
|
||||||
|
color: var(--muted); display: inline-grid; place-items: center;
|
||||||
|
transition: all 0.1s; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.filter-toggle:hover { color: var(--fg-2); background: var(--surface-strong); }
|
||||||
|
.filter-toggle.active { color: var(--accent-2); background: var(--accent-soft); border-color: var(--accent-soft); }
|
||||||
|
.filter-toggle svg { width: 13px; height: 13px; }
|
||||||
|
|
||||||
|
.sort-group {
|
||||||
|
display: inline-flex; align-items: center; gap: 2px;
|
||||||
|
height: 26px; padding: 0 4px 0 8px;
|
||||||
|
border-radius: 5px; cursor: pointer;
|
||||||
|
color: var(--muted); font-size: var(--text-sm);
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.sort-group:hover { background: var(--surface-strong); color: var(--fg-2); }
|
||||||
|
.sort-group .label { font-family: var(--font-mono); letter-spacing: 0.02em; }
|
||||||
|
.sort-group svg { width: 13px; height: 13px; }
|
||||||
|
.sort-group .arrow-up, .sort-group .arrow-down { transition: opacity 0.12s; }
|
||||||
|
.sort-group.desc .arrow-up { opacity: 0.25; }
|
||||||
|
.sort-group.desc .arrow-down { opacity: 1; }
|
||||||
|
.sort-group.asc .arrow-up { opacity: 1; }
|
||||||
|
.sort-group.asc .arrow-down { opacity: 0.25; }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: '../dist-renderer',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
+737
@@ -0,0 +1,737 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<title>Obelisk — Memory</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Playfair+Display:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f8f9fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-hover: #f3f4f8;
|
||||||
|
--surface-active: #eceef4;
|
||||||
|
--border: #e2e4ea;
|
||||||
|
--border-hover: #c8ccd6;
|
||||||
|
--text: #1e293b;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-soft: #e0e7ff;
|
||||||
|
--accent-glow: rgba(99, 102, 241, 0.08);
|
||||||
|
--purple: #a855f7;
|
||||||
|
--pink: #ec4899;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-soft: #fef2f2;
|
||||||
|
--danger-border: #fecaca;
|
||||||
|
--restore: #059669;
|
||||||
|
--restore-soft: #ecfdf5;
|
||||||
|
--restore-border: #a7f3d0;
|
||||||
|
--serif: 'Playfair Display', Charter, 'Iowan Old Style', Georgia, serif;
|
||||||
|
--mono: 'IBM Plex Mono', monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(30, 41, 59, 0.04);
|
||||||
|
--shadow: 0 2px 8px rgba(30, 41, 59, 0.06);
|
||||||
|
--shadow-lg: 0 8px 24px rgba(30, 41, 59, 0.08);
|
||||||
|
--transition: 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse at center top, rgba(168, 85, 247, 0.35), rgba(99, 102, 241, 0.2) 60%, transparent 80%);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 40%;
|
||||||
|
width: 10px;
|
||||||
|
height: 18px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(to right, #475569, #1e293b);
|
||||||
|
clip-path: polygon(30% 0%, 70% 0%, 80% 100%, 20% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--serif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab .count {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-active);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-group {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-header:hover .project-name { color: var(--accent); }
|
||||||
|
|
||||||
|
.project-header .chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
transition: transform var(--transition);
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-header.collapsed .chevron { transform: rotate(-90deg); }
|
||||||
|
|
||||||
|
.project-header .project-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-header .project-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item:hover {
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item.expanded {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item.deleted {
|
||||||
|
opacity: 0.55;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-item.deleted:hover { opacity: 0.8; }
|
||||||
|
|
||||||
|
.memory-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-path {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-summary {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow); }
|
||||||
|
.btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger-border);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
.btn-delete:hover {
|
||||||
|
border-color: var(--danger);
|
||||||
|
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-restore {
|
||||||
|
color: var(--restore);
|
||||||
|
border-color: var(--restore-border);
|
||||||
|
background: var(--restore-soft);
|
||||||
|
}
|
||||||
|
.btn-restore:hover {
|
||||||
|
border-color: var(--restore);
|
||||||
|
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel.open { display: block; }
|
||||||
|
|
||||||
|
.detail-panel .file-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .file-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .file-content h1,
|
||||||
|
.detail-panel .file-content h2,
|
||||||
|
.detail-panel .file-content h3 {
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 1.2em 0 0.4em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .file-content h1 { font-size: 18px; }
|
||||||
|
.detail-panel .file-content h2 { font-size: 16px; }
|
||||||
|
.detail-panel .file-content h3 { font-size: 14px; }
|
||||||
|
.detail-panel .file-content h1:first-child,
|
||||||
|
.detail-panel .file-content h2:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.detail-panel .file-content p { margin: 0.6em 0; }
|
||||||
|
|
||||||
|
.detail-panel .file-content ul,
|
||||||
|
.detail-panel .file-content ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .file-content li { margin: 0.25em 0; }
|
||||||
|
|
||||||
|
.detail-panel .file-content code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--surface-active);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .file-content pre {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.8em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .file-content pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel .provenance {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance code {
|
||||||
|
background: var(--surface-active);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 28px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(80px);
|
||||||
|
background: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast .undo-btn {
|
||||||
|
color: var(--accent-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 56px 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 17px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header>
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-icon"></div>
|
||||||
|
<h1>Memory</h1>
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">Let Claude Code search its own memory.</div>
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat"><span class="stat-value" id="stat-active">0</span><span class="stat-label">active</span></div>
|
||||||
|
<div class="stat"><span class="stat-value" id="stat-deleted">0</span><span class="stat-label">archived</span></div>
|
||||||
|
<div class="stat"><span class="stat-value" id="stat-projects">0</span><span class="stat-label">projects</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="active">Active <span class="count" id="tab-active-count">0</span></div>
|
||||||
|
<div class="tab" data-tab="deleted">Archived <span class="count" id="tab-deleted-count">0</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast">
|
||||||
|
<span id="toast-msg"></span>
|
||||||
|
<span class="undo-btn" id="toast-undo">Undo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MOCK_MEMORIES = [
|
||||||
|
{
|
||||||
|
id: 'mem-1781021027286-51v0qh',
|
||||||
|
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
|
||||||
|
project: '-Users-tomiya-Code-quiet-zero',
|
||||||
|
message_start: 'a1b2c3d4',
|
||||||
|
message_end: 'e5f6g7h8',
|
||||||
|
path: '.obelisk/memories/filter-opts-design.md',
|
||||||
|
summary: 'Unified filter opts design: decided to add project/after/before/limit to all list-returning API functions instead of building a query builder DSL. Agent already knows JS; function composition is the query builder.',
|
||||||
|
created_at: '2026-06-01T16:03:47.286Z',
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_reason: null,
|
||||||
|
_file_content: '# Filter Opts Design Decision\n\nWe chose to add a consistent `opts` object to every list-returning function rather than building a query builder abstraction.\n\n## Reasoning\n\n- The agent already writes JS — function composition IS the query builder\n- A DSL adds a new mental model the agent has to learn\n- `search()` already had the right pattern; we just propagated it\n\n## Alternatives Considered\n\n- Fluent builder (`.filter().select().take()`) — too much implementation, new abstraction\n- Per-function specific filters — inconsistent, hard to compose\n\n## Constraints\n\n- Must be backward compatible (string → sessionId, number → limit)\n- Must push filters to SQL, not pull-and-filter client-side'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem-1781021100000-x9y2z1',
|
||||||
|
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
|
||||||
|
project: '-Users-tomiya-Code-quiet-zero',
|
||||||
|
message_start: 'i9j0k1l2',
|
||||||
|
message_end: 'm3n4o5p6',
|
||||||
|
path: '.obelisk/memories/is-error-design.md',
|
||||||
|
summary: 'Use structural is_error field from JSONL instead of text pattern matching for failures(). The old ERROR_PATS approach had ~90% false positive rate. Bash exit code pattern kept as fallback but SQLite LIKE has no character classes.',
|
||||||
|
created_at: '2026-06-02T10:15:00.000Z',
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_reason: null,
|
||||||
|
_file_content: '# is_error Design\n\nReplaced text-based error pattern matching with the structural `is_error` boolean from Claude Code JSONL.\n\n## Problem\n\nThe old `failures()` matched content against patterns like "Error", "failed", "ENOENT". This caught source code containing those words, Agent results discussing errors, etc. ~90% false positive rate.\n\n## Solution\n\nIndex `b.is_error` from tool_result blocks into a new `is_error INTEGER` column on `tool_results`. Query becomes `WHERE is_error = 1`.\n\n## Edge Cases\n\nBash exit code fallback: `content LIKE \'Exit code %\'` for cases where is_error might not be set. Note: SQLite LIKE does not support `[1-9]` character classes.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem-1781021200000-a3b4c5',
|
||||||
|
session_id: '2831d8a1-df70-4365-a203-59bbe8e354cb',
|
||||||
|
project: '-Users-tomiya-Code-quiet-zero',
|
||||||
|
message_start: 'q7r8s9t0',
|
||||||
|
message_end: 'u1v2w3x4',
|
||||||
|
path: '.obelisk/memories/no-wiki-philosophy.md',
|
||||||
|
summary: 'Core philosophy: raw sessions are already structured data. Do not compile them into wiki pages or markdown summaries as an intermediate entity. Keep the relational structure, let agent query at application layer. Memory layer is selective conclusions with provenance, not comprehensive coverage.',
|
||||||
|
created_at: '2026-05-30T14:20:00.000Z',
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_reason: null,
|
||||||
|
_file_content: '# No Wiki Philosophy\n\n"若无必要,勿增实体"\n\nRaw session data is already structured: messages, tool calls, tool results, files, subagents, workflows, parent chains. Maintaining these relationships in SQLite is already a powerful structure.\n\nCompiling into markdown pages introduces an unnecessary intermediate entity: information gets flattened, causal chains and reference relationships need post-hoc reconstruction.\n\nThe memory layer is NOT a wiki. It is selective, agent-written conclusions with clear provenance. It does not try to comprehensively cover everything.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem-1781021300000-d6e7f8',
|
||||||
|
session_id: 'ca0b1609-984a-4761-a090-a4fc4f25b8b8',
|
||||||
|
project: '-Users-tomiya-Code-bub',
|
||||||
|
message_start: 'y5z6a7b8',
|
||||||
|
message_end: 'c9d0e1f2',
|
||||||
|
path: '.obelisk/memories/bub-tasktree-decision.md',
|
||||||
|
summary: 'Dynamic TaskTree architecture: chose three-layer design (main Agent → Original Work → Side Quests) over flat task queue. logos_complete semantics split into plan (generate children) and return (bubble up summary).',
|
||||||
|
created_at: '2026-05-29T18:30:00.000Z',
|
||||||
|
deleted_at: null,
|
||||||
|
deleted_reason: null,
|
||||||
|
_file_content: '# Dynamic TaskTree Architecture\n\nChose a three-layer architecture for the dynamic task tree:\n1. Main Agent — orchestrator\n2. Original Work — primary task execution\n3. Side Quests — spawned sub-tasks\n\n## Key Decision\n\nSplit `logos_complete` into two distinct semantics:\n- **plan**: generate child nodes (`plan: [...]`)\n- **return**: bubble up summary to parent (`summary: "..."`)\n\nThe old design conflated these — a node with k subtasks called logos_complete k+1 times.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mem-1781021400000-g3h4i5',
|
||||||
|
session_id: 'defd4ccd-b2d7-4c07-a32b-0a7b74e8aace',
|
||||||
|
project: '-Users-tomiya-Code-quiet-zero',
|
||||||
|
message_start: 'j6k7l8m9',
|
||||||
|
message_end: 'n0o1p2q3',
|
||||||
|
path: '.obelisk/memories/stale-memory-example.md',
|
||||||
|
summary: 'Obsolete: originally planned to add a full query builder with .filter().select() chain syntax. This was rejected in favor of unified filter opts on existing functions.',
|
||||||
|
created_at: '2026-06-01T12:00:00.000Z',
|
||||||
|
deleted_at: '2026-06-03T09:00:00.000Z',
|
||||||
|
deleted_reason: 'Superseded by filter-opts-design memory. The query builder plan was rejected.',
|
||||||
|
_file_content: '# Query Builder Plan (REJECTED)\n\nThis approach was considered and rejected.\n\nWe originally planned a fluent query builder:\n```js\nsummaries.filter({project: like("quiet-zero")}).limit(10)\n```\n\nRejected because:\n- Adds unnecessary abstraction\n- Agent already writes JS\n- Function composition achieves the same thing'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let memories = JSON.parse(JSON.stringify(MOCK_MEMORIES));
|
||||||
|
let currentTab = 'active';
|
||||||
|
let expandedId = null;
|
||||||
|
let collapsedProjects = new Set();
|
||||||
|
let toastTimeout = null;
|
||||||
|
|
||||||
|
function getActive() { return memories.filter(m => !m.deleted_at); }
|
||||||
|
function getDeleted() { return memories.filter(m => m.deleted_at); }
|
||||||
|
function getProjects(list) {
|
||||||
|
const groups = {};
|
||||||
|
for (const m of list) {
|
||||||
|
const p = m.project || '(no project)';
|
||||||
|
if (!groups[p]) groups[p] = [];
|
||||||
|
groups[p].push(m);
|
||||||
|
}
|
||||||
|
return Object.entries(groups).sort((a, b) => {
|
||||||
|
const latestA = Math.max(...a[1].map(m => new Date(m.created_at).getTime()));
|
||||||
|
const latestB = Math.max(...b[1].map(m => new Date(m.created_at).getTime()));
|
||||||
|
return latestB - latestA;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProject(slug) {
|
||||||
|
if (!slug) return '(no project)';
|
||||||
|
const parts = slug.replace(/^-/, '').split('-');
|
||||||
|
return parts.slice(-2).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, undoFn) {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
document.getElementById('toast-msg').textContent = msg;
|
||||||
|
toast.classList.add('show');
|
||||||
|
document.getElementById('toast-undo').onclick = () => { undoFn(); hideToast(); };
|
||||||
|
clearTimeout(toastTimeout);
|
||||||
|
toastTimeout = setTimeout(hideToast, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideToast() {
|
||||||
|
document.getElementById('toast').classList.remove('show');
|
||||||
|
clearTimeout(toastTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function softDelete(id) {
|
||||||
|
const mem = memories.find(m => m.id === id);
|
||||||
|
if (!mem) return;
|
||||||
|
mem.deleted_at = new Date().toISOString();
|
||||||
|
mem.deleted_reason = 'Deleted via panel';
|
||||||
|
expandedId = null;
|
||||||
|
render();
|
||||||
|
showToast(`Archived: ${mem.path.split('/').pop()}`, () => restore(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(id) {
|
||||||
|
const mem = memories.find(m => m.id === id);
|
||||||
|
if (!mem) return;
|
||||||
|
mem.deleted_at = null;
|
||||||
|
mem.deleted_reason = null;
|
||||||
|
render();
|
||||||
|
showToast(`Restored: ${mem.path.split('/').pop()}`, () => softDelete(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(id) {
|
||||||
|
expandedId = expandedId === id ? null : id;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProject(project) {
|
||||||
|
if (collapsedProjects.has(project)) collapsedProjects.delete(project);
|
||||||
|
else collapsedProjects.add(project);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const active = getActive();
|
||||||
|
const deleted = getDeleted();
|
||||||
|
const projects = new Set(memories.map(m => m.project).filter(Boolean));
|
||||||
|
|
||||||
|
document.getElementById('stat-active').textContent = active.length;
|
||||||
|
document.getElementById('stat-deleted').textContent = deleted.length;
|
||||||
|
document.getElementById('stat-projects').textContent = projects.size;
|
||||||
|
document.getElementById('tab-active-count').textContent = active.length;
|
||||||
|
document.getElementById('tab-deleted-count').textContent = deleted.length;
|
||||||
|
|
||||||
|
const list = currentTab === 'active' ? active : deleted;
|
||||||
|
const grouped = getProjects(list);
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
|
||||||
|
if (!list.length) {
|
||||||
|
content.innerHTML = `<div class="empty">${currentTab === 'active' ? 'No active memories yet.' : 'No archived memories.'}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = grouped.map(([project, mems]) => {
|
||||||
|
const isCollapsed = collapsedProjects.has(project);
|
||||||
|
return `
|
||||||
|
<div class="project-group">
|
||||||
|
<div class="project-header ${isCollapsed ? 'collapsed' : ''}" onclick="toggleProject('${project}')">
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
<span class="project-name">${formatProject(project)}</span>
|
||||||
|
<span class="project-count">${mems.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="memory-list" style="${isCollapsed ? 'display:none' : ''}">
|
||||||
|
${mems.map(m => renderMemory(m)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMemory(m) {
|
||||||
|
const isExpanded = expandedId === m.id;
|
||||||
|
const isDeleted = !!m.deleted_at;
|
||||||
|
return `
|
||||||
|
<div class="memory-item ${isExpanded ? 'expanded' : ''} ${isDeleted ? 'deleted' : ''}" onclick="toggleExpand('${m.id}')">
|
||||||
|
<div class="memory-content">
|
||||||
|
<div class="memory-path">${m.path}</div>
|
||||||
|
<div class="memory-summary">${m.summary}</div>
|
||||||
|
<div class="memory-meta">
|
||||||
|
<span>${formatDate(m.created_at)}</span>
|
||||||
|
${m.deleted_at ? `<span>archived ${formatDate(m.deleted_at)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="memory-actions" onclick="event.stopPropagation()">
|
||||||
|
${isDeleted
|
||||||
|
? `<button class="btn btn-restore" onclick="restore('${m.id}')">restore</button>`
|
||||||
|
: `<button class="btn btn-delete" onclick="softDelete('${m.id}')">archive</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
${isExpanded ? `
|
||||||
|
<div class="detail-panel open" onclick="event.stopPropagation()">
|
||||||
|
<div class="file-label">File Content</div>
|
||||||
|
<div class="file-content">${marked.parse(m._file_content || '*(file not available)*')}</div>
|
||||||
|
<div class="provenance">
|
||||||
|
<span>session <code>${m.session_id?.slice(0, 8) || '—'}</code></span>
|
||||||
|
<span>messages <code>${m.message_start?.slice(0, 8) || '—'}</code> → <code>${m.message_end?.slice(0, 8) || '—'}</code></span>
|
||||||
|
<span>id <code>${m.id}</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
currentTab = tab.dataset.tab;
|
||||||
|
expandedId = null;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Obelisk Query Patterns
|
# Obelisk Query Patterns
|
||||||
|
|
||||||
These are copyable CodeAct patterns for `runtime.mjs --query` scripts, plus one
|
These are copyable CodeAct patterns for `runtime.mjs --query` scripts plus
|
||||||
`--remember` registration pattern. They are not new APIs. Adapt them to the
|
`--attune` memory mutation patterns. They are not new APIs. Adapt them to the
|
||||||
user's scope and return compact evidence.
|
user's scope and return compact evidence.
|
||||||
|
|
||||||
Read this before the first query for broad synthesis, progress summaries,
|
Read this before the first query for broad synthesis, progress summaries,
|
||||||
@@ -43,14 +43,17 @@ return {
|
|||||||
memories: map.current_project.memories.map(m => ({
|
memories: map.current_project.memories.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
path: m.path,
|
path: m.path,
|
||||||
|
anchors: m.anchors,
|
||||||
summary: m.summary?.slice(0, 240),
|
summary: m.summary?.slice(0, 240),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
prior_memories: memories({ ...scoped, query: topic, limit: 5 }).map(m => ({
|
prior_memories: memories({ ...scoped, query: topic, limit: 5 }).map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
path: m.path,
|
path: m.path,
|
||||||
|
anchors: m.anchors,
|
||||||
session_id: m.session_id,
|
session_id: m.session_id,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
|
rank: m.rank,
|
||||||
summary: m.summary?.slice(0, 260),
|
summary: m.summary?.slice(0, 260),
|
||||||
})),
|
})),
|
||||||
session_evidence: search(topic.replace(/[-_]/g, ' '), { ...scoped, limit: 8 })
|
session_evidence: search(topic.replace(/[-_]/g, ' '), { ...scoped, limit: 8 })
|
||||||
@@ -88,6 +91,7 @@ return {
|
|||||||
memories: map.current_project.memories.map(m => ({
|
memories: map.current_project.memories.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
path: m.path,
|
path: m.path,
|
||||||
|
anchors: m.anchors,
|
||||||
summary: m.summary?.slice(0, 240),
|
summary: m.summary?.slice(0, 240),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -138,11 +142,13 @@ const prior_memories = memories({
|
|||||||
}).map(m => ({
|
}).map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
path: m.path,
|
path: m.path,
|
||||||
|
anchors: m.anchors,
|
||||||
session_id: m.session_id,
|
session_id: m.session_id,
|
||||||
message_start: m.message_start,
|
message_start: m.message_start,
|
||||||
message_end: m.message_end,
|
message_end: m.message_end,
|
||||||
created_at: m.created_at,
|
created_at: m.created_at,
|
||||||
summary: m.summary?.slice(0, 260),
|
summary: m.summary?.slice(0, 260),
|
||||||
|
rank: m.rank,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const session_evidence = search(ftsTopic, { project, limit: 8 })
|
const session_evidence = search(ftsTopic, { project, limit: 8 })
|
||||||
@@ -167,14 +173,14 @@ return {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## Register Approved Memory
|
## Attune Approved Memory
|
||||||
|
|
||||||
Use this only after the user approves writing memory and the markdown file
|
Use this only after the user approves writing memory and the markdown file
|
||||||
already exists. `remember()` validates the file and stores a normalized absolute
|
already exists. `remember()` validates the file and stores a normalized absolute
|
||||||
path, so keep the script small and return the registered record.
|
path, so keep the script small and return the registered record.
|
||||||
|
|
||||||
Run this script with `runtime.mjs --remember <script>`. The `--remember` runtime
|
Run this script with `runtime.mjs --attune <script>`. The `--attune` runtime
|
||||||
exposes only `remember()`, not retrieval helpers.
|
exposes only `remember()` and `forget()`, not retrieval helpers.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
return remember({
|
return remember({
|
||||||
@@ -182,6 +188,7 @@ return remember({
|
|||||||
session_id: 'source-session-id',
|
session_id: 'source-session-id',
|
||||||
message_start: 'first-message-uuid',
|
message_start: 'first-message-uuid',
|
||||||
message_end: 'last-message-uuid',
|
message_end: 'last-message-uuid',
|
||||||
|
anchors: [{ kind: 'file', path: 'SKILL.md' }],
|
||||||
summary: [
|
summary: [
|
||||||
'Decision: Obelisk uses one user-facing entry that queries both memory and raw sessions.',
|
'Decision: Obelisk uses one user-facing entry that queries both memory and raw sessions.',
|
||||||
'Memory records are prior notes and must be identified naturally when they influence an answer.',
|
'Memory records are prior notes and must be identified naturally when they influence an answer.',
|
||||||
@@ -190,6 +197,53 @@ return remember({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Forget Approved Memory
|
||||||
|
|
||||||
|
Use this only after the user asks to archive an outdated or wrong memory. Identify
|
||||||
|
the exact memory ID in a normal `--query` script first. If one candidate clearly
|
||||||
|
matches the user's request, that request is approval to archive it; if several
|
||||||
|
candidates match, ask which one to forget.
|
||||||
|
|
||||||
|
Run the mutation with `runtime.mjs --attune <script>`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
return forget({
|
||||||
|
id: 'mem-id-to-delete',
|
||||||
|
reason: 'Outdated by newer project guidance.',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`forget()` archives the record. Active recall through `memories()` will omit it,
|
||||||
|
and the markdown file at `path` is left in place.
|
||||||
|
|
||||||
|
## Update Approved Memory
|
||||||
|
|
||||||
|
Use this when the user explicitly corrects an existing memory, or after the
|
||||||
|
agent proposes a replacement and the user approves. An update is one combined
|
||||||
|
operation: archive the old record and register the replacement markdown file.
|
||||||
|
The new markdown file must already exist before running `--attune`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const archived = forget({
|
||||||
|
id: 'old-memory-id',
|
||||||
|
reason: 'Replaced by updated memory from the current session.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = remember({
|
||||||
|
path: '.obelisk/memories/updated-memory.md',
|
||||||
|
session_id: 'current-session-id',
|
||||||
|
message_start: 'first-message-uuid',
|
||||||
|
message_end: 'last-message-uuid',
|
||||||
|
anchors: [{ kind: 'file', path: 'src/path/to/file.ts' }],
|
||||||
|
summary: 'Updated summary: concise English retrieval surface for the replacement memory.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { archived, created };
|
||||||
|
```
|
||||||
|
|
||||||
|
If the agent only suspects a memory is stale, do not run this pattern yet.
|
||||||
|
Answer from current evidence and ask whether to archive or replace the memory.
|
||||||
|
|
||||||
## One-Shot Retrieval For Synthesis
|
## One-Shot Retrieval For Synthesis
|
||||||
|
|
||||||
Use this for conclusion, broad history, failure investigation, or file evolution
|
Use this for conclusion, broad history, failure investigation, or file evolution
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ the needed join, grouping, or exact schema-level check better than helpers.
|
|||||||
|
|
||||||
Ordering and context are semantic:
|
Ordering and context are semantic:
|
||||||
|
|
||||||
- `sessions()`, `memories()`, `summaries()`, `workflows()`, and `failures()` are newest first.
|
- `sessions()`, `summaries()`, `workflows()`, and `failures()` are newest first.
|
||||||
|
- `memories()` without `query` is newest first; `memories({ query })` is FTS-ranked over memory `summary`/`path`, with lower rank sorting earlier.
|
||||||
- `fileHistory()` is oldest first.
|
- `fileHistory()` is oldest first.
|
||||||
- `search().context` is temporal neighbors in one session, not causal context.
|
- `search().context` is temporal neighbors in one session, not causal context.
|
||||||
- `context(uuid)` and `trace(uuid)` are for parent-chain/causal expansion.
|
- `context(uuid)` and `trace(uuid)` are for parent-chain/causal expansion.
|
||||||
@@ -97,19 +98,44 @@ For semantic questions, build a task-local evidence view:
|
|||||||
{
|
{
|
||||||
query_plan: { mode, scope, facets, limits },
|
query_plan: { mode, scope, facets, limits },
|
||||||
prior_memories: [
|
prior_memories: [
|
||||||
{ id, path, session_id, created_at, summary }
|
{ id, path, anchors, session_id, created_at, summary }
|
||||||
],
|
],
|
||||||
evidence: [
|
evidence: [
|
||||||
{ type, id, session_id, timestamp, facet, snippet }
|
{ type, id, session_id, timestamp, content_type, is_meta, facet, snippet }
|
||||||
],
|
],
|
||||||
omitted: 0
|
omitted: 0
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For message evidence, preserve `content_type` when projecting snippets.
|
||||||
|
`text` can support user-visible claims; `thinking` is only trace/debug context;
|
||||||
|
`tool_use` means follow `tool_calls` for structured details; `tool_result`
|
||||||
|
means follow `tool_results` for structured output. Mixed or unfamiliar message
|
||||||
|
surfaces remain `unknown`.
|
||||||
|
|
||||||
|
Preserve `is_meta` separately from `content_type`. Default message evidence
|
||||||
|
should exclude `is_meta=1` rows because they are transcript control-plane
|
||||||
|
content, not ordinary user intent or assistant conclusions. Include them only
|
||||||
|
when investigating injected caveats, command envelopes, or transcript structure.
|
||||||
|
When writing raw SQL for ordinary conversation evidence, add
|
||||||
|
`COALESCE(m.is_meta,0)=0` to message filters unless meta rows are the subject of
|
||||||
|
the investigation.
|
||||||
|
|
||||||
Memory recall is English-indexed: translate non-English user requests into
|
Memory recall is English-indexed: translate non-English user requests into
|
||||||
concise English query terms before calling `memories({ query })`. Memory
|
concise English query terms before calling `memories({ query })`. Memory
|
||||||
summaries registered with `remember()` are also English, regardless of the
|
summaries registered with `remember()` are also English, regardless of the
|
||||||
conversation language.
|
conversation language.
|
||||||
|
`memories({ query })` uses safe FTS5 tokenization over memory `summary` and
|
||||||
|
`path`, so hyphens and punctuation do not need raw `MATCH` escaping.
|
||||||
|
`memories()` returns active memories only. For raw SQL memory recall, include
|
||||||
|
`deleted_at IS NULL`; archived memory records are management/audit data.
|
||||||
|
|
||||||
|
The agent may decide whether to use, ignore, or verify a recalled memory for the
|
||||||
|
current answer without user approval because no persistent state changes. If a
|
||||||
|
user explicitly says a memory is wrong, outdated, should be forgotten, or should
|
||||||
|
be replaced, that request is approval to mutate the exact matching memory. If
|
||||||
|
the agent discovers the conflict without an explicit user request, it should
|
||||||
|
answer from current evidence and ask before archiving or replacing the memory.
|
||||||
|
|
||||||
Then synthesize the conclusion in the final answer. Do not pretend the raw
|
Then synthesize the conclusion in the final answer. Do not pretend the raw
|
||||||
evidence view is itself a stored Obelisk entity.
|
evidence view is itself a stored Obelisk entity.
|
||||||
@@ -121,7 +147,11 @@ project conventions, abandoned alternatives, repeated failure causes, workflow
|
|||||||
patterns, and conclusions synthesized across multiple raw evidence points. Do
|
patterns, and conclusions synthesized across multiple raw evidence points. Do
|
||||||
not propose memory for one-off lookups, uncertain findings, or duplicate
|
not propose memory for one-off lookups, uncertain findings, or duplicate
|
||||||
coverage. The offer is only a proposal: write the markdown file and run
|
coverage. The offer is only a proposal: write the markdown file and run
|
||||||
`--remember` only after user approval.
|
`--attune` only after user approval.
|
||||||
|
|
||||||
|
Memory updates are archive-plus-write, not in-place edits: run `forget()` on the
|
||||||
|
old record and `remember()` the replacement markdown file under the same user
|
||||||
|
approval.
|
||||||
|
|
||||||
## Text Search Semantics
|
## Text Search Semantics
|
||||||
|
|
||||||
|
|||||||
+132
-25
@@ -41,6 +41,8 @@ CREATE TABLE messages (
|
|||||||
timestamp TEXT, -- ISO 8601
|
timestamp TEXT, -- ISO 8601
|
||||||
role TEXT, -- "user" or "assistant" (from message payload)
|
role TEXT, -- "user" or "assistant" (from message payload)
|
||||||
text TEXT, -- extracted text content (thinking + text blocks, truncated to 10k chars)
|
text TEXT, -- extracted text content (thinking + text blocks, truncated to 10k chars)
|
||||||
|
content_type TEXT, -- "text", "thinking", "tool_use", "tool_result", or "unknown"
|
||||||
|
is_meta INTEGER DEFAULT 0, -- 1 for injected/control-plane transcript messages
|
||||||
model TEXT, -- model name (e.g. "claude-opus-4-6-20250529"), NULL for user messages
|
model TEXT, -- model name (e.g. "claude-opus-4-6-20250529"), NULL for user messages
|
||||||
is_sidechain INTEGER DEFAULT 0, -- 1 if this message is on a sidechain (retry/branch)
|
is_sidechain INTEGER DEFAULT 0, -- 1 if this message is on a sidechain (retry/branch)
|
||||||
agent_id TEXT, -- subagent or workflow agent UUID (NULL for main conversation)
|
agent_id TEXT, -- subagent or workflow agent UUID (NULL for main conversation)
|
||||||
@@ -54,6 +56,22 @@ CREATE TABLE messages (
|
|||||||
|
|
||||||
Indexes: `idx_messages_session(session_id)`, `idx_messages_agent(agent_id)`, `idx_messages_ts(session_id, timestamp)`.
|
Indexes: `idx_messages_session(session_id)`, `idx_messages_agent(agent_id)`, `idx_messages_ts(session_id, timestamp)`.
|
||||||
|
|
||||||
|
`content_type` preserves the top-level Claude Code content block shape for the
|
||||||
|
message row. Treat `text` as user/assistant visible language, `thinking` as
|
||||||
|
trace/debug material, and `tool_use` as a marker that the assistant message
|
||||||
|
contains tool calls. `tool_result` marks a tool-result message, but the
|
||||||
|
structured payload remains in `tool_results`. Tool-call details remain in
|
||||||
|
`tool_calls`. Messages whose top-level content is not one of these four raw
|
||||||
|
message surfaces are `unknown`. Real user input is represented by `type='user'`
|
||||||
|
and `content_type='text'`, not by a separate `user_message` content type.
|
||||||
|
|
||||||
|
`is_meta` marks transcript control-plane content: injected caveats, command
|
||||||
|
envelopes such as `<command-name>/exit</command-name>`, and similar messages
|
||||||
|
that may appear as user-role text but are not ordinary user intent. It is
|
||||||
|
separate from `type`, `role`, and `content_type`. Default helpers hide meta
|
||||||
|
messages from ordinary recall; use `includeMeta: true` or explicit SQL when
|
||||||
|
investigating injected context, command messages, or transcript structure.
|
||||||
|
|
||||||
### messages_fts
|
### messages_fts
|
||||||
|
|
||||||
FTS5 virtual table for full-text search over message text.
|
FTS5 virtual table for full-text search over message text.
|
||||||
@@ -70,6 +88,26 @@ CREATE VIRTUAL TABLE messages_fts USING fts5(
|
|||||||
|
|
||||||
Queried via `MATCH` syntax. Rebuilt on each index pass.
|
Queried via `MATCH` syntax. Rebuilt on each index pass.
|
||||||
|
|
||||||
|
### memories_fts
|
||||||
|
|
||||||
|
FTS5 virtual table for ranked memory recall over registered memory summaries
|
||||||
|
and paths.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
||||||
|
id UNINDEXED, -- memory record ID, carried for inspection
|
||||||
|
path, -- searchable memory file path
|
||||||
|
summary, -- searchable compact memory summary
|
||||||
|
content=memories,
|
||||||
|
content_rowid=rowid,
|
||||||
|
tokenize='unicode61 remove_diacritics 1'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`memories({ query })` queries this table with safe tokenization and joins back to
|
||||||
|
`memories`, omitting archived rows. It is rebuilt during index finalization;
|
||||||
|
`remember()` also inserts the new memory row into FTS immediately.
|
||||||
|
|
||||||
### tool_calls
|
### tool_calls
|
||||||
|
|
||||||
Every tool invocation by the assistant. One row per `tool_use` content block.
|
Every tool invocation by the assistant. One row per `tool_use` content block.
|
||||||
@@ -191,14 +229,22 @@ CREATE TABLE memories (
|
|||||||
message_start TEXT, -- first relevant message UUID, if known
|
message_start TEXT, -- first relevant message UUID, if known
|
||||||
message_end TEXT, -- last relevant message UUID, if known
|
message_end TEXT, -- last relevant message UUID, if known
|
||||||
path TEXT, -- normalized absolute markdown memory file path
|
path TEXT, -- normalized absolute markdown memory file path
|
||||||
|
anchors TEXT, -- optional JSON array of recall anchors
|
||||||
summary TEXT, -- retrieval summary of the memory
|
summary TEXT, -- retrieval summary of the memory
|
||||||
created_at TEXT -- ISO 8601 registration time
|
created_at TEXT, -- ISO 8601 registration time
|
||||||
|
deleted_at TEXT, -- ISO 8601 archive time, if forgotten
|
||||||
|
deleted_reason TEXT -- human/agent deletion reason, if forgotten
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Indexes: `idx_memories_project(project)`,
|
Indexes: `idx_memories_project(project)`,
|
||||||
`idx_memories_session(session_id)`, `idx_memories_created(created_at)`.
|
`idx_memories_session(session_id)`, `idx_memories_created(created_at)`.
|
||||||
|
|
||||||
|
Active memory means `deleted_at IS NULL`. Recall helpers return active memories
|
||||||
|
only. Archived memories are management/audit data, not recall data. Query recall
|
||||||
|
uses `memories_fts` joined back to `memories`; when using raw SQL for memory
|
||||||
|
recall, include `deleted_at IS NULL`.
|
||||||
|
|
||||||
### Key Relationships
|
### Key Relationships
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -221,8 +267,8 @@ workflows.run_id <-- workflow_agents.run_id
|
|||||||
|
|
||||||
## 2. Query API Reference
|
## 2. Query API Reference
|
||||||
|
|
||||||
Read helpers are available as globals inside `--query` scripts. Memory write
|
Read helpers are available as globals inside `--query` scripts. Memory mutation
|
||||||
helpers are available only inside `--remember` scripts. Scripts run in an async
|
helpers are available only inside `--attune` scripts. Scripts run in an async
|
||||||
IIFE with a 30-second timeout.
|
IIFE with a 30-second timeout.
|
||||||
|
|
||||||
### Simple Layer
|
### Simple Layer
|
||||||
@@ -240,6 +286,7 @@ Full-text search across all message text using FTS5.
|
|||||||
| `opts.after` | `string` | ISO 8601 lower bound on timestamp |
|
| `opts.after` | `string` | ISO 8601 lower bound on timestamp |
|
||||||
| `opts.before` | `string` | ISO 8601 upper bound on timestamp |
|
| `opts.before` | `string` | ISO 8601 upper bound on timestamp |
|
||||||
| `opts.cwd` | `string` | Filter by working directory (supports LIKE) |
|
| `opts.cwd` | `string` | Filter by working directory (supports LIKE) |
|
||||||
|
| `opts.includeMeta` | `boolean` | Include injected/control-plane messages (default `false`) |
|
||||||
|
|
||||||
**Scope note:** `sessions.project` is the stored Claude Code project slug,
|
**Scope note:** `sessions.project` is the stored Claude Code project slug,
|
||||||
`sessions.project_path` is the absolute session path derived from message `cwd`
|
`sessions.project_path` is the absolute session path derived from message `cwd`
|
||||||
@@ -248,15 +295,22 @@ Helper `project` filters are fuzzy `LIKE` filters over `sessions.project`. For
|
|||||||
exact project membership, use `sql()` with `s.project = ?` or
|
exact project membership, use `sql()` with `s.project = ?` or
|
||||||
`s.project_path = ?`.
|
`s.project_path = ?`.
|
||||||
|
|
||||||
**Returns:** `Array<{ message, session, rank, context }>` where `context` is the
|
**Returns:** `Array<{ message, session, rank, context }>` where `message`
|
||||||
6 nearest messages by timestamp in the same session. It is temporal neighbor
|
includes `{ uuid, text, content_type, is_meta, role, timestamp, model, cwd }`
|
||||||
context, not a parent chain. `rank` is the FTS5 relevance score used by
|
and `context` is the 6 nearest non-meta messages by timestamp in the same
|
||||||
`ORDER BY rank`; lower values sort earlier, so treat the returned order as the
|
session unless `includeMeta: true` is passed. It is temporal neighbor context,
|
||||||
relevance order unless you are deliberately using FTS5 ranking details.
|
not a parent chain. `rank` is the FTS5 relevance score used by `ORDER BY rank`;
|
||||||
|
lower values sort earlier, so treat the returned order as the relevance order
|
||||||
|
unless you are deliberately using FTS5 ranking details.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const hits = search('MCTS exploration');
|
const hits = search('MCTS exploration');
|
||||||
return hits.map(h => ({ title: h.session.title, text: h.message.text?.slice(0, 200) }));
|
return hits.map(h => ({
|
||||||
|
title: h.session.title,
|
||||||
|
content_type: h.message.content_type,
|
||||||
|
is_meta: h.message.is_meta,
|
||||||
|
text: h.message.text?.slice(0, 200),
|
||||||
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `context(uuid)`
|
#### `context(uuid)`
|
||||||
@@ -285,8 +339,8 @@ Read-only SQL with parameterized bindings. Returns an array of row objects.
|
|||||||
|
|
||||||
**Returns:** `Array<Object>` -- each row as `{ column: value }`.
|
**Returns:** `Array<Object>` -- each row as `{ column: value }`.
|
||||||
|
|
||||||
Write statements are rejected. Use `--remember` and `remember()` for memory
|
Write statements are rejected. Use `--attune` with `remember()` or `forget()`
|
||||||
registration after user approval.
|
for memory mutation after user approval.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const rows = sql('SELECT id, title FROM sessions WHERE project = ? ORDER BY ended_at DESC LIMIT 5', 'Users-tomiya-Code-quiet-zero');
|
const rows = sql('SELECT id, title FROM sessions WHERE project = ? ORDER BY ended_at DESC LIMIT 5', 'Users-tomiya-Code-quiet-zero');
|
||||||
@@ -306,9 +360,11 @@ const chain = trace('some-uuid');
|
|||||||
return chain.map(m => ({ role: m.role, text: m.text?.slice(0, 100) }));
|
return chain.map(m => ({ role: m.role, text: m.text?.slice(0, 100) }));
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `thread(sessionId)`
|
#### `thread(sessionId, opts?)`
|
||||||
|
|
||||||
All messages in a session, ordered by timestamp.
|
Session messages ordered by timestamp. Meta messages are omitted by default;
|
||||||
|
pass `{ includeMeta: true }` to include injected caveats, command envelopes, and
|
||||||
|
other control-plane transcript rows.
|
||||||
|
|
||||||
**Returns:** `Array<message>`.
|
**Returns:** `Array<message>`.
|
||||||
|
|
||||||
@@ -457,7 +513,7 @@ from `process.cwd()` against `sessions.project_path`, then from exact
|
|||||||
],
|
],
|
||||||
memory_total,
|
memory_total,
|
||||||
memories: [
|
memories: [
|
||||||
{ id, path, summary, session_id, project, created_at }
|
{ id, path, anchors, summary, session_id, project, created_at }
|
||||||
]
|
]
|
||||||
} | null,
|
} | null,
|
||||||
projects: [
|
projects: [
|
||||||
@@ -491,6 +547,7 @@ return {
|
|||||||
memories: map.current_project?.memories.map(m => ({
|
memories: map.current_project?.memories.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
path: m.path,
|
path: m.path,
|
||||||
|
anchors: m.anchors,
|
||||||
summary: m.summary,
|
summary: m.summary,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -522,12 +579,12 @@ return qz.map(s => ({ title: s.title, branch: s.git_branch, ended: s.ended_at })
|
|||||||
|
|
||||||
#### `memories(opts?)`
|
#### `memories(opts?)`
|
||||||
|
|
||||||
Registered markdown memory records. Like other list helpers, passing a string
|
Active registered markdown memory records. Like other list helpers, passing a
|
||||||
is treated as `sessionId`, and passing a number is treated as `limit`.
|
string is treated as `sessionId`, and passing a number is treated as `limit`.
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `opts.query` | `string` | English term filter over `summary` and `path`; hyphens/underscores are treated as spaces |
|
| `opts.query` | `string` | English FTS recall query over `summary` and `path`; hyphens/underscores/punctuation are safely tokenized |
|
||||||
| `opts.project` | `string` | SQL `LIKE` pattern over `memories.project` |
|
| `opts.project` | `string` | SQL `LIKE` pattern over `memories.project` |
|
||||||
| `opts.sessionId` | `string` | Restrict to one source session |
|
| `opts.sessionId` | `string` | Restrict to one source session |
|
||||||
| `opts.sessions` | `string[]` | Restrict to a set of source session IDs |
|
| `opts.sessions` | `string[]` | Restrict to a set of source session IDs |
|
||||||
@@ -536,9 +593,13 @@ is treated as `sessionId`, and passing a number is treated as `limit`.
|
|||||||
| `opts.branch` | `string` | Filter by source session git branch (exact match) |
|
| `opts.branch` | `string` | Filter by source session git branch (exact match) |
|
||||||
| `opts.limit` | `number` | Max results (default 50) |
|
| `opts.limit` | `number` | Max results (default 50) |
|
||||||
|
|
||||||
**Returns:** `Array<memory_row>` ordered by `created_at` descending.
|
**Returns:** `Array<memory_row & { rank?: number }>` with archived memories
|
||||||
|
omitted. Without `query`, results are ordered by `created_at` descending. With
|
||||||
|
`query`, results are ordered by FTS rank first, then `created_at` descending;
|
||||||
|
lower rank sorts earlier.
|
||||||
|
|
||||||
`query` is a lightweight English term filter, not FTS5 ranking. Translate
|
`query` uses safe FTS5 tokenization rather than raw `MATCH`, so punctuation-only
|
||||||
|
queries return no rows instead of broadening into all memories. Translate
|
||||||
non-English user requests into concise English query terms before calling
|
non-English user requests into concise English query terms before calling
|
||||||
`memories()`. Use it to avoid pulling all recent memories, then read the
|
`memories()`. Use it to avoid pulling all recent memories, then read the
|
||||||
markdown file at `path` when a memory looks relevant. The runtime rejects
|
markdown file at `path` when a memory looks relevant. The runtime rejects
|
||||||
@@ -553,6 +614,7 @@ const prior = memories({
|
|||||||
return prior.map(m => ({
|
return prior.map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
path: m.path,
|
path: m.path,
|
||||||
|
anchors: m.anchors,
|
||||||
session_id: m.session_id,
|
session_id: m.session_id,
|
||||||
summary: m.summary?.slice(0, 240),
|
summary: m.summary?.slice(0, 240),
|
||||||
}));
|
}));
|
||||||
@@ -562,11 +624,11 @@ return prior.map(m => ({
|
|||||||
|
|
||||||
Register a human-approved markdown memory file. This is a write helper, not a
|
Register a human-approved markdown memory file. This is a write helper, not a
|
||||||
recall helper; use it only after the user has approved writing memory. It is
|
recall helper; use it only after the user has approved writing memory. It is
|
||||||
available only in scripts run with `runtime.mjs --remember`.
|
available only in scripts run with `runtime.mjs --attune`.
|
||||||
|
|
||||||
`--remember` exposes only `remember()`, not `search()`, `sql()`, `memories()`,
|
`--attune` exposes only `remember()` and `forget()`, not `search()`, `sql()`,
|
||||||
or other retrieval helpers. If source IDs are unknown, find them first with a
|
`memories()`, or other retrieval helpers. If source IDs or memory IDs are
|
||||||
normal `--query` script.
|
unknown, find them first with a normal `--query` script.
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
@@ -576,12 +638,14 @@ normal `--query` script.
|
|||||||
| `record.message_start` | `string` | First relevant source message UUID, if known |
|
| `record.message_start` | `string` | First relevant source message UUID, if known |
|
||||||
| `record.message_end` | `string` | Last relevant source message UUID, if known |
|
| `record.message_end` | `string` | Last relevant source message UUID, if known |
|
||||||
| `record.project` | `string` | Project slug override. Defaults from `sessions.project` for `session_id` |
|
| `record.project` | `string` | Project slug override. Defaults from `sessions.project` for `session_id` |
|
||||||
|
| `record.anchors` | `array` or JSON `string` | Optional recall anchors stored as JSON text. Expected shape is an array of objects, such as `{ kind: 'file', path: 'src/index/builder.ts' }` |
|
||||||
|
|
||||||
`remember()` validates that `path` exists and is a regular file, and rejects
|
`remember()` validates that `path` exists and is a regular file, and rejects
|
||||||
obvious CJK text in `summary`. It stores the normalized absolute path in
|
obvious CJK text in `summary`. It stores the normalized absolute path in
|
||||||
`memories.path`.
|
`memories.path`. `anchors` is nullable; omit it or pass an empty array when the
|
||||||
|
memory has no explicit file or object anchors.
|
||||||
|
|
||||||
**Returns:** `{ id, path, project, created_at }`.
|
**Returns:** `{ id, path, project, anchors, created_at }`.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
return remember({
|
return remember({
|
||||||
@@ -589,10 +653,53 @@ return remember({
|
|||||||
session_id: 'source-session-id',
|
session_id: 'source-session-id',
|
||||||
message_start: 'first-message-uuid',
|
message_start: 'first-message-uuid',
|
||||||
message_end: 'last-message-uuid',
|
message_end: 'last-message-uuid',
|
||||||
|
anchors: [{ kind: 'file', path: 'src/index/builder.ts' }],
|
||||||
summary: 'Decision: keep Obelisk as one user-facing entry that queries both memory and raw session evidence. Memory is prior notes, not final authority.',
|
summary: 'Decision: keep Obelisk as one user-facing entry that queries both memory and raw session evidence. Memory is prior notes, not final authority.',
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `forget(record)`
|
||||||
|
|
||||||
|
Archive a human-approved memory record. Use it when the user says a memory is
|
||||||
|
outdated, wrong, or should be forgotten. It is available only in scripts run
|
||||||
|
with `runtime.mjs --attune`.
|
||||||
|
|
||||||
|
`forget()` requires a precise memory ID. Do not pass a query string and let the
|
||||||
|
helper choose. If the ID is unknown, first use a normal `--query` script with
|
||||||
|
`memories()` to identify candidates. If exactly one candidate clearly matches
|
||||||
|
the user's request, the request is approval to archive it. If multiple memories
|
||||||
|
could match, ask the user which one to forget.
|
||||||
|
|
||||||
|
| Param | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `record.id` | `string` | Memory record ID to archive |
|
||||||
|
| `record.reason` | `string` | Required reason for audit and future management views |
|
||||||
|
|
||||||
|
`forget()` sets `deleted_at` and `deleted_reason`. It does not delete the
|
||||||
|
markdown file at `path`. Active recall helpers omit archived memories.
|
||||||
|
|
||||||
|
**Returns:** `{ id, deleted_at, deleted_reason }`, or the same fields plus
|
||||||
|
`already_deleted: true` if the record had already been forgotten.
|
||||||
|
|
||||||
|
```js
|
||||||
|
return forget({
|
||||||
|
id: 'mem-20260610-example',
|
||||||
|
reason: 'Outdated by newer project guidance.',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Memory Mutation Approval
|
||||||
|
|
||||||
|
Agents may decide whether to use, ignore, or verify memory in a single answer
|
||||||
|
without approval. Approval is required only for persistent memory mutations.
|
||||||
|
When the user explicitly says a memory is wrong, outdated, should be forgotten,
|
||||||
|
or should say something else, that utterance is approval to mutate the exact
|
||||||
|
matching memory. If multiple memories could match, ask the user to choose.
|
||||||
|
|
||||||
|
Updating is not an in-place edit. Archive the old record with `forget()`, then
|
||||||
|
write and register a replacement markdown file with `remember()` under the same
|
||||||
|
approval.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Common Query Patterns
|
## 3. Common Query Patterns
|
||||||
|
|||||||
+51
-3
@@ -16,7 +16,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
message_count INTEGER DEFAULT 0, jsonl_path TEXT);
|
message_count INTEGER DEFAULT 0, jsonl_path TEXT);
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
|
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
|
||||||
timestamp TEXT, role TEXT, text TEXT, model TEXT,
|
timestamp TEXT, role TEXT, text TEXT, content_type TEXT,
|
||||||
|
is_meta INTEGER DEFAULT 0, model TEXT,
|
||||||
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
|
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
|
||||||
input_tokens INTEGER, output_tokens INTEGER,
|
input_tokens INTEGER, output_tokens INTEGER,
|
||||||
cwd TEXT, skill TEXT, turn_duration_ms INTEGER);
|
cwd TEXT, skill TEXT, turn_duration_ms INTEGER);
|
||||||
@@ -57,7 +58,12 @@ CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id);
|
|||||||
CREATE TABLE IF NOT EXISTS memories (
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
|
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
|
||||||
message_start TEXT, message_end TEXT,
|
message_start TEXT, message_end TEXT,
|
||||||
path TEXT, summary TEXT, created_at TEXT);
|
path TEXT, anchors TEXT, summary TEXT, created_at TEXT,
|
||||||
|
deleted_at TEXT, deleted_reason TEXT);
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||||
|
id UNINDEXED, path, summary,
|
||||||
|
content=memories, content_rowid=rowid,
|
||||||
|
tokenize='unicode61 remove_diacritics 1');
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
|
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
|
||||||
@@ -68,9 +74,27 @@ function openDb() {
|
|||||||
db.exec('PRAGMA journal_mode=WAL');
|
db.exec('PRAGMA journal_mode=WAL');
|
||||||
db.exec('PRAGMA synchronous=NORMAL');
|
db.exec('PRAGMA synchronous=NORMAL');
|
||||||
db.exec(SCHEMA);
|
db.exec(SCHEMA);
|
||||||
|
migrateDb(db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureColumn(db, table, column, definition) {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
|
||||||
|
if (!columns.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateDb(db) {
|
||||||
|
ensureColumn(db, 'messages', 'content_type', 'TEXT');
|
||||||
|
ensureColumn(db, 'messages', 'is_meta', 'INTEGER DEFAULT 0');
|
||||||
|
ensureColumn(db, 'memories', 'anchors', 'TEXT');
|
||||||
|
ensureColumn(db, 'memories', 'deleted_at', 'TEXT');
|
||||||
|
ensureColumn(db, 'memories', 'deleted_reason', 'TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMemoryFts(db) {
|
||||||
|
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
|
||||||
|
}
|
||||||
|
|
||||||
function trunc(s) {
|
function trunc(s) {
|
||||||
return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s;
|
return typeof s === 'string' && s.length > TEXT_LIMIT ? s.slice(0, TEXT_LIMIT) : s;
|
||||||
}
|
}
|
||||||
@@ -101,6 +125,30 @@ function extractText(content) {
|
|||||||
return parts.length ? trunc(parts.join('\n')) : null;
|
return parts.length ? trunc(parts.join('\n')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractContentType(content) {
|
||||||
|
if (typeof content === 'string') return 'text';
|
||||||
|
if (!Array.isArray(content) || !content.length) return 'unknown';
|
||||||
|
const types = new Set();
|
||||||
|
let sawUnknown = false;
|
||||||
|
for (const b of content) {
|
||||||
|
if (!b || typeof b !== 'object') { sawUnknown = true; continue; }
|
||||||
|
if (b.type === 'text') types.add('text');
|
||||||
|
else if (b.type === 'thinking') types.add('thinking');
|
||||||
|
else if (b.type === 'tool_use') types.add('tool_use');
|
||||||
|
else if (b.type === 'tool_result') types.add('tool_result');
|
||||||
|
else sawUnknown = true;
|
||||||
|
}
|
||||||
|
return !sawUnknown && types.size === 1 ? [...types][0] : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_ENVELOPE_RE = /^\s*(<command-name>[^<]+<\/command-name>|<task-notification>|<local-command-caveat>|<local-command-stdout>)/;
|
||||||
|
|
||||||
|
function extractMessageIsMeta(record, text = extractText(record?.message?.content)) {
|
||||||
|
const msg = record?.message || {};
|
||||||
|
if (record?.isMeta === true || msg.isMeta === true) return 1;
|
||||||
|
return typeof text === 'string' && COMMAND_ENVELOPE_RE.test(text) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function filePath(name, input) {
|
function filePath(name, input) {
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
return ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(name) ? (input.file_path || null) : null;
|
return ['Read', 'Edit', 'Write', 'NotebookEdit'].includes(name) ? (input.file_path || null) : null;
|
||||||
@@ -129,4 +177,4 @@ function readLines(filePath, callback) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CLAUDE_DIR, DB_PATH, TEXT_LIMIT, openDb, trunc, truncJson, extractText, filePath, isDir, readLines, fs, path, os };
|
export { CLAUDE_DIR, DB_PATH, TEXT_LIMIT, openDb, rebuildMemoryFts, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines, fs, path, os };
|
||||||
|
|||||||
+10
-3
@@ -1,4 +1,4 @@
|
|||||||
import { CLAUDE_DIR, openDb, trunc, truncJson, extractText, filePath, isDir, readLines, fs, path } from './db.mjs';
|
import { CLAUDE_DIR, openDb, rebuildMemoryFts, trunc, truncJson, extractText, extractContentType, extractMessageIsMeta, filePath, isDir, readLines, fs, path } from './db.mjs';
|
||||||
|
|
||||||
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
|
||||||
const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl');
|
const HISTORY_PATH = path.join(CLAUDE_DIR, 'history.jsonl');
|
||||||
@@ -82,7 +82,7 @@ function indexJsonl(db, fi) {
|
|||||||
|
|
||||||
const ins = {
|
const ins = {
|
||||||
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) VALUES (?,?,?,?,?,?,?,?,?,?)'),
|
ses: db.prepare('INSERT OR REPLACE INTO sessions (id,title,project,project_path,started_at,ended_at,git_branch,version,message_count,jsonl_path) VALUES (?,?,?,?,?,?,?,?,?,?)'),
|
||||||
msg: db.prepare('INSERT OR REPLACE INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
|
msg: db.prepare('INSERT OR REPLACE INTO messages (uuid,session_id,type,parent_uuid,timestamp,role,text,content_type,is_meta,model,is_sidechain,agent_id,input_tokens,output_tokens,cwd,skill) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'),
|
||||||
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'),
|
tc: db.prepare('INSERT OR REPLACE INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)'),
|
||||||
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'),
|
tr: db.prepare('INSERT OR REPLACE INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)'),
|
||||||
sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'),
|
sum: db.prepare('INSERT OR REPLACE INTO summaries (id,session_id,timestamp,source,content) VALUES (?,?,?,?,?)'),
|
||||||
@@ -129,12 +129,14 @@ function indexJsonl(db, fi) {
|
|||||||
|
|
||||||
const msg = obj.message || {};
|
const msg = obj.message || {};
|
||||||
const text = extractText(msg.content);
|
const text = extractText(msg.content);
|
||||||
|
const contentType = extractContentType(msg.content);
|
||||||
|
const isMeta = extractMessageIsMeta(obj, text);
|
||||||
const usage = msg.usage || {};
|
const usage = msg.usage || {};
|
||||||
const aid = fi.isSubagent ? fi.agentId : (obj.agentId || null);
|
const aid = fi.isSubagent ? fi.agentId : (obj.agentId || null);
|
||||||
|
|
||||||
if (obj.uuid) {
|
if (obj.uuid) {
|
||||||
ins.msg.run(obj.uuid, sid, obj.type, obj.parentUuid || null, ts,
|
ins.msg.run(obj.uuid, sid, obj.type, obj.parentUuid || null, ts,
|
||||||
msg.role || obj.type, text, msg.model || null,
|
msg.role || obj.type, text, contentType, isMeta, msg.model || null,
|
||||||
obj.isSidechain ? 1 : 0, aid, usage.input_tokens || null, usage.output_tokens || null,
|
obj.isSidechain ? 1 : 0, aid, usage.input_tokens || null, usage.output_tokens || null,
|
||||||
obj.cwd || null, obj.attributionSkill || null);
|
obj.cwd || null, obj.attributionSkill || null);
|
||||||
}
|
}
|
||||||
@@ -252,6 +254,10 @@ function buildIndex({ force = false } = {}) {
|
|||||||
if (last && Date.now() - last.mtime < BUILD_DEBOUNCE_MS) { db.close(); return; }
|
if (last && Date.now() - last.mtime < BUILD_DEBOUNCE_MS) { db.close(); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
db.prepare("DELETE FROM index_state WHERE jsonl_path != '__last_build__'").run();
|
||||||
|
}
|
||||||
|
|
||||||
const files = discoverJsonlFiles();
|
const files = discoverJsonlFiles();
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
@@ -270,6 +276,7 @@ function buildIndex({ force = false } = {}) {
|
|||||||
refreshSessionProjectPaths(db);
|
refreshSessionProjectPaths(db);
|
||||||
indexHistory(db);
|
indexHistory(db);
|
||||||
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
||||||
|
rebuildMemoryFts(db);
|
||||||
db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now());
|
db.prepare("INSERT OR REPLACE INTO index_state (jsonl_path, mtime, lines_processed) VALUES ('__last_build__', ?, 0)").run(Date.now());
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+91
-31
@@ -1,4 +1,4 @@
|
|||||||
import { openDb, readLines, fs, path } from './db.mjs';
|
import { readLines, fs, path } from './db.mjs';
|
||||||
|
|
||||||
function normalizeOpts(optsOrScalar, scalarKey = 'sessionId') {
|
function normalizeOpts(optsOrScalar, scalarKey = 'sessionId') {
|
||||||
if (optsOrScalar == null) return {};
|
if (optsOrScalar == null) return {};
|
||||||
@@ -45,6 +45,14 @@ function assertEnglishMemoryText(value, label) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSafeFtsQuery(text) {
|
||||||
|
const tokens = String(text || '').match(/[\p{Letter}\p{Number}]+/gu) || [];
|
||||||
|
return tokens
|
||||||
|
.slice(0, 12)
|
||||||
|
.map(token => `"${token}"`)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function createQueryApi(db) {
|
function createQueryApi(db) {
|
||||||
const q = (sql, ...p) => {
|
const q = (sql, ...p) => {
|
||||||
assertReadOnlySql(sql);
|
assertReadOnlySql(sql);
|
||||||
@@ -59,7 +67,7 @@ function createQueryApi(db) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const search = (text, opts = {}) => {
|
const search = (text, opts = {}) => {
|
||||||
const { limit = 20, sessionId, project, after, before, cwd } = opts;
|
const { limit = 20, sessionId, project, after, before, cwd, includeMeta = false } = opts;
|
||||||
let where = 'WHERE mf.text MATCH ?';
|
let where = 'WHERE mf.text MATCH ?';
|
||||||
const p = [text];
|
const p = [text];
|
||||||
if (sessionId) { where += ' AND mf.session_id=?'; p.push(sessionId); }
|
if (sessionId) { where += ' AND mf.session_id=?'; p.push(sessionId); }
|
||||||
@@ -67,19 +75,21 @@ function createQueryApi(db) {
|
|||||||
if (after) { where += ' AND m.timestamp>?'; p.push(after); }
|
if (after) { where += ' AND m.timestamp>?'; p.push(after); }
|
||||||
if (before) { where += ' AND m.timestamp<?'; p.push(before); }
|
if (before) { where += ' AND m.timestamp<?'; p.push(before); }
|
||||||
if (cwd) { where += ' AND m.cwd LIKE ?'; p.push(cwd); }
|
if (cwd) { where += ' AND m.cwd LIKE ?'; p.push(cwd); }
|
||||||
|
if (!includeMeta) where += ' AND COALESCE(m.is_meta,0)=0';
|
||||||
p.push(limit);
|
p.push(limit);
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT m.uuid,m.session_id,m.text,m.role,m.timestamp,m.model,m.cwd,
|
SELECT m.uuid,m.session_id,m.text,m.content_type,m.is_meta,m.role,m.timestamp,m.model,m.cwd,
|
||||||
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
|
s.id as s_id,s.title as s_title,s.project as s_project,s.started_at as s_started,
|
||||||
rank
|
rank
|
||||||
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
|
FROM messages_fts mf JOIN messages m ON m.uuid=mf.uuid LEFT JOIN sessions s ON s.id=m.session_id
|
||||||
${where} ORDER BY rank LIMIT ?`).all(...p);
|
${where} ORDER BY rank LIMIT ?`).all(...p);
|
||||||
return rows.map(r => {
|
return rows.map(r => {
|
||||||
|
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
|
||||||
const ctx = db.prepare(
|
const ctx = db.prepare(
|
||||||
'SELECT uuid,text,role,timestamp,model FROM messages WHERE session_id=? AND uuid!=? ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6'
|
`SELECT uuid,text,content_type,is_meta,role,timestamp,model FROM messages WHERE session_id=? AND uuid!=? ${metaClause} ORDER BY ABS(JULIANDAY(timestamp)-JULIANDAY(?)) LIMIT 6`
|
||||||
).all(r.session_id, r.uuid, r.timestamp).sort((a,b) => a.timestamp < b.timestamp ? -1 : 1);
|
).all(r.session_id, r.uuid, r.timestamp).sort((a,b) => a.timestamp < b.timestamp ? -1 : 1);
|
||||||
return {
|
return {
|
||||||
message: { uuid: r.uuid, text: r.text, role: r.role, timestamp: r.timestamp, model: r.model, cwd: r.cwd },
|
message: { uuid: r.uuid, text: r.text, content_type: r.content_type, is_meta: r.is_meta || 0, role: r.role, timestamp: r.timestamp, model: r.model, cwd: r.cwd },
|
||||||
session: { id: r.s_id, title: r.s_title, project: r.s_project, started_at: r.s_started },
|
session: { id: r.s_id, title: r.s_title, project: r.s_project, started_at: r.s_started },
|
||||||
rank: r.rank,
|
rank: r.rank,
|
||||||
context: ctx,
|
context: ctx,
|
||||||
@@ -110,7 +120,11 @@ function createQueryApi(db) {
|
|||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
const thread = (sid) => db.prepare('SELECT * FROM messages WHERE session_id=? ORDER BY timestamp').all(sid);
|
const thread = (sid, opts = {}) => {
|
||||||
|
const includeMeta = opts?.includeMeta === true;
|
||||||
|
const metaClause = includeMeta ? '' : 'AND COALESCE(is_meta,0)=0';
|
||||||
|
return db.prepare(`SELECT * FROM messages WHERE session_id=? ${metaClause} ORDER BY timestamp`).all(sid);
|
||||||
|
};
|
||||||
|
|
||||||
const subagents = (optsOrSid) => {
|
const subagents = (optsOrSid) => {
|
||||||
const opts = normalizeOpts(optsOrSid);
|
const opts = normalizeOpts(optsOrSid);
|
||||||
@@ -267,7 +281,7 @@ function createQueryApi(db) {
|
|||||||
WITH names AS (
|
WITH names AS (
|
||||||
SELECT project FROM sessions WHERE project IS NOT NULL GROUP BY project
|
SELECT project FROM sessions WHERE project IS NOT NULL GROUP BY project
|
||||||
UNION
|
UNION
|
||||||
SELECT project FROM memories WHERE project IS NOT NULL GROUP BY project
|
SELECT project FROM memories WHERE project IS NOT NULL AND deleted_at IS NULL GROUP BY project
|
||||||
),
|
),
|
||||||
session_stats AS (
|
session_stats AS (
|
||||||
SELECT project, COUNT(*) AS session_count, MAX(COALESCE(ended_at, started_at)) AS last_session_at
|
SELECT project, COUNT(*) AS session_count, MAX(COALESCE(ended_at, started_at)) AS last_session_at
|
||||||
@@ -278,7 +292,7 @@ function createQueryApi(db) {
|
|||||||
memory_stats AS (
|
memory_stats AS (
|
||||||
SELECT project, COUNT(*) AS memory_count, MAX(created_at) AS last_memory_at
|
SELECT project, COUNT(*) AS memory_count, MAX(created_at) AS last_memory_at
|
||||||
FROM memories
|
FROM memories
|
||||||
WHERE project IS NOT NULL
|
WHERE project IS NOT NULL AND deleted_at IS NULL
|
||||||
GROUP BY project
|
GROUP BY project
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -322,11 +336,11 @@ function createQueryApi(db) {
|
|||||||
ORDER BY COALESCE(ended_at, started_at) DESC
|
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(currentProject.project, sessionLimit);
|
`).all(currentProject.project, sessionLimit);
|
||||||
const memoryTotal = db.prepare('SELECT COUNT(*) AS c FROM memories WHERE project = ?').get(currentProject.project)?.c || 0;
|
const memoryTotal = db.prepare('SELECT COUNT(*) AS c FROM memories WHERE project = ? AND deleted_at IS NULL').get(currentProject.project)?.c || 0;
|
||||||
const memoriesForProject = db.prepare(`
|
const memoriesForProject = db.prepare(`
|
||||||
SELECT id, path, summary, session_id, project, created_at
|
SELECT id, path, anchors, summary, session_id, project, created_at
|
||||||
FROM memories
|
FROM memories
|
||||||
WHERE project = ?
|
WHERE project = ? AND deleted_at IS NULL
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(currentProject.project, memoryLimit);
|
`).all(currentProject.project, memoryLimit);
|
||||||
@@ -345,11 +359,11 @@ function createQueryApi(db) {
|
|||||||
FROM (
|
FROM (
|
||||||
SELECT project FROM sessions WHERE project IS NOT NULL GROUP BY project
|
SELECT project FROM sessions WHERE project IS NOT NULL GROUP BY project
|
||||||
UNION
|
UNION
|
||||||
SELECT project FROM memories WHERE project IS NOT NULL GROUP BY project
|
SELECT project FROM memories WHERE project IS NOT NULL AND deleted_at IS NULL GROUP BY project
|
||||||
)
|
)
|
||||||
`).get()?.c || 0;
|
`).get()?.c || 0;
|
||||||
const totalSessions = db.prepare('SELECT COUNT(*) AS c FROM sessions').get()?.c || 0;
|
const totalSessions = db.prepare('SELECT COUNT(*) AS c FROM sessions').get()?.c || 0;
|
||||||
const totalMemories = db.prepare('SELECT COUNT(*) AS c FROM memories').get()?.c || 0;
|
const totalMemories = db.prepare('SELECT COUNT(*) AS c FROM memories WHERE deleted_at IS NULL').get()?.c || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: {
|
current: {
|
||||||
@@ -422,25 +436,32 @@ function createQueryApi(db) {
|
|||||||
timestamp: 'mem.created_at',
|
timestamp: 'mem.created_at',
|
||||||
branch: 's.git_branch',
|
branch: 's.git_branch',
|
||||||
});
|
});
|
||||||
const terms = String(query || '')
|
let where = baseWhere + ' AND mem.deleted_at IS NULL';
|
||||||
.trim()
|
|
||||||
.replace(/[-_]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(Boolean);
|
|
||||||
let where = baseWhere;
|
|
||||||
for (const term of terms) {
|
|
||||||
where += " AND lower(coalesce(mem.summary,'') || ' ' || coalesce(mem.path,'')) LIKE ?";
|
|
||||||
params.push(`%${term.toLowerCase()}%`);
|
|
||||||
}
|
|
||||||
params.push(limit);
|
|
||||||
const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=mem.session_id' : '';
|
const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=mem.session_id' : '';
|
||||||
|
const hasQuery = String(query || '').trim().length > 0;
|
||||||
|
const ftsQuery = buildSafeFtsQuery(query);
|
||||||
|
if (!hasQuery) {
|
||||||
|
params.push(limit);
|
||||||
return db.prepare(`SELECT mem.* FROM memories mem ${join} WHERE ${where} ORDER BY mem.created_at DESC LIMIT ?`).all(...params);
|
return db.prepare(`SELECT mem.* FROM memories mem ${join} WHERE ${where} ORDER BY mem.created_at DESC LIMIT ?`).all(...params);
|
||||||
|
}
|
||||||
|
if (!ftsQuery) return [];
|
||||||
|
params.unshift(ftsQuery);
|
||||||
|
params.push(limit);
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT mem.*, mf.rank AS rank
|
||||||
|
FROM memories_fts mf
|
||||||
|
JOIN memories mem ON mem.rowid = mf.rowid
|
||||||
|
${join}
|
||||||
|
WHERE memories_fts MATCH ? AND ${where}
|
||||||
|
ORDER BY mf.rank, mem.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(...params);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw, memories, overview };
|
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw, memories, overview };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRememberApi(db) {
|
function createAttuneApi(db) {
|
||||||
const resolveMemoryPath = (memoryPath, sessionId) => {
|
const resolveMemoryPath = (memoryPath, sessionId) => {
|
||||||
let base = null;
|
let base = null;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -459,19 +480,58 @@ function createRememberApi(db) {
|
|||||||
return resolved;
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project }) => {
|
const normalizeAnchors = (anchors) => {
|
||||||
|
if (anchors == null) return null;
|
||||||
|
let parsed = anchors;
|
||||||
|
if (typeof anchors === 'string') {
|
||||||
|
const trimmed = anchors.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
throw new Error('remember() anchors must be a JSON array');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) throw new Error('remember() anchors must be an array');
|
||||||
|
for (const anchor of parsed) {
|
||||||
|
if (!anchor || typeof anchor !== 'object' || Array.isArray(anchor)) {
|
||||||
|
throw new Error('remember() anchors entries must be objects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed.length ? JSON.stringify(parsed) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project, anchors }) => {
|
||||||
if (!memoryPath || !summary) throw new Error('remember() requires path and summary');
|
if (!memoryPath || !summary) throw new Error('remember() requires path and summary');
|
||||||
assertEnglishMemoryText(summary, 'remember() summary');
|
assertEnglishMemoryText(summary, 'remember() summary');
|
||||||
const normalizedPath = resolveMemoryPath(memoryPath, session_id);
|
const normalizedPath = resolveMemoryPath(memoryPath, session_id);
|
||||||
|
const normalizedAnchors = normalizeAnchors(anchors);
|
||||||
const id = `mem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const id = `mem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const proj = project || db.prepare('SELECT project FROM sessions WHERE id=?').get(session_id)?.project || null;
|
const proj = project || db.prepare('SELECT project FROM sessions WHERE id=?').get(session_id)?.project || null;
|
||||||
const created_at = new Date().toISOString();
|
const created_at = new Date().toISOString();
|
||||||
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, summary, created_at) VALUES (?,?,?,?,?,?,?,?)').run(
|
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, anchors, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?)').run(
|
||||||
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, summary, created_at);
|
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, normalizedAnchors, summary, created_at);
|
||||||
return { id, path: normalizedPath, project: proj, created_at };
|
db.prepare(`
|
||||||
|
INSERT INTO memories_fts(rowid, id, path, summary)
|
||||||
|
SELECT rowid, id, path, summary FROM memories WHERE id = ?
|
||||||
|
`).run(id);
|
||||||
|
return { id, path: normalizedPath, project: proj, anchors: normalizedAnchors, created_at };
|
||||||
};
|
};
|
||||||
|
|
||||||
return { remember };
|
const forget = ({ id, reason }) => {
|
||||||
|
const deletionReason = String(reason || '').trim();
|
||||||
|
if (!id || !deletionReason) throw new Error('forget() requires id and reason');
|
||||||
|
const row = db.prepare('SELECT id, deleted_at, deleted_reason FROM memories WHERE id=?').get(id);
|
||||||
|
if (!row) throw new Error(`forget() memory not found: ${id}`);
|
||||||
|
if (row.deleted_at) {
|
||||||
|
return { id, deleted_at: row.deleted_at, deleted_reason: row.deleted_reason, already_deleted: true };
|
||||||
|
}
|
||||||
|
const deleted_at = new Date().toISOString();
|
||||||
|
db.prepare('UPDATE memories SET deleted_at=?, deleted_reason=? WHERE id=?').run(deleted_at, deletionReason, id);
|
||||||
|
return { id, deleted_at, deleted_reason: deletionReason };
|
||||||
|
};
|
||||||
|
|
||||||
|
return { remember, forget };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { createQueryApi, createRememberApi };
|
export { createQueryApi, createAttuneApi };
|
||||||
|
|||||||
+6
-6
@@ -7,7 +7,7 @@ const vm = require('node:vm');
|
|||||||
|
|
||||||
import { DB_PATH, openDb } from './db.mjs';
|
import { DB_PATH, openDb } from './db.mjs';
|
||||||
import { buildIndex } from './indexer.mjs';
|
import { buildIndex } from './indexer.mjs';
|
||||||
import { createQueryApi, createRememberApi } from './query.mjs';
|
import { createQueryApi, createAttuneApi } from './query.mjs';
|
||||||
|
|
||||||
function executeScript(api, scriptContent) {
|
function executeScript(api, scriptContent) {
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
@@ -22,8 +22,8 @@ function executeQuery(db, scriptContent) {
|
|||||||
return executeScript(createQueryApi(db), scriptContent);
|
return executeScript(createQueryApi(db), scriptContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeRemember(db, scriptContent) {
|
function executeAttune(db, scriptContent) {
|
||||||
return executeScript(createRememberApi(db), scriptContent);
|
return executeScript(createAttuneApi(db), scriptContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
@@ -49,16 +49,16 @@ function main() {
|
|||||||
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (args[0] === '--remember' && args[1]) {
|
if (args[0] === '--attune' && args[1]) {
|
||||||
buildIndex();
|
buildIndex();
|
||||||
const db = openDb();
|
const db = openDb();
|
||||||
const script = fs.readFileSync(path.resolve(args[1]), 'utf8');
|
const script = fs.readFileSync(path.resolve(args[1]), 'utf8');
|
||||||
executeRemember(db, script)
|
executeAttune(db, script)
|
||||||
.then(r => { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); db.close(); })
|
.then(r => { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); db.close(); })
|
||||||
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query <file.js>\n node runtime.mjs --remember <file.js>\n');
|
process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query <file.js>\n node runtime.mjs --attune <file.js>\n');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user